Creating random music covers using Conways Game of Life algorithm

(A sample cover art follows.)

I'm currently working on replacing all the covers of my music files to have a unified theme, and I've started this project in Java to refresh my knowledge after a gap of 2-3 years.

The program generates random pixels in the upper-left quadrant of the image, mirrors the quadrant to the other three quadrants, and then simulates Conway's Game of Life to create an interesting symmetric pattern. Finally, the pixels are colored to create a circular colored gradient.

If anything catches your eye, please let me know. However, I'm primarily interested in improving performance and whether I've gone overboard with the use of parallel streams.

Cover.java

package cover;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Random;
import java.util.stream.IntStream;
import javax.imageio.ImageIO;

import static utils.Constants.*;

public class Cover {

boolean[][] pixelData = new boolean[HEIGHT][WIDTH];

public Cover() {
generatePixelData();
}

/*
* Generates PIXEL_PERCENTAGE of random pixels for the upper left quadrant of the cover.
*/
private void generatePixelData() {
Random random = new Random();
IntStream.range(0, HEIGHT / 2).parallel().forEach(y -> {
for (int x = 0; x < WIDTH / 2; x++) {
pixelData[y][x] = random.nextInt(100) < PIXEL_PERCENTAGE;
}
});

mirrorPixelData();
}

/*
* Mirrors the pattern of the upper left corner to the other three quadrants.
*/
private void mirrorPixelData() {
IntStream.range(0, HEIGHT / 2).parallel().forEach(y -> {
// Mirror to top right
IntStream.range(0, WIDTH / 2).parallel().forEach(x -> {
pixelData[y][WIDTH - 1 - x] = pixelData[y][x];
});

// Mirror to bottom left
IntStream.range(0, WIDTH / 2).parallel().forEach(x -> {
pixelData[HEIGHT - 1 - y][x] = pixelData[y][x];
});

// Mirror to bottom right
IntStream.range(0, WIDTH / 2).parallel().forEach(x -> {
pixelData[HEIGHT - 1 - y][WIDTH - 1 - x] = pixelData[y][x];
});
});

simulateConwaysWayOfLife();
}

/*
* Simulates Conways way of life for the random pixels.
* Each step the following transitions occur:
* 1. Live pixel with <2 neighbors dies.
* 2. Live pixel with =2 or =3 neighrbors lives on.
* 3. Live pixel with >3 neighbors dies.
* 4. Dead pixel with =3 neighbors becomes alive.
*/
private void simulateConwaysWayOfLife() {
for (int simulation = 0; simulation < SIMULATIONS; simulation++) {
boolean[][] simulatedPixelData = new boolean[HEIGHT][WIDTH];
IntStream.range(0, HEIGHT).parallel().forEach(y -> {
IntStream.range(0, WIDTH).parallel().forEach(x -> {

// Checks for every pixel how many neighbor pixels are alive.
int alive = 0;
for (int ny = -1; ny < 2; ny++) {
for (int nx = -1; nx < 2; nx++) {
if (y + ny >= 0 && y + ny < HEIGHT &&
x + nx >= 0 && x + nx < WIDTH) {
if (pixelData[y + ny][x + nx]) {
alive++;
}
}
}
}

// The pixels itself does not count as a neighbor.
if (pixelData[y][x]) alive--;

// Conways way of life rules implementation.
switch (alive) {
case 0:
case 1:
simulatedPixelData[y][x] = false;
break;
case 2:
simulatedPixelData[y][x] = pixelData[y][x];
break;
case 3:
simulatedPixelData[y][x] = true;
break;
default:
simulatedPixelData[y][x] = false;
break;
}
});
});
pixelData = simulatedPixelData;
}

scalePixelData();
}

/*
* Scales the cover to a higher resolution.
*/
private void scalePixelData() {
boolean[][] scaledData = new boolean[SCALED_HEIGHT][SCALED_WIDTH];
IntStream.range(0, HEIGHT).parallel().forEach(y -> {
IntStream.range(0, WIDTH).parallel().forEach(x -> {
boolean value = pixelData[y][x];
IntStream.range(0, SCALE_FACTOR).parallel().forEach(scaleY -> {
IntStream.range(0, SCALE_FACTOR).parallel().forEach(scaleX -> {
scaledData[y * SCALE_FACTOR + scaleY][x * SCALE_FACTOR + scaleX] = value;
});
});
});
});

pixelData = scaledData;
roundEdges();
}

/*
* Rounds the corners of the scaled cover to get a smoother look.
*/
private void roundEdges() {
boolean[][] roundedPixels = new boolean[SCALED_HEIGHT][SCALED_WIDTH];
IntStream.range(0, SCALED_HEIGHT).parallel().forEach(y -> {
IntStream.range(0, SCALED_WIDTH).parallel().forEach(x -> {

// Checks for every pixel how many neighbor pixels are alive.
int alive = 0;
for (int ny = -1; ny < 2; ny++) {
for (int nx = -1; nx < 2; nx++) {
if (y + ny >= 0 && y + ny < SCALED_HEIGHT &&
x + nx >= 0 && x + nx < SCALED_WIDTH) {
if (pixelData[y + ny][x + nx]) {
alive++;
}
}
}
}

// The pixel itself does not count as neighbor.
if (pixelData[y][x]) alive--;
roundedPixels[y][x] = alive > 4;
});
});

pixelData = roundedPixels;
convertToImageData();
}

/*
* For every pixel determines its color to create a gradient and make the background transparent.
*/
private void convertToImageData() {
Random random = new Random();

int argbChannelCount = 4;
int[] imageData = new int[SCALED_HEIGHT * SCALED_WIDTH* argbChannelCount];
IntStream.range(0, SCALED_HEIGHT).parallel().forEach(y -> {
IntStream.range(0, SCALED_WIDTH).parallel().forEach(x -> {
int index = y * argbChannelCount * SCALED_WIDTH + x * argbChannelCount;

// Sets rgba values.
imageData[index] = pixelData[y][x] ? colors[0] : 0;
imageData[index + 1] = pixelData[y][x] ? colors[1] : 0;
imageData[index + 2] = pixelData[y][x] ? colors[2] : 0;
imageData[index + 3] = pixelData[y][x] ? 255 : 0;
});
});

createImage(imageData);
}

/*
* Converts the imagedata to an actual image.
*/
public static void createImage(int[] imageData) {
final BufferedImage bufferedImage = new BufferedImage(SCALED_WIDTH, SCALED_HEIGHT, BufferedImage.TYPE_INT_ARGB);
bufferedImage.getRaster().setPixels(0, 0, SCALED_WIDTH, SCALED_HEIGHT, imageData);

try {
File file = new File("cover.png");
ImageIO.write(bufferedImage, "png", file);
} catch(IOException e) {
System.out.println("Error: " + e);
}
}
}



