Okay. A random post on the /r/cellular_automata subreddit inspired me.
Let’s generate a cellular automata where each pixel updates based on a neural network given as input:
- The x/y coordinates (scaled to the range 0-1)
- An optional random value (to make it more dynamic)
- A variety of neighboring data, such as:
- The number of neighbors that are ‘active’ (> 50% white), ranges 0-8 scaled to 0-1. This should allow Conway’s Game of Life
- The RGB values of all neighbors (allows a superset of the above)
- Gradients, subtract color value of the left from the right so that you get edges and side to side movement
Let’s do it!
First off, I’m going to use brain.js for the neural networks. It’s quick and easy to use with one big exception: I couldn’t figure out how to generate random weights. It’s not really something that neural networks are ‘designed’ to do in general and I did eventually figure it out, but it was a bit of a pain.
Things to play with:
- The
regeneratebutton will generate a new random network - The
debugbutton will toggle debug information under your mouse - The
networkbutton will toggle a JSON view of the current neural network - The
svgbutton will toggle a rendered SVG of the current neural network - The
importbutton can take a JSON object containing a network (copied from thenetworktab) and load it back in gridSizecontrols how large pixels are (making them too small will cause performance issues)showBorderwill render pixel bordersoutputAddNoisewill add noise to the pixels after they are generated by the networkinputAddNoisewill add noise to the first input to the neural network (otherwise it will be zero)inputAddXYwill add X/Y as the second and third input to the neural network (otherwise zero)biasScaleandweightScalecontrol the generated values of the neural network; the given values generally have output, too small or large will mean all values end up the sameactivationchanges the neural network activation functioncolorInputModecontrols how additional inputs are added:neighbor countis counting how many neighbors each pixel has, ranges 0-8 scaled to 0-1neighbor RGBpasses the R, G, B value of the 8 neighboring pixels in as 0-1 each (for 24 input channels)gradiant x/ywill pass the difference between right/left and top/bottom, making patterns that tend to movenonewill ignore neighbor hood, using only noise/x/y (if on above)
hiddenLayersshould be a JSON list of number defining how many neurons are in each hidden layer of the neural network; more is more complicated behavior but slower
Below the code is broken up a bit, we have:
init.jsinitializes the sketch, not super interestinginput-modes.jsdefines the different input modes above, basically it stores how many values will be generated and a function to generate theminput.jstakes those values and adds noise/x/y if requestedreset.jsresets the sketch, this also does the work of setting up the random neural network, which is one of the most interesting bits of the codedraw.jsupdates the network (using a double buffer so that we don’t modify the input values as we’re generating them) and draws them to the screen
Let’s check it out!
let gui;
let params = {
gridSize: 10,
gridSizeMin: 1,
gridSizeMax: 20,
showBorder: true,
outputAddNoise: false,
inputAddNoise: true,
inputXY: true,
biasScale: 2,
biasScaleMin: 1,
biasScaleMax: 10,
biasScaleStep: 0.1,
weightScale: 7,
weightScaleMin: 1,
weightScaleMax: 10,
weightScaleStep: 0.1,
activation: ['sigmoid', 'relu', 'leaky-relu', 'tanh'],
colorInputMode: undefined,
hiddenLayers: '[10, 10, 10, 10]',
};
let data = []
let gridWidth = 0
let gridHeight = 0
let outputSize = 3
let inputSize = 3 + (9 * outputSize) // x, y, and rgb of 3x3 area
let hiddenLayers = [10, 10, 10, 10]
let net = undefined
function toggleVisible(el) {
if (el.style.display === 'none') {
el.style.display = 'block'
} else {
el.style.display = 'none'
}
}
function makeToggle(name, type) {
let el = document.createElement(type)
el.id = name
el.style.display = 'none'
let button = document.createElement('button')
button.innerText = name
button.addEventListener('click', function(e) { e.preventDefault(); toggleVisible(el); return false; })
button.href = '#'
return [button, el]
}
function setup() {
createCanvas(400, 400);
let button = document.createElement('button')
button.innerText = `regenerate`
button.addEventListener('click', function(e) { e.preventDefault(); reset(); return false; })
button.href = '#'
document.body.appendChild(button)
gui = createGuiPanel();
gui.addObject(params);
gui.setPosition(420, 0);
let [debugButton, debugEl] = makeToggle('debug', 'pre')
let [networkButton, networkEl] = makeToggle('network', 'pre')
let [svgButton, svgEl] = makeToggle('svg', 'div')
for (let el of [debugButton, networkButton, svgButton, debugEl, networkEl, svgEl]) {
document.body.appendChild(el)
}
let importButton = document.createElement('button')
importButton.innerText = `import`
importButton.addEventListener('click', function(e) {
e.preventDefault();
js = JSON.parse(prompt('Neural network JSON:'))
reset(js)
return false;
})
importButton.href = '#'
document.body.appendChild(importButton)
document.body.appendChild(document.createElement('br'))
reset()
}
let inputModes = {
'neighbor count': {
size: 1,
inputAt: (x, y) => {
let neighbors = 0
for (let xd = -1; xd <= 1; xd++) {
for (let yd = -1; yd <= 1; yd++) {
if (xd === 0 && yd === 0)
continue
let g = (getData(x, y, 0) + getData(x, y, 1) + getData(x, y, 2)) / 3
if (g > 0.5)
neighbors += 1
}
}
return [neighbors / 8.0]
}
},
'neighbor RGB': {
size: 24,
inputAt: (x, y) => {
let values = []
for (let xd = -1; xd <= 1; xd++) {
for (let yd = -1; yd <= 1; yd++) {
if (xd === 0 && yd === 0)
continue
for (let c = 0; c < outputSize; c++) {
values.push(getData(x, y, c)) // RGB
}
}
}
return values
}
},
'gradiant x/y': {
size: 2,
inputAt: (x, y) => {
let gl = getData(x - 1, y, 0) + getData(x - 1, y, 1) + getData(x - 1, y, 2)
let gr = getData(x + 1, y, 0) + getData(x + 1, y, 1) + getData(x + 1, y, 2)
let gu = getData(x, y - 1, 0) + getData(x, y - 1, 1) + getData(x, y - 1, 2)
let gd = getData(x, y + 1, 0) + getData(x, y + 1, 1) + getData(x, y + 2, 2)
return [
gr - gl,
gd - gu
]
}
},
'none': {
size: 0,
inputAt: (x, y) => [],
}
}
params.colorInputMode = Object.keys(inputModes)
function inputAt(x, y) {
let input = [
params.inputNoise ? Math.random() : 0,
params.inputXY ? 1.0 * x / gridWidth : 0,
params.inputXY ? 1.0 * y / gridHeight : 0,
]
input = input.concat(inputModes[params.colorInputMode].inputAt(x, y))
return input
}
function reset(network) {
inputSize = 3 + inputModes[params.colorInputMode].size
hiddenLayers = JSON.parse(params.hiddenLayers)
net = new brain.NeuralNetwork({
hiddenLayers,
activation: Array.isArray(params.activation) ? params.activation[0] : params.activation,
})
gridWidth = width / params.gridSize
gridHeight = height / params.gridSize
// Train it on random data to initialize
let trainingData = []
for (let i = 0; i < 1; i++) {
let input = []
for (let j = 0; j < inputSize; j++) {
input.push(Math.random())
}
let output = []
for (let j = 0; j < outputSize; j++) {
output.push(Math.random())
}
trainingData.push({input, output})
}
net.train(trainingData, { iterations: 1 })
// Rewrite the weights
if (network) {
net = net.fromJSON(network)
} else {
let js = net.toJSON()
for (let layer = 1; layer < js.layers.length; layer++) {
for (let neuron in js.layers[layer]) {
let newBias = params.biasScale * Math.random() * 2 - params.biasScale
js.layers[layer][neuron].bias = newBias
for (let weight in js.layers[layer][neuron].weights) {
let newWeight = params.weightScale * Math.random() * 2 - params.weightScale
js.layers[layer][neuron].weights[weight] = newWeight
}
}
}
net = net.fromJSON(js)
}
data = []
for (let x = 0; x < gridWidth; x++) {
var row = []
for (let y = 0; y < gridHeight; y++) {
var pixel = []
for (let c = 0; c < outputSize; c++) {
pixel.push(Math.random())
}
row.push(pixel)
}
data.push(row)
}
document.getElementById('network').innerText = JSON.stringify(net.toJSON(), null, 2)
params.network = JSON.stringify(net.toJSON())
}
function getData(x, y, c) {
try {
return data[x][y][c] || 0
} catch(ex) {
return 0
}
}
function draw() {
if (params.showBorder) {
stroke(0)
} else {
noStroke()
}
let buffer = []
for (let x = 0; x < gridWidth; x++) {
var row = []
for (let y = 0; y < gridHeight; y++) {
let pixel = net.run(inputAt(x, y))
if (params.addNoise) {
pixel[0] += (Math.random() - 0.5) / 100
pixel[1] += (Math.random() - 0.5) / 100
pixel[2] += (Math.random() - 0.5) / 100
}
row.push(pixel)
}
buffer.push(row)
}
data = buffer
for (let x = 0; x < gridWidth; x++) {
for (let y = 0; y < gridHeight; y++) {
fill(255 * getData(x, y, 0), 255 * getData(x, y, 1), 255 * getData(x, y, 2))
rect(x * params.gridSize, y * params.gridSize, params.gridSize, params.gridSize)
}
}
let gx = Math.round(mouseX / params.gridSize)
let gy = Math.round(mouseY / params.gridSize)
let r = Math.round(255 * getData(gx, gy, 0))
let g = Math.round(255 * getData(gx, gy, 1))
let b = Math.round(255 * getData(gx, gy, 2))
let debugData = {
frame: frameCount,
pt: {gx, gy},
rgb: {r, g, b},
output: net.run(inputAt(gx, gy)),
input: inputAt(gx, gy),
}
document.getElementById('debug').innerText = JSON.stringify(debugData, null, 2)
document.getElementById('svg').innerHTML = brain.utilities.toSVG(net)
}
Take a look at the code and feel free to ask me if anything needs more explication. Of particular interest are:
Generating a random brain.js network
net = new brain.NeuralNetwork({
hiddenLayers,
activation: Array.isArray(params.activation) ? params.activation[0] : params.activation,
})
// Train it on random data to initialize
let trainingData = []
for (let i = 0; i < 1; i++) {
let input = []
for (let j = 0; j < inputSize; j++) {
input.push(Math.random())
}
let output = []
for (let j = 0; j < outputSize; j++) {
output.push(Math.random())
}
trainingData.push({input, output})
}
net.train(trainingData, { iterations: 1 })
// Rewrite the weights
let js = net.toJSON()
for (let layer = 1; layer < js.layers.length; layer++) {
for (let neuron in js.layers[layer]) {
let newBias = params.biasScale * Math.random() * 2 - params.biasScale
js.layers[layer][neuron].bias = newBias
for (let weight in js.layers[layer][neuron].weights) {
let newWeight = params.weightScale * Math.random() * 2 - params.weightScale
js.layers[layer][neuron].weights[weight] = newWeight
}
}
}
net = net.fromJSON(js)
This is a bit of a pain, but I finally got it to work. In order to have value to rewrite, you have to train it at least once. Originally I trained on random data, but that didn’t work nearly as well. So now instead, we train, export to JSON, rewrite all of the weights and biases, then load it back in. It’s messy, but it works. Perhaps I’ll make a PR for brain.js to allow random weights. Perhaps it does already and I just can’t find it. :)