Pixels2GenAI
Path i Foundations
M 02 · 2.1.4 · hands-on

2.1.4 Creating Star Fields

Place individual pixels with integer array indexing instead of geometric primitives, then swap the random distribution (uniform → Gaussian) to turn a scatter into a star cluster — the indexing pattern behind every particle system.

Duration15–20 min
Levelbeginner
Load3 core concepts
Prereqs2.1.3 (boolean masks), basic NumPy random

Overview

Filled shapes work pixel-by-pixel through a condition — boolean masks for circles, edge tests for triangles. Particle systems work pixel-by-pixel through a coordinate list: you say exactly where each pixel goes. The mechanic NumPy uses for this is integer array indexing, and it is one line: canvas[y_coords, x_coords] = brightness. Change the distribution that generated those coordinates — uniform vs. Gaussian — and the same line draws either an evenly-scattered starfield or a clustered galaxy core [1]. In this lesson you will scatter 150 pixels, swap the distribution to draw a cluster, then layer multiple clusters into a galaxy.

Learning objectives

  1. Use integer array indexing to place individual pixels at arbitrary coordinates in one vectorised step.
  2. Generate point sets with np.random.randint (uniform) and np.random.normal (Gaussian), and understand why each produces a distinct visual.
  3. Clip out-of-range Gaussian samples with np.clip and cast safely to int for array indexing.
  4. Compose multi-cluster scenes by adding the same per-cluster routine repeatedly with different centres and spreads.

Quick start — scatter 150 stars

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

CANVAS_SIZE = 400
NUM_STARS = 150

canvas = np.zeros((CANVAS_SIZE, CANVAS_SIZE), dtype=np.uint8)

# Uniformly random (x, y) for each star
x_coords = np.random.randint(0, CANVAS_SIZE, size=NUM_STARS)
y_coords = np.random.randint(0, CANVAS_SIZE, size=NUM_STARS)

# Integer array indexing — paint all 150 pixels in one assignment
canvas[y_coords, x_coords] = 255

Image.fromarray(canvas, mode='L').save('simple_star.png')
150 white dots scattered evenly across a black 400 by 400 canvas, like a flat star field
Fig. 1 150 stars placed by integer array indexing — one assignment, no loop.

Core concepts

Concept 1 — Integer array indexing

You have already seen two ways to address pixels in this module: slicing (rectangular regions), and boolean masking (shape-defined regions). The third — integer array indexing — addresses pixels by coordinate list. When you write

python · integer_indexing_demo.py
canvas[y_coords, x_coords] = 255

NumPy interprets it as: for each i, set the pixel at (y_coords[i], x_coords[i]) to 255 [2]. The two coordinate arrays have the same length, and they are walked in lockstep. The whole operation is vectorised — no explicit loop in Python.

Indexing formBest forExample
SlicingRectangular regionscanvas[0:100, 0:200] = 255
Boolean maskingShape-defined regionscanvas[dist < r] = color
Integer indexingIndividual pointscanvas[y, x] = 255

Concept 2 — Uniform vs. Gaussian distributions

A scatter of 150 pixels can look very different depending on how the coordinates were drawn.

Uniformnp.random.randint(low, high, size=n) returns integers in [low, high) with equal probability. Every pixel is equally likely, so the result is flat — “white noise” at the pixel level [3]:

python · uniform_demo.py
x = np.random.randint(0, CANVAS_SIZE, size=NUM_STARS)
y = np.random.randint(0, CANVAS_SIZE, size=NUM_STARS)

Gaussiannp.random.normal(mean, std, size=n) returns floats clustered around mean, with about 68% inside one standard deviation. Stars bunch around the centre and thin out with a bell-curve falloff [4]:

python · gaussian_demo.py
x = np.random.normal(CENTER_X, spread, size=NUM_STARS)
y = np.random.normal(CENTER_Y, spread, size=NUM_STARS)

# Gaussian samples are floats and can go outside the canvas
x = np.clip(x, 0, CANVAS_SIZE - 1).astype(int)
y = np.clip(y, 0, CANVAS_SIZE - 1).astype(int)
Two-panel comparison: left side evenly scattered stars, right side a tight cluster centred on the canvas
Fig. 2 Uniform on the left, Gaussian on the right. Same count, same canvas, very different perception.

The two steps after a Gaussian draw — np.clip then .astype(int) — are not cosmetic. np.random.normal returns floats (array indexing needs ints) and can produce values well outside the canvas (because Gaussian tails are unbounded). Skipping either step throws IndexError or TypeError.

Concept 3 — Layering for depth

A single cluster reads as flat. Stacking several clusters at different centres, sizes, and spreads gives the visual depth that science-fiction starfields rely on, going back to Atari’s Star Raiders in 1979 [6]. The recipe:

  1. Big diffuse cluster — many stars, wide spread, large canvas-scale presence (galactic core).
  2. Medium clusters — fewer stars, tighter spread, off to one side (satellite groupings).
  3. Background fill — a Gaussian with very large spread acts as a sparse all-over background, since the bell curve flattens when the standard deviation approaches the canvas size.