Constants.java

package utils;

import java.util.ArrayList;
import java.util.List;

public final class Constants {
public static final int WIDTH = 100;
public static final int HEIGHT = 100;
public static final int SCALE_FACTOR = 5;
public static final int SCALED_WIDTH = WIDTH * SCALE_FACTOR;
public static final int SCALED_HEIGHT = HEIGHT * SCALE_FACTOR;

public static final int PIXEL_PERCENTAGE = 30;
public static final int SIMULATIONS = 10;

}
}



package cover;
private int[] color1;
private int[] color2;

public ColorGradient(int[] color1, int[] color2) {
if (color1.length != 3 || color2.length != 3) {
throw new IllegalArgumentException("Colors must consist of three integers.");
}

this.color1 = color1;
this.color2 = color2;
}

// Calculate the difference between the individual color channels.
public int redComponentDifference() { return color1[0] - color2[0]; }
public int greenComponentDifference() { return color1[1] - color2[1]; }
public int blueComponentDifference() { return color1[2] - color2[2]; }

// Getter methods.
public int[] getColor1() {
return color1;
}

public int[] getColor2() {
return color2;
}
}



package cover;

import static utils.Constants.SCALED_HEIGHT;
import static utils.Constants.SCALED_WIDTH;

private double maxDistanceFromCenter = euclideanDistance(0, 0);

}

public int[] getColors(int x, int y) {
double distanceFactor = distanceFactor(x, y);
int[] colors = new int[3];

return colors;
}

private double distanceFactor(int x, int y) {
return euclideanDistance(x, y) / maxDistanceFromCenter;
}

private double euclideanDistance(int x, int y) {
double deltaX = Math.abs(SCALED_WIDTH / 2 - x);
double deltaY = Math.abs(SCALED_HEIGHT / 2 - y);
return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
}
}


Benchmark

Intrigued by coderoddes' comment about the performance of streams, I stumbled upon the following post and benchmarked the method generatePixels().

package com.zipity.cover;

import java.util.Random;
import java.util.function.IntFunction;
import java.util.stream.IntStream;

