Line Integral Convolution for Python, written in Rust
rLIC (pronounced 'relic') is a highly optimized, minimal implementation of the
Line Integral
Convolution algorithm
for in-memory numpy arrays, written in Rust.
rLIC is currently in beta. As of version 0.5.0, the only public API,
rlic.convolve, is considered feature complete and stable. However, minor
behavior changes may still happen, particularly where performance can be
improved as a result. The library as a whole may still grow additional APIs,
which wouldn't immediately be marked as stable.
rlic.convolve is trivially thread-safe, because it does not mutate any external
data. As of version 0.5.1, Wheels are not yet distributed for free-threaded
CPython, but this build target is still supported and tested.
python -m pip install rLIC
rLIC consists in a single Python function, rlic.convolve, that convolves a
texture image (usually noise) with a 2D vector field described by its
components u and v, via a 1D kernel array. The result is an image where
pixel intensity is strongly correlated along field lines.
Let's see an example. We'll use matplotlib to visualize inputs and outputs.
import matplotlib.pyplot as plt
import numpy as np
import rlic
SHAPE = NX, NY = (256, 256)
prng = np.random.default_rng(0)
texture = prng.random(SHAPE)
x = np.linspace(0, np.pi, NY)
U = np.broadcast_to(np.cos(2 * x), SHAPE)
V = np.broadcast_to(np.sin(x).T, SHAPE)
fig, axs = plt.subplots(ncols=2, sharex=True, sharey=True, figsize=(10, 5))
for ax in axs:
ax.set(aspect="equal", xticks=[], yticks=[])
ax = axs[0]
ax.set_title("Input texture (noise)")
ax.imshow(texture)
ax = axs[1]
ax.set_title("Input vector field")
Y, X = np.mgrid[0:NY, 0:NX]
ax.streamplot(X, Y, U, V)Now let's compute some convolutions, varying the number of iterations
kernel = 1 - np.abs(np.linspace(-1, 1, 65))
fig_out, axs_out = plt.subplots(ncols=3, figsize=(15, 5))
for ax in axs_out:
ax.set(aspect="equal", xticks=[], yticks=[])
for n, ax in zip((1, 5, 100), axs_out, strict=True):
image = rlic.convolve(
texture,
U,
V,
kernel=kernel,
boundaries="periodic",
iterations=n,
)
ax.set_title(f"Convolution result ({n} iteration(s))")
ax.imshow(image)By default, the direction of the vector field affects the result. That is, the
sign of each component matters. Such a vector field is analogous to a velocity
field. However, the sign of u or v may sometimes be irrelevant, and only
their absolute directions should be taken into account. Such a vector field is
analogous to a polarization field. rLIC supports this use case via an
additional keyword argument, uv_mode, which can be either 'velocity'
(default), or 'polarization'. In practice, the difference between these two
modes in only visible around sharps changes in sign in either u or v, and
with certain kernels.
Let's illustrate one such case
import matplotlib.pyplot as plt
import numpy as np
import rlic
SHAPE = NX, NY = (256, 256)
prng = np.random.default_rng(0)
texture = prng.random(SHAPE)
kernel = 1 - np.abs(np.linspace(-1, 1, 65, dtype="float64"))
U0 = np.ones(SHAPE)
ii = np.broadcast_to(np.arange(NX), SHAPE)
U = np.where(ii<NX/2, -U0, U0)
V = np.zeros((NX, NX))
fig, axs = plt.subplots(ncols=3, sharex=True, sharey=True, figsize=(15, 5))
for ax in axs:
ax.set(aspect="equal", xticks=[], yticks=[])
ax = axs[0]
ax.set_title("Input vector field")
Y, X = np.mgrid[0:NY, 0:NX]
ax.streamplot(X, Y, U, V)
for uv_mode, ax in zip(("velocity", "polarization"), axs[1:], strict=True):
image = rlic.convolve(
texture,
U,
V,
kernel=kernel,
uv_mode=uv_mode,
boundaries={"x": "periodic", "y": "closed"},
)
ax.set_title(f"{uv_mode=!r}")
ax.imshow(image)rLIC.convolve allocates exactly two buffers with the same size as texture,
u and v, regardless of the number of iterations performed, one of which is
discarded when the function returns. This means that peak usage is about 5/3 of
the amount needed to hold input data in memory, and usage drops to 4/3 on
return.