Encapsulate one cluster in a function and the scene is a handful of calls:

python · add_cluster_demo.py
def add_cluster(canvas, cx, cy, num_stars, spread):
    x = np.random.normal(cx, spread, size=num_stars)
    y = np.random.normal(cy, spread, size=num_stars)
    x = np.clip(x, 0, canvas.shape[1] - 1).astype(int)
    y = np.clip(y, 0, canvas.shape[0] - 1).astype(int)
    canvas[y, x] = 255

Exercises

Three exercises in Execute → Modify → Create order: scatter uniform stars, swap to Gaussian and add a second cluster, then build a multi-cluster galaxy.

EXECUTE I.

Run the uniform star field

Run simple_star.py and inspect the result.

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

CANVAS_SIZE = 400
NUM_STARS = 150

canvas = np.zeros((CANVAS_SIZE, CANVAS_SIZE), dtype=np.uint8)

x_coords = np.random.randint(0, CANVAS_SIZE, size=NUM_STARS)
y_coords = np.random.randint(0, CANVAS_SIZE, size=NUM_STARS)

canvas[y_coords, x_coords] = 255

Image.fromarray(canvas, mode='L').save('simple_star.png')

Reflection questions

  • Why canvas[y_coords, x_coords] and not canvas[x_coords, y_coords]?
  • What happens if a coordinate falls outside the canvas range?
  • How would you produce twice as many stars? Half as many?
MODIFY II.

Cluster, then double it

Edit exercise1_execute.py to produce these two pictures.

Goals

  1. One Gaussian cluster at (200, 200) with spread = 60 and 200 stars.
  2. Two clusters — the one above, plus a tight 80-star cluster at (100, 300) with spread = 30.
CREATE III.

Multi-cluster galaxy

Build a galaxy-like scene on a 512×512 canvas — at least three Gaussian clusters with different centres, counts, and spreads, plus a sparse “background” cluster that scatters stars across the whole canvas.

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

CANVAS_SIZE = 512
canvas = np.zeros((CANVAS_SIZE, CANVAS_SIZE), dtype=np.uint8)

def add_cluster(canvas, cx, cy, num_stars, spread):
    """Drop num_stars stars centred at (cx, cy) with the given Gaussian spread."""
    # TODO 1: draw Gaussian samples for x and y
    # TODO 2: clip to canvas bounds and cast to int
    # TODO 3: assign brightness 255 at the (y, x) coordinates
    pass

# TODO 4: call add_cluster at least three times with different centres,
#         counts (50–300), and spreads (20–100). End with a very wide cluster
#         centred near the middle to act as background stars.

Image.fromarray(canvas, mode='L').save('multi_cluster.png')

Make it your own

  • Brightness variation. Replace the constant 255 with np.random.randint(50, 256, size=num_stars) to give each star its own brightness — distant stars dimmer, near stars brighter.
  • Cross-shaped foreground stars. For 10 chosen “near” stars, draw a small + shape (centre at 255, four neighbours at 200) instead of a single pixel.
  • Colour. Switch the canvas to RGB and tint each cluster — slight blue for hot stars, slight red for cool — by writing a 3-tuple into the canvas instead of a scalar.

Downloads

simple_star.py — uniform scatter gaussian_cluster.py — single cluster multi_cluster_solution.py — galaxy reference

Summary

Common pitfalls to avoid

  • canvas[x, y] instead of canvas[y, x] — your scene appears transposed.
  • Skipping np.clip after np.random.normal — Gaussian tails can land at -3 or 530, triggering IndexError.
  • Skipping .astype(int) after clipping — array indices must be integer; floats throw TypeError.
  • Reusing a random seed across calls and wondering why two “different” clusters look identical — set the seed once at the top, or not at all.

References

  1. [1] Pearson, M. (2011). Generative Art: A Practical Guide Using Processing. Manning.
  2. [2] Harris, C. R., et al. (2020). Array programming with NumPy. Nature, 585(7825), 357–362. doi:10.1038/s41586-020-2649-2
  3. [3] NumPy Community. (2024). Random sampling (numpy.random). NumPy Documentation. numpy.org
  4. [4] Ross, S. M. (2014). Introduction to Probability and Statistics for Engineers and Scientists (5th ed.). Academic Press.
  5. [5] Binney, J., & Tremaine, S. (2008). Galactic Dynamics (2nd ed.). Princeton University Press.
  6. [6] Maher, J. (2012). The Future Was Here: The Commodore Amiga. MIT Press.
  7. [7] Clark, A., et al. (2024). Pillow Documentation. pillow.readthedocs.io