Today I’m showing how I recreated Giovanni Pintori’s poster “Numbers”.

Giovanni Pintori's poster

First off, I analyzed the poster and I noticed that Giovanni Pintori achieved the overall look of this poster by creating a tessellation with random numbers. He used multiple fonts and random font sizes, but most of the fonts he used are bold, filling as much of the canvas as possible. He also positioned the numbers so that they are as close to others as possible, but never overlaps with others. He filled the voids in between large texts with smaller texts, and the way he determines overlapping is by checking against the actual shape of the texts.

Because of the non-grid random tessellation nature of the poster, I thought of poisson disk sampling algorithm and used it to fill up the canvas by randomly positioned circles, and just leave the problem of modifying the algorithm to work with texts until later.

Poisson Disk Sampling

Then I started working on the color pattern of the random numbers. I noticed that the distribution of color follows two rules: 1) the canvas is vertically partitioned by some random curves, and each section is colored using an analogous color scheme. 2) within each section there are some small connected areas where the numbers are colored differently.

I wanted to first deal with the small areas because they look like peaks in a height map. I believed that I could create a 2D height map using perlin noise, sample it and obtain those areas by comparing the sample to a cutoff value. I first generated a 256*358 noise texture using a javascritp library texgen.js:

Noise texture

Then I was able to color the circles with a predefined palette by sampling the noise texture and equally partitioning in the vertical direction.

Coloring the circles

To create the organic boundaries of color bands, I created some curves with perlin noise and made it match the characteristics of the boundaries in the original poster.

Boundary curves

After randomly offsetting the curves vertically to give the bands different heights, I made the color bands follow the curve boundaries.

Band division

From now on, I started replacing the circles with texts. To modify the poisson disk sampling algorithm so that it work with text, I have to figure out a way of collision testing for text. Since the algorithm runs hundreds if not thousands of passes for each sample point, it’s computational expensive to use the actual polygonal shape of the text as collision bounds. I decided to use bounding rectangles created based on empirical calculations.

It looked like this using rectangular bounding box and the naive poisson disk sampling algorithm.

Adding in texts

I modified the algorithm so that it uses the bounding box of text to do distance calculation and collision testing.

With collision testing

With height map and noise curve aided color assignment and modified poisson disk sampling algorithm, I recreated the poster that looks very similar to the original piece.

Compositing

Code:

// A computational approach to Giovanni Pintori's poster "Numbers"
// http://www.giovannipintoriresearch.com/portfolio/poster-numbers/

var r = new Rune({ container: "#canvas", width: 768, height: 1074, debug: false });

var palette = [[[56, 60, 94], [81, 113, 133]], // blue
[[141, 130, 63], [224, 183, 71]], // yellow
[[175, 75, 46], [44, 44, 44]], // orange and dark gray
[[136, 138, 128], [73, 87, 82]], // pale cyan and mid gray
[[38, 93, 67], [129, 158, 92], [138, 175, 67]], // grass green
[[56, 60, 94], [81, 113, 133]], // blue
[[175, 75, 46], [224, 183, 71]], // orange and yellow
[[136, 138, 128], [73, 87, 82]], // pale cyan and mid gray
];

var numStrips = 7;
var stripBoundaryYs = [];
var stripNoiseSeeds = [];

var debugTextDistribution = false;

function generateTexture() {
var seed = Date.now();
var noise = new TG.FractalNoise().seed(seed).baseFrequency(30).octaves(6).step(1.5).amplitude(0.4).persistence(0.6).interpolation(2);
var texture = new TG.Texture(256, 358).set(noise);
document.getElementById("texture").appendChild(texture.toCanvas());
}

function texture2D(x, y) {
var canvas = document.getElementById("texture").getElementsByTagName("canvas")[0];
var context = canvas.getContext("2d");
var imageData = context.getImageData(0, 0, canvas.width, canvas.height);

var index = y * imageData.width + x;
var i = index * 4, d = imageData.data;
return [d[i], d[i + 1], d[i + 2], d[i + 3]];
}

function generateBoundaries() {
var previousBaseY = 0;
for (var i = 0; i < numStrips; i++) {
var baseY = previousBaseY + (i + 1) / numStrips * r.height + Rune.random(0, 80);
stripBoundaryYs.push(baseY);
var noiseSeed = i * Rune.random(100);
stripNoiseSeeds.push(noiseSeed);
}
}

function drawBoundaries() {
for (var i = 0; i < numStrips; i++) {
var curve = r.path().stroke(255).strokeWidth(5).fill(false);
var numSamples = 64;
for (var j = 0; j <= numSamples; j++) {
var x = j / numSamples * r.width;
var y = getBoundaryY(i, x);
x == 0 ? curve.moveTo(x, y) : curve.lineTo(x, y);
}
}
}

function getBoundaryY(strip, x) {
var noise = new Rune.Noise();
noise.noiseDetail(2);
noise.noiseSeed(stripNoiseSeeds[strip]);

var perc = x / r.width;
return stripBoundaryYs[strip] + noise.get(123.456 + perc * 9.6) * 128 - perc * 130;
}

function getStripIndex(x, y) {
var index = 0;
while (index < numStrips && getBoundaryY(index, x) < y) index++;
return index;
}

function runSampler(withTexture, font) {
var sampler = new PoissonDiskSampler(r, font);
sampler.createPoints();

// offset the whole canvas to fix top edge distribution issue
var g = r.group(0, -40);

for (var i = 0; i < sampler.pointList.length; i++) {
var point = sampler.pointList[i];
var text = point.text;
text.stroke(false);
g.add(text);

if (debugTextDistribution) {
r.rect(point.x - point.width * 0.5, point.y - point.height * 0.5, point.width, point.height)
.fill(255, 0.3)
.stroke(false)
}

if (withTexture) {
var tex = texture2D(Math.floor(point.x / 3), Math.floor(point.y / 3));
var currentStrip = getStripIndex(point.x, point.y);
var colorPalette = palette[currentStrip % palette.length];
var colors = (tex[0] < 128) ? colorPalette[0] : colorPalette[1];
var color = new Rune.Color(colors[0], colors[1], colors[2]);
text.fill(color);
}
}
}

function loadFont(fontName, callback) {
var f = new Rune.Font(fontName);
f.load(function(err) { !!err ? console.log(err) : callback(f); });
}

(function() {
if (!debugTextDistribution) generateTexture();
generateBoundaries();

loadFont("font/ChauPhilomeneOne.ttf", function(f) {
r.rect(0, 0, r.width, r.height).fill(15).stroke(false);
runSampler(!debugTextDistribution, f);
r.draw();
});
})();