public final  class CoverArtGeneratorBenchmark {

public static boolean[][] seqGeneratePixelsRange(int n) {
boolean pixelData[][] = new boolean[n][n];
Random random = new Random();
IntStream.range(0, n).forEach(y -> {
for (int x = 0; x < n; x++) {
pixelData[y][x] = random.nextInt(100) < 30;
}
});
return pixelData;
}

public static boolean[][] paraGeneratePixelsRange(int n) {
boolean pixelData[][] = new boolean[n][n];
Random random = new Random();
IntStream.range(0, n).parallel().forEach(y -> {
for (int x = 0; x < n; x++) {
pixelData[y][x] = random.nextInt(100) < 30;
}
});
return pixelData;
}

public static boolean[][] seqGeneratePixelsIterate(int n) {
boolean pixelData[][] = new boolean[n][n];
Random random = new Random();
IntStream.iterate(1, i -> i + 1).limit(n).forEach(y -> {
for (int x = 0; x < n; x++) {
pixelData[y - 1][x] = random.nextInt(100) < 30;
}
});
return pixelData;
}

public static boolean[][] paraGeneratePixelsIterate(int n) {
boolean pixelData[][] = new boolean[n][n];
Random random = new Random();
IntStream.iterate(1, i -> i + 1).limit(n).parallel().forEach(y -> {
for (int x = 0; x < n; x++) {
pixelData[y - 1][x] = random.nextInt(100) < 30;
}
});
return pixelData;
}

public static boolean[][] iterGeneratePixelsLoop(int n) {
boolean pixelData[][] = new boolean[n][n];
Random random = new Random();
for (int y = 0; y < n; y++) {
for (int x = 0; x < n; x++) {
pixelData[y][x] = random.nextInt(100) < 30;
}
}
return pixelData;
}

/*
* Applies the function parameter func, passing n as parameter.
* Returns the average time (ms.) to execute the function 100 times.
*/
public static <R> double measurePerf(IntFunction<R> funcs, int n) {
int numOfExecutions = 100;
double totTime = 0.0;
for (int i = 0; i < numOfExecutions; i++) {
double start = System.nanoTime();
funcs.apply(n);
double duration = (System.nanoTime() - start)/1_000_000;
totTime += duration;
}
double avgTime = totTime/numOfExecutions;
return avgTime;
}

/*
* Executes the functions in the vararg funcs for different stream sizes.
*/

public static <R> void xqtFunctions(@SuppressWarnings("unchecked") IntFunction<R>... funcs) {
int[] sizes = {100, 500, 1000, 2500};

for (int i = 0; i < sizes.length; ++i) {
System.out.printf("%7d", sizes[i]);
for (int j = 0; j < funcs.length; ++j) {
System.out.printf("%10.5f", measurePerf(funcs[j], sizes[i]));
}

System.out.println();
}
}

@SuppressWarnings("unchecked")
public static void main(String[] args) {
System.out.println("Streams created with the range() method:");
System.out.println(" Size Sequential Parallel");
xqtFunctions(CoverArtGeneratorBenchmark::seqGeneratePixelsRange, CoverArtGeneratorBenchmark::paraGeneratePixelsRange);

System.out.println("Streams created with the iterate() method:");
System.out.println(" Size Sequential Parallel");
xqtFunctions(CoverArtGeneratorBenchmark::seqGeneratePixelsIterate, CoverArtGeneratorBenchmark::paraGeneratePixelsIterate);

System.out.println("Iterative solution with an explicit loop:");
System.out.println(" Size Iterative");
xqtFunctions(CoverArtGeneratorBenchmark::iterGeneratePixelsLoop);
}
}



Streams created with the range() method:

Size Sequential Parallel
100 0.19389 0.69368
500 2.52478 18.44609
1000 9.83310 82.51152
2500 59.34847 528.95678
5000 226.03198 Not tested
10000 904.63635 Not tested

Streams created with the iterate() method:

Size Sequential Parallel
100 0.17763 0.90946
500 2.38955 20.80894
1000 8.73398 83.54772
2500 54.71999 519.07294
5000 226.25446 Not tested
10000 913.62030 Not tested

Iterative solution with an explicit loop:

Size Sequential
100 0.15455
500 2.23185
1000 8.91831
2500 55.97316
5000 224.81226
10000 893.46052

Advice I - Add missing main(String[])

public static void main(String[] args) {
new Cover();
}


Advice II - Can make the pixelData private:

You have the following:

boolean[][] pixelData = new boolean[HEIGHT][WIDTH];


I suggest you add the private keyword:

private boolean[][] pixelData = new boolean[HEIGHT][WIDTH];


Advice III - More verbose package names

You have two packages: cover and utils. Why not:

package com.zipity.image.coverart;


Advice IV - Get rid of parallel()

First of all, each parallel stream goes through 100 pixels. That is too little in order to parallel() offer any efficiency gains. I tried to run your program with both parallel() and without it. Without it, the program was around 2+ times faster.

Advice V - More verbose name of the main class

I suggest you rename Cover.java to CoverGenerator.java or even more verbosely CoverArtGenerator.java.

• Thank you, I have adopted the changes - I always struggle with meaningful names. Also, intrigued by your comment, I started to benchmark the streams, and it's even worse than you said (I added the results to the post). Commented Apr 15 at 18:09
• I wonder if, somewhere in between the amount of OP's used parallels and zero parallels, there might be a sweet spot where using one parallel would actually speed up the performance Commented Apr 19 at 7:30