Colour, layers, and pixel-level image processing
Learning objectives
- learner can control colour with HSB mode, gradients, and blend modes across stacked layers
- learner can draw into off-screen PGraphics/framebuffer layers and cross-dissolve them
- learner can read and rewrite the pixels[] array to build image filters, palette extraction, and pixel-mapped renders
Capstone — one whole task that evidences the objectives
Take a source photo and produce a layered, recoloured piece: extract its palette, remap its pixels into drawn tiles or a convolution filter, and composite the result across blend-moded PGraphics layers.
Prerequisite modules
This module builds the image-transformation half of a live-visuals practice: taking found imagery and reprocessing it into something that reads as yours on a projector. In a VJ or audiovisual set, raw photos and camera feeds rarely work as-is — they need recolouring to match the set’s palette, restructuring into tiles or filtered textures that move with the music, and compositing so multiple visual voices coexist on one screen. That whole pipeline — source image in, layered recoloured artwork out — is the capstone task.
The arc starts supported and on-canvas: switch to HSB so hue becomes a single controllable axis, then build gradients by lerping colours in a loop, then stack simple shapes under different blend modes and watch LIGHTEST and DIFFERENCE change the composite. Next the work moves off-screen — “PGraphics is an independent off-screen drawing layer” is the JIT pointer when your first layered sketch needs elements composed separately, and “cross-dissolving two layers per pixel with lerpColor()” shows how two buffers become one animated transition. Finally you drop to the pixel level: loadPixels() and the row-major index formula unlock palette extraction by sampling and sorting, pixel-mapping into drawn substitutes, brightness-driven tile rasterization, and convolution kernels for blur and sharpen.
Every required atom is load-bearing for the capstone: you cannot extract a palette without pixels[] access and 2D grid iteration, cannot remap into tiles without the brightness-to-size move, and cannot composite the final piece without PGraphics layers and blend modes. The supporting atoms enrich rather than gate — alpha-accumulation traces, continuous cellular automata, and frame differencing point at where this pixel toolkit goes next: generative textures and live camera input. Drill the gradient loop, pixels[] indexing, and nested grid iteration until they are automatic; everything else in the module rides on them.
Walkthrough
You’ll build the “found image in → recoloured, layered artwork out” pipeline. There’s no photo to load headless, so we generate a source image in code — the pixel-processing moves are identical to a loadImage()’d photo. Paste each sketch into the p5 web editor and press Run (▶). Each step is a complete, standalone sketch.
1 — a gradient by interpolation. lerpColor(a, b, t) blends two colours by fraction t. Loop it down the rows and you get a smooth gradient — the atom of all colour blending. Work in HSB so the endpoints are chosen by hue, not RGB guesswork ([[hsb-color-mode-p5js]], [[p5js-color-gradients-lerp]]).
function setup() { createCanvas(400, 400); colorMode(HSB, 360, 100, 100); }
function draw() {
let a = color(200, 80, 90), b = color(340, 70, 55);
for (let y = 0; y < height; y++) {
stroke(lerpColor(a, b, y / height)); // interpolate per row
line(0, y, width, y);
}
}
2 — blend modes stack light. blendMode() changes how new pixels combine with what’s under them. ADD sums channels, so overlapping shapes get brighter where they meet — the RGB-primary Venn every VJ knows. Always reset to BLEND after ([[p5js-blend-modes]], [[processing-image-blend-modes]]).
function setup() { createCanvas(400, 400); noStroke(); }
function draw() {
background(0);
blendMode(ADD); // additive light
fill(255, 0, 0); circle(160, 200, 210);
fill(0, 255, 0); circle(240, 170, 210);
fill(0, 0, 255); circle(200, 250, 210);
blendMode(BLEND);
}
3 — an off-screen layer (PGraphics). createGraphics() is a whole independent canvas you draw into off-screen, then composite in one image() call. It’s how separate visual voices stay separate until you deliberately merge them ([[pgraphics-as-layer]], [[p5js-framebuffer-gpu-texture]]).
let layer;
function setup() {
createCanvas(400, 400);
layer = createGraphics(400, 400); // an independent off-screen canvas
layer.noStroke();
layer.fill(255, 200, 0);
for (let i = 0; i < 6; i++) layer.circle(70 + i * 50, 200, 60);
}
function draw() {
background(30, 0, 60);
blendMode(SCREEN);
image(layer, 0, 0); // composite the whole layer at once
blendMode(BLEND);
}
4 — down to the pixels. loadPixels() exposes pixels[], four entries (R,G,B,A) per pixel in row-major order: pixel (x,y) starts at index (x + y*width) * 4. Write to it directly and you’ve built an image from scratch — the same array you’d read to process a photo ([[processing-image-pixels]], [[pixel-manipulation]]).
function setup() { createCanvas(400, 400); }
function draw() {
loadPixels();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let v = 255 - dist(x, y, 200, 200); // bright centre, dark edges
let i = (x + y * width) * 4; // the row-major index formula
pixels[i] = v; pixels[i + 1] = v * 0.4; pixels[i + 2] = 255 - v; pixels[i + 3] = 255;
}
}
updatePixels();
}
5 — brightness → tiles (rasterize). Sample a source’s brightness on a grid and draw a substitute mark whose size tracks it: bright source → big tile. That’s the whole “turn a photo into drawn dots” move, and 2D grid iteration is its engine ([[brightness-to-tile-rasterize]], [[pixel-mapping-remap]], [[processing-two-d-arrays]]).
function setup() { createCanvas(400, 400); }
function draw() {
background(10);
noStroke();
let step = 20; // 400/20 = 20 tiles across
for (let x = 0; x < width; x += step) {
for (let y = 0; y < height; y += step) {
let bright = (sin(x * 0.03) + cos(y * 0.03) + 2) / 4; // 0–1 stand-in for photo luminance
fill(120, 200, 255);
let s = bright * step; // brighter source → bigger tile
ellipse(x + step / 2, y + step / 2, s, s);
}
}
}
6 — the full pipeline (the capstone). Generate a source in an off-screen layer, read its pixels, remap each tile’s hue and size from the sampled brightness (a one-palette recolour), and composite it all over a screened glow layer. Source image in, layered recoloured artwork out ([[image-palette-extraction-sorting]], [[pgraphics-lerpcolor-blend]]):
let src, glow;
function setup() {
createCanvas(400, 400);
colorMode(HSB, 360, 100, 100);
noStroke();
src = createGraphics(400, 400); // 1) a procedural "source" image
src.colorMode(HSB, 360, 100, 100);
src.noStroke();
for (let i = 0; i < 200; i++) {
src.fill((i * 7) % 360, 70, 90);
src.circle((i * 53) % 400, (i * 97) % 400, 40);
}
src.loadPixels(); // pixels[] is raw RGBA regardless of colorMode
glow = createGraphics(400, 400); // 2) a soft glow layer
glow.noStroke();
glow.fill(255, 40);
for (let r = 260; r > 0; r -= 20) glow.circle(200, 200, r);
}
function draw() {
background(230, 60, 15);
blendMode(SCREEN);
image(glow, 0, 0); // glow, screened under the tiles
blendMode(BLEND);
let step = 25; // 400/25 = 16 tiles across
for (let x = 0; x < width; x += step) {
for (let y = 0; y < height; y += step) {
let idx = (x + y * width) * 4;
let b = (src.pixels[idx] + src.pixels[idx + 1] + src.pixels[idx + 2]) / 3; // luminance
fill(map(b, 0, 255, 200, 320), 70, map(b, 0, 255, 40, 95)); // recolour to one palette
let s = map(b, 0, 255, 6, step);
rect(x, y, s, s);
}
}
}
What good looks like. A processed image should read as a deliberate transformation, not a filter slapped on: a coherent recolour (all hues pulled into one range, per the palette discipline from [[generating-palettes-in-hsb]]), a legible structure from the tiling, and layers that add depth without turning to mud. The beginner failure is stacking blend modes until everything blows out to white — keep one dominant layer and let others support. Check value contrast survives on a projector. (Skill map: live-visualist Domain B2/B4 — colour discipline and texture/contrast as language.)
Now make it yours. Replace the generated source with loadImage() of a real photo (preload() it). Add a convolution kernel — a 3×3 weighted average of neighbours — for blur or edge-detect ([[convolution-kernel]]). Extract a real palette by sampling pixels and sorting by brightness. Cross-dissolve two PGraphics layers with lerpColor() per pixel for an animated transition. Swap rect tiles for rotated lines whose angle tracks brightness.
Runnable examples
Generated from the context/ instrument corpus by concept (redistributable idioms only). Do not edit — regenerate with gen-module-examples.mjs.
feedback-trail
osc(4).modulate(src(o0), 0.6).out(o0)
hydra-0022 · CC0-1.0
function draw(){ fill(0, 20); rect(0, 0, width, height); circle(mouseX, mouseY, 40) }
p5live-0003 · CC0-1.0
palette-cycle
osc(30, 0.1, 1).colorama(0.1).out()
hydra-0015 · CC0-1.0
hsvrgb [fract (ft/6.28 + 0.1*time), 1, 1] >> rgb
punctual-0024 · CC0-1.0
hue-shift
osc(30).hue(() => time * 0.1).out()
hydra-0016 · CC0-1.0
hsvrgb [fract (ft/6.28 + 0.1*time), 1, 1] >> rgb
punctual-0024 · CC0-1.0
gradient-ramp
gradient(0.3).out()
hydra-0170 · MIT
[fr, fr*0.5, 1-fr] >> rgb
punctual-0033 · CC0-1.0
grain-glitch
col += (h21(st + fract(u_time)) - 0.5) * 0.15;
glsl-0026 · public-domain
blur-soften
filter(BLUR, 4)
p5live-0073 · CC0-1.0
pixel-manipulation
loadPixels(); for(let i=0;i<pixels.length;i+=4) pixels[i]=255; updatePixels()
p5live-0030 · CC0-1.0
Atoms in this module
Required — these gate the capstone
Supporting — enrichment, not gating
Part of curricula
- Live Visualist — zero to performing live-coded & generative visuals — Generative canvas — colour, motion, and Hydra live-coding required
- VJ — visual performance with projection, light & video — Generate & compose: build your own look optional
Unlocks — modules that require this one