Pixels2GenAI
Path i Foundations
M 01 · 1.2.1 · hands-on

1.2.1 Random Pattern Generation

Turn uniform random numbers into compelling visual compositions. Use Kronecker products for pixel-perfect scaling, seed-based reproducibility, and discrete colour palettes inspired by Gerhard Richter's colour charts.

Duration15–20 min
Levelbeginner
Load3 core concepts
Prereqs1.1.1 (RGB arrays)

Overview

Random number generation is a fundamental building block of computational art and generative design. In this lesson you will discover how simple random processes can create compelling visual patterns, from abstract colour compositions to structured artistic grids inspired by pioneers like Gerhard Richter [1].

Learning objectives

  1. Understand how uniform random distributions create unbiased colour sampling for image generation.
  2. Use Kronecker products to efficiently scale small pixel grids into pixel-perfect tile patterns.
  3. Recognise how colour space properties affect the visual perception of randomness.
  4. Create Richter-inspired discrete-palette compositions using index-based colour mapping.

Quick start — see randomness in action

Run this code to generate a random colour tile pattern:

python · quick_start.py
import numpy as np
from PIL import Image

# Set seed for reproducible randomness
np.random.seed(42)

# Create 10x10 grid of random RGB colors
random_colors = np.random.randint(0, 256, size=(10, 10, 3), dtype=np.uint8)

# Scale each color to a 20x20 pixel tile using Kronecker product
scaled_image = np.kron(random_colors, np.ones((20, 20, 1), dtype=np.uint8))

# Save the result
result = Image.fromarray(scaled_image)
result.save('quick_random_tiles.png')
10x10 grid of random coloured tiles, each tile is 20x20 pixels
Fig. 1 Random colour tiles generated using uniform distribution and Kronecker scaling.

Core concepts

Concept 1 — Uniform random distribution

The uniform distribution is the foundation of digital randomness. When you call np.random.randint(0, 256), every integer from 0 to 255 has an equal probability of being selected [2]. This creates what we perceive as “pure randomness.”

python · uniform_demo.py
import numpy as np

# Every RGB value has equal 1/256 probability
random_rgb = np.random.randint(0, 256, size=(5, 5, 3), dtype=np.uint8)

# This gives us 256^3 = 16,777,216 possible colors per pixel
total_colors = 256 ** 3
print(f"Total possible colors: {total_colors:,}")

For generative art, uniform distribution provides three key properties:

  • Unpredictability — no discernible patterns in individual elements.
  • Visual balance — all colours represented equally over large samples.
  • Infinite variety — each generation produces unique results.
A 10x10 grid of random coloured tiles demonstrating uniform distribution across the full RGB range
Fig. 2 Full-range uniform random tiles — 100 unique colours drawn from 16.7 million possibilities.

Concept 2 — Kronecker product for scaling

The Kronecker product (np.kron) is a linear algebra operation that efficiently scales images by repeating each pixel into a larger block [4]. Instead of writing nested loops, you get pixel-perfect tile scaling in a single function call.

python · kronecker_demo.py
import numpy as np

# Original: 2x2 array
small = np.array([[1, 2], [3, 4]])

# Scaling matrix: each element becomes a 3x3 block
scale = np.ones((3, 3))

# Kronecker product result: 6x6 array
large = np.kron(small, scale)
# Result: each original value repeated in 3x3 blocks
print(large)

For images, this transforms a small random grid into pixel-perfect tiles:

python · kronecker_image.py
import numpy as np

# Small random grid: 5x5x3 (75 total color values)
small_grid = np.random.randint(0, 256, size=(5, 5, 3), dtype=np.uint8)

# Scale each color to a 40x40 pixel tile
tile_size = np.ones((40, 40, 1), dtype=np.uint8)
large_image = np.kron(small_grid, tile_size)
# Result: 200x200x3 image (40,000 pixels, but only 75 unique color values)

The third dimension of the scaling matrix is 1 rather than 3 because each colour channel scales independently. The Kronecker product broadcasts the single-channel scale across all three RGB channels.

Concept 3 — Colour space considerations

When generating random colours, the choice of colour space dramatically affects the visual result [5]:

  • RGB space — uniform in red, green, and blue channels independently.
  • Perceptual uniformity — RGB is not perceptually uniform. Some random colours appear much brighter than others.
  • Gamut coverage — random RGB covers the entire digital colour palette, including colours that rarely appear in nature.
python · color_space.py
import numpy as np

# These are all equally "random" in RGB space
color1 = [255, 255, 255]  # Bright white
color2 = [128, 128, 128]  # Medium gray
color3 = [255, 0, 0]      # Pure red
color4 = [1, 1, 1]        # Nearly black

# But they have very different perceptual brightness!

This perceptual non-uniformity creates visually interesting compositions because the eye perceives some tiles as “popping forward” (bright colours) while others recede (dark colours), adding implicit depth to a flat, two-dimensional pattern.

