A textured Ouchi Illusion

The Ouchi illusion is a powerful demonstration that static images may produce an illusory movement. One striking aspect is that it makes you feel quite dizzy from trying to compensate for this illusory movement.

ouchi.jpg

The illlusion is is generated by your own eye movements and is a consequence of the aperture problem, which is a fundamental problem in vision science. The aperture problem is the fact that the visual system can only integrate information along the direction of motion, and not perpendicular to it. This is because the visual system is made of a set of filters that are oriented in different directions, and the integration is done by summing the responses of these filters. The aperture problem is a problem because it means that the visual system cannot recover the direction of motion of a contour from the responses of these filters.

Here, we explore variations of this illusion which xwould use textures instead of regular angles using the MotionClouds library. The idea is to use the same texture in the two parts of the image (center vs surround), but to rotate by 90° the texture in the center:

my sweet ouchi

Optimizing the parameters of the texture would help tell us what matters to generate that illusion...

Let's first initialize the notebook:

In [1]:
import numpy as np
import matplotlib.pyplot as plt
fig_width = 10
figsize = (fig_width, fig_width)

install and load the library

In [2]:
%pip install MotionClouds
Requirement already satisfied: MotionClouds in /opt/homebrew/lib/python3.11/site-packages (20220927)
Requirement already satisfied: numpy in /opt/homebrew/lib/python3.11/site-packages (from MotionClouds) (1.26.2)
Note: you may need to restart the kernel to use updated packages.

In particular, we just generate one frame:

In [3]:
def sigmoid(x, # the input
            slope=61.803, # this slope is inverse gold times 100
            threshold=0.5 # the mean of x
            ):
    # return (x > threshold).astype(np.float)
    return 1 / (1 + np.exp(- slope * (x - threshold)))
In [4]:
import MotionClouds as mc
seed = 123456789
N = 512
mc.N_X, mc.N_Y, mc.N_frame = N, N, 1
fx, fy, ft = mc.get_grids(mc.N_X, mc.N_Y, mc.N_frame)
params = dict(theta=0, B_sf=.06, sf_0=.3, B_theta=.1)
params = dict(theta=np.pi/3, B_sf=.5, sf_0=.07, B_theta=.1)
params = dict(theta=0, B_sf=.2, sf_0=.2, B_theta=.25)
params = dict(theta=0, B_sf=.04, sf_0=.2, B_theta=.25)
env = mc.envelope_gabor(fx, fy, ft, **params)
image = mc.rectif(mc.random_cloud(env, seed=seed)).reshape((mc.N_X, mc.N_Y))
image = sigmoid(image)
In [5]:
import matplotlib.pyplot as plt
for key in ['xtick.bottom', 'xtick.labelbottom', 'ytick.left', 'ytick.labelleft']: plt.rcParams[key] = False
%matplotlib inline
fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(image, cmap=plt.gray())
No description has been provided for this image

define a crop function

The library has a representation of space that we may take advantage of:

In [6]:
fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(fx, cmap=plt.gray())
print(fx.min(), fx.max())
-0.5 0.498046875
No description has been provided for this image

We may easily define a central cropping mask:

In [7]:
rho = .2 
# mask = ((fx**2 + fy**2) < rho**2).squeeze()
mask = 1-sigmoid((fx**2 + fy**2) - rho**2, slope=1e3, threshold=0).squeeze()

fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(mask, cmap=plt.gray())
print(mask.min(), mask.max(), mask.shape)
0.0 1.0 (512, 512)
No description has been provided for this image

From this we define a cropping function

In [8]:
def crop_and_merge(image, mask, use_rot=True, use_fill=False, fill=.5):
    N_X, N_Y = image.shape

    image_fig = image.copy()
    if use_rot: image_fig = np.rot90(image_fig)
    image_fig = np.roll(image_fig, N_X//4 + int(N_X//2*np.random.rand()), axis=0 ) # roll over one axis
    image_fig = np.roll(image_fig, N_Y//4 + int(N_Y//2*np.random.rand()), axis=1 ) # roll over one axis    

    if use_fill:
        return image * (1-mask) + fill * mask
    else:
        return image * (1-mask) + image_fig * mask
        

vanilla textured Ouchi illusion

We may now define a function that generates the Ouchi illusion:

In [9]:
fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
No description has been provided for this image
In [10]:
fig.savefig('../files/2023-11-29-ouchi-illusion.png', dpi=300, bbox_inches='tight')

A lower frequency produces (in my own eyes) less perceived pulsation and makes the motion aspect of the illusion more powerful:

In [11]:
params_update = params.copy()
params_update.update(sf_0=0.12)
env = mc.envelope_gabor(fx, fy, ft, **params_update)
z = mc.rectif(mc.random_cloud(env, seed=seed))
image_low = z.reshape((mc.N_X, mc.N_Y))
image_low = sigmoid(image_low)

fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image_low, mask, use_rot=True), cmap=plt.gray())
No description has been provided for this image

and without:

In [12]:
fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask, use_rot=False), cmap=plt.gray())
No description has been provided for this image

We can also define a first-order figure:

In [13]:
fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask, use_fill=True), cmap=plt.gray())
No description has been provided for this image

A fun fact is that if you do not threshold the image and have the image in grayscale, the illusion disappears (still with a bit of motion, but not as strong):

In [14]:
params_update = params.copy()
params_update.update(sf_0=0.12)
env = mc.envelope_gabor(fx, fy, ft, **params)
z = mc.rectif(mc.random_cloud(env, seed=seed))
image = z.reshape((mc.N_X, mc.N_Y))

fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
No description has been provided for this image

Other masks:

In [15]:
image = mc.rectif(mc.random_cloud(env, seed=seed)).reshape((mc.N_X, mc.N_Y))
image = sigmoid(image)
mask_square = 1-sigmoid(np.abs(fx+fy) + np.abs(fx-fy) - 2*rho, slope=1e3, threshold=0).squeeze()

fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask_square, use_rot=True), cmap=plt.gray())
No description has been provided for this image
In [16]:
mask_cross = (np.abs(fx) < rho/2) * (np.abs(fy) < rho*2)
mask_cross += (np.abs(fx) < rho*2) * (np.abs(fy) < rho/2)
mask_cross = 1 - (mask_cross>0).squeeze()

fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask_cross, use_rot=True), cmap=plt.gray())
No description has been provided for this image
In [17]:
mask_bands = (np.sin(2*np.pi*fx*6)>0).squeeze()

fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask_bands, use_rot=True), cmap=plt.gray())
No description has been provided for this image
In [18]:
mask_chessboard = (np.sin(np.pi*fx*6)*np.sin(np.pi*fy*6)>0).squeeze()

fig, ax = plt.subplots(figsize=figsize)
_ = ax.imshow(crop_and_merge(image, mask_chessboard, use_rot=True), cmap=plt.gray())
No description has been provided for this image

changing parameters of the textured Ouchi illusion

We may now define a function that generates the Ouchi illusion:

In [19]:
for sf_0 in params['sf_0'] * np.geomspace(0.1, 3, 7):
    print(f'{sf_0=:.3f}')
    params_update = params.copy()
    params_update.update(sf_0=sf_0)
    env = mc.envelope_gabor(fx, fy, ft, **params_update)
    z = mc.rectif(mc.random_cloud(env, seed=seed))
    image = z.reshape((mc.N_X, mc.N_Y))
    image = sigmoid(image)
    
    fig, ax = plt.subplots(figsize=figsize)
    _ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
    plt.show()
sf_0=0.020
No description has been provided for this image
sf_0=0.035
No description has been provided for this image
sf_0=0.062
No description has been provided for this image
sf_0=0.110
No description has been provided for this image
sf_0=0.193
No description has been provided for this image
sf_0=0.340
No description has been provided for this image
sf_0=0.600
No description has been provided for this image
In [20]:
for B_sf in params['B_sf'] * np.geomspace(0.1, 10, 7):
    print(f'{B_sf=:.3f}')
    params_update = params.copy()
    params_update.update(B_sf=B_sf)
    env = mc.envelope_gabor(fx, fy, ft, **params_update)
    z = mc.rectif(mc.random_cloud(env, seed=seed))
    image = z.reshape((mc.N_X, mc.N_Y))
    image = sigmoid(image)

    fig, ax = plt.subplots(figsize=figsize)
    _ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
    plt.show()