A 10x10 grid of random grayscale tiles showing brightness variation
Fig. 3 Grayscale random tiles — when colour is removed, the brightness variation becomes especially visible.

Exercises

Three progressively challenging exercises, each building on the previous using the Execute → Modify → Create approach.

EXECUTE I.

Random colour tiles

Run the following code exactly as written and observe the output. This creates a grid of random coloured tiles using uniform distribution and Kronecker scaling.

python · exercise1_execute.py
import numpy as np
from PIL import Image

# Create a 10x10 grid of random RGB colors
random_colors = np.random.randint(0, 256, size=(10, 10, 3), dtype=np.uint8)

# Scale each color to a 20x20 pixel tile using Kronecker product
scaling_matrix = np.ones((20, 20, 1), dtype=np.uint8)
image_array = np.kron(random_colors, scaling_matrix)

# Convert to image and save
result_image = Image.fromarray(image_array)
result_image.save('random_tiles.png')

print(f"Image dimensions: {image_array.shape}")

Reflection questions

  • What do you notice about the colours? Are any two tiles exactly the same?
  • Why does each tile appear as a solid block rather than individual pixels?
  • What role does the Kronecker product play in creating the final image?
MODIFY II.

Tune the parameters

Open exercise1_execute.py from Exercise 1 and modify the marked parameters to achieve each of these different effects. Change only the specified values, then re-run.

Goals

  1. Muted palette — change the colour range to produce soft, pastel-like tones only.
  2. Grayscale tiles — make all tiles appear in shades of gray.
  3. Larger, bolder tiles — increase the tile size for a more graphic effect.
CREATE III.

Richter-inspired discrete palette

Create something new: a colour grid inspired by Gerhard Richter’s 1024 Colours (1973) [1] using only discrete colour values. Instead of the full 0—255 range, restrict each channel to eight specific values — producing a structured, limited-palette composition.

Goal: Create random tiles that use only colour values divisible by 32 (0, 32, 64, 96, 128, 160, 192, 224) on a 16x16 grid with 12x12 pixel tiles.

python · exercise3_starter.py
import numpy as np
from PIL import Image

# Discrete color palette (values divisible by 32)
color_palette = np.array([0, 32, 64, 96, 128, 160, 192, 224])

# TODO 1: Create a 16x16 grid of random indices into the palette
#         (each index selects one of the 8 palette values, for each of 3 channels)

# TODO 2: Map the random indices to actual color values using array indexing

# TODO 3: Scale up to 12x12 pixel tiles using the Kronecker product and save as
#         'richter_style.png'

Make it your own

After the TODOs work, try these extensions by editing the script directly:

  • Change the palette to fewer values (e.g. [0, 85, 170, 255] — four values per channel, 64 total colours).
  • Try a warm-only palette: restrict the red channel to high values and the blue channel to low values.
  • Increase the grid to 32x32 tiles with 6x6 pixel size for a denser, more textile-like composition.
  • Add a seed with np.random.seed(...) and find a composition you like. Record the seed number so you can reproduce it later.

Downloads

random_tiles.py — combined script

Summary

Common pitfalls to avoid

  • Forgetting dtype=np.uint8 — Pillow expects unsigned 8-bit integers for standard images. Without this, Image.fromarray may produce unexpected results or errors.
  • Confusing the Kronecker scaling matrix shape — the third dimension should be 1 (not 3) so broadcasting works correctly across all three RGB channels.
  • Running without a seed and expecting the same output — each run produces a different image unless you set np.random.seed() beforehand.
  • Using np.random.rand() (float 0—1) instead of np.random.randint() (integer 0—255) for direct image arrays. Float arrays require multiplication and casting before they work with Pillow.

References

  1. [1] Richter, G. (1973). 1024 Colours [Oil on canvas, No. 350]. Gerhard Richter Archive. gerhard-richter.com
  2. [2] Knuth, D. E. (1997). The Art of Computer Programming, Volume 2: Seminumerical Algorithms (3rd ed.). Addison-Wesley.
  3. [3] NumPy Community. (2023). Random sampling (numpy.random). NumPy Documentation, version 1.24. numpy.org
  4. [4] Horn, R. A., & Johnson, C. R. (2012). Matrix Analysis (2nd ed.). Cambridge University Press.
  5. [5] Fairchild, M. D. (2013). Color Appearance Models (3rd ed.). Wiley. ISBN 978-1-119-96703-3.
  6. [6] Harris, C. R., et al. (2020). Array programming with NumPy. Nature, 585, 357–362. doi:10.1038/s41586-020-2649-2
  7. [7] Galanter, P. (2003). What is generative art? Complexity theory as a context for art theory. In Proceedings of the 6th Generative Art Conference. Politecnico di Milano.