B_sf=0.004
No description has been provided for this image
B_sf=0.009
No description has been provided for this image
B_sf=0.019
No description has been provided for this image
B_sf=0.040
No description has been provided for this image
B_sf=0.086
No description has been provided for this image
B_sf=0.186
No description has been provided for this image
B_sf=0.400
No description has been provided for this image
In [21]:
for theta in np.linspace(0, np.pi, 7, endpoint=False):
    print(f'{theta=:.3f}')
    params_update = params.copy()
    params_update.update(theta=theta)
    env = mc.envelope_gabor(fx, fy, ft, **params_update)
    z = mc.rectif(mc.random_cloud(env, seed=seed))
    image = z.reshape((mc.N_X, mc.N_Y))
    image = sigmoid(image)

    fig, ax = plt.subplots(figsize=figsize)
    _ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
    plt.show()
theta=0.000
No description has been provided for this image
theta=0.449
No description has been provided for this image
theta=0.898
No description has been provided for this image
theta=1.346
No description has been provided for this image
theta=1.795
No description has been provided for this image
theta=2.244
No description has been provided for this image
theta=2.693
No description has been provided for this image
In [22]:
for B_theta in params['B_theta'] * np.geomspace(0.3, 10, 7):
    print(f'{B_theta=:.3f} rad ({B_theta*180/np.pi:.1f}°)')
    params_update = params.copy()
    params_update.update(B_theta=B_theta)
    env = mc.envelope_gabor(fx, fy, ft, **params_update)
    z = mc.rectif(mc.random_cloud(env, seed=seed))
    image = z.reshape((mc.N_X, mc.N_Y))
    image = sigmoid(image)

    fig, ax = plt.subplots(figsize=figsize)
    _ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
    plt.show()
B_theta=0.075 rad (4.3°)
No description has been provided for this image
B_theta=0.135 rad (7.7°)
No description has been provided for this image
B_theta=0.241 rad (13.8°)
No description has been provided for this image
B_theta=0.433 rad (24.8°)
No description has been provided for this image
B_theta=0.777 rad (44.5°)
No description has been provided for this image
B_theta=1.394 rad (79.8°)
No description has been provided for this image
B_theta=2.500 rad (143.2°)
No description has been provided for this image

The alpha parameter gas little effect when the enveloppe is narrow:

In [23]:
# for alpha in np.linspace(-1, 2, 7):
#     print(f'{alpha=:.3f}')
#     params_update = params.copy()
#     params_update.update(alpha=alpha)
#     env = mc.envelope_gabor(fx, fy, ft, **params_update)
#     z = mc.rectif(mc.random_cloud(env, seed=seed))
#     image = z.reshape((mc.N_X, mc.N_Y))
#     image = sigmoid(image)
    
#     fig, ax = plt.subplots(figsize=figsize)
#     _ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
#     plt.show()
In [24]:
for slope in np.geomspace(1, 200, 7):
    print(f'{slope=:.3f}')
    env = mc.envelope_gabor(fx, fy, ft, **params)
    z = mc.rectif(mc.random_cloud(env, seed=seed))
    image = z.reshape((mc.N_X, mc.N_Y))
    image = sigmoid(image, slope=slope)
    
    fig, ax = plt.subplots(figsize=figsize)
    _ = ax.imshow(crop_and_merge(image, mask, use_rot=True), cmap=plt.gray())
    plt.show()
slope=1.000
No description has been provided for this image
slope=2.418
No description has been provided for this image
slope=5.848
No description has been provided for this image
slope=14.142
No description has been provided for this image
slope=34.200
No description has been provided for this image
slope=82.704
No description has been provided for this image
slope=200.000
No description has been provided for this image

some book keeping for the notebook

In [25]:
%load_ext watermark
%watermark -i -h -m -v -p numpy,MotionClouds,matplotlib  -r -g -b
Python implementation: CPython
Python version       : 3.11.6
IPython version      : 8.17.2

numpy       : 1.26.2
MotionClouds: 20220927
matplotlib  : 3.8.1

Compiler    : Clang 15.0.0 (clang-1500.0.40.1)
OS          : Darwin
Release     : 23.1.0
Machine     : arm64
Processor   : arm
CPU cores   : 10
Architecture: 64bit

Hostname: obiwan.local

Git hash: 91207b437e03f546b6c190aa83595a84621e3c6f

Git repo: https://github.com/laurentperrinet/sciblog.git

Git branch: master