Like many bigger projects, this visualization required a few different languages and tools. Here’s a summary of the code I used to create this project.
I started out with a bunch of comics in .pdf
files, which I got from Image’s website here.
Then I used ImageMagick to convert those .pdf
files into indivial .png
files that I could then process.
magick convert Saga_vol1.pdf pages/saga-1-page-%03d.png
This gave me a .png
file for each page in the comic.
Then I used Processing to come up with the average color of a particular .png
file:
void setup() {
size(500, 500);
PImage image = loadImage("saga-1-page-001.png");
color c = average(image);
background(c);
}
color average(PImage image) {
float r = 0;
float g = 0;
float b = 0;
for (int y = 0; y < image.height; y++) {
for (int x = 0; x < image.width; x++) {
color pixelColor = image.get(x, y);
r += red(pixelColor);
g += green(pixelColor);
b += blue(pixelColor);
}
}
r /= image.width * image.height;
g /= image.width * image.height;
b /= image.width * image.height;
return color(r, g, b);
}
Instead of implementing k-means clustering myself, I used OpenCV for Processing which let me use OpenCV’s kmeans()
function.
This StackOverflow answer was super helpful, as was this GitHub repo by Mario Zechner of libGDX fame.
Note: This code is pretty hackish and wasn’t really designed for other people to read!
Here is the code that generates clusters and their respective weights:
import gab.opencv.OpenCV;
import org.opencv.core.Core;
import org.opencv.core.CvType;
import org.opencv.core.Mat;
import org.opencv.core.TermCriteria;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.LinkedHashMap;
ArrayList<Integer> pageColors = new ArrayList<Integer>();
void setup() {
size(400, 100);
PImage image = loadImage("C:/Users/kevin/Desktop/comics/Saga1/pages/saga-1-page-059.png");
OpenCV openCv = new OpenCV(this, 500, 500);
PGraphics mg = createGraphics(400, 100);
mg.noStroke();
mg.noSmooth();
mg.beginDraw();
mg.noStroke();
Mat m = new Mat(image.height, image.width, CvType.CV_8UC4);
OpenCV.toCv(image, m);
Map<Integer, Float> colorWeights = cluster(m, 4);
float cellX = 0;
for (color c : colorWeights.keySet()) {
float cellWidth = width * colorWeights.get(c);
mg.fill(c);
mg.rect(cellX, 0, cellWidth, height);
cellX += cellWidth;
}
mg.endDraw();
image(mg, 0, 0, width, height);
}
ArrayList<Integer> convertToColors(Mat m) {
ArrayList<Integer> colors = new ArrayList<Integer>();
for (int i = 0; i < m.rows(); i++) {
double r = m.get(i, 1)[0] * 255;
double g = m.get(i, 2)[0] * 255;
double b = m.get(i, 3)[0] * 255;
colors.add(color((float)r, (float)g, (float)b));
}
return colors;
}
public Map<Integer, Float> cluster(Mat image, int k) {
Mat samples = image.reshape(1, image.cols() * image.rows());
Mat samples32f = new Mat();
samples.convertTo(samples32f, CvType.CV_32F, 1.0 / 255.0);
Mat labels = new Mat();
TermCriteria criteria = new TermCriteria(TermCriteria.COUNT, 100, 1);
Mat centers = new Mat();
Core.kmeans(samples32f, k, labels, criteria, 1, Core.KMEANS_PP_CENTERS, centers);
return getColorWeights(image, labels, centers);
}
public Map<Integer, Float> getColorWeights(Mat image, Mat labels, Mat colors) {
Map<Integer, Float> colorTotals = new HashMap<Integer, Float>();
int index = 0;
for (int y = 0; y < image.rows(); y++) {
for (int x = 0; x < image.cols(); x++) {
int label = (int)labels.get(index, 0)[0];
double r = colors.get(label, 1)[0]*255;
double g = colors.get(label, 2)[0]*255;
double b = colors.get(label, 3)[0]*255;
color c = color((float)r, (float)g, (float)b);
if (!colorTotals.containsKey(c)) {
colorTotals.put(c, 0f);
}
colorTotals.put(c, colorTotals.get(c) + 1);
index++;
}
}
int imageSize = image.rows() * image.cols();
List<Entry<Integer, Float>> list = new ArrayList<Entry<Integer, Float>>(colorTotals.entrySet());
list.sort(new Comparator<Entry<Integer, Float>>() {
public int compare(Entry<Integer, Float> eOne, Entry<Integer, Float> eTwo) {
return eTwo.getValue().compareTo(eOne.getValue());
}
}
);
LinkedHashMap<Integer, Float> colorWeights = new LinkedHashMap<Integer, Float>();
for (Entry<Integer, Float> entry : list) {
colorWeights.put(entry.getKey(), entry.getValue() / imageSize);
}
return colorWeights;
}
Fun fact: if you cluster all of the colors into a single group, you end up with the average color!
I also put together a function that took a comic page, clustered it to a certain number of colors, and then recolored the page using only those colors:
void drawReduced (Mat cutout, Mat labels, Mat centers, int k) {
PGraphics pg = createGraphics(cutout.width(), cutout.height());
pg.beginDraw();
pg.noSmooth();
int index = 0;
for (int y = 0; y < cutout.rows(); y++) {
for (int x = 0; x < cutout.cols(); x++) {
int label = (int)labels.get(index, 0)[0];
double r = centers.get(label, 1)[0]*255;
double g = centers.get(label, 2)[0]*255;
double b = centers.get(label, 3)[0]*255;
pg.stroke(color((float)r, (float)g, (float)b));
pg.point(x, y);
index++;
}
}
pg.endDraw();
image(pg, 0, 0, width, height);
}
This was a ton of fun to play around with, but it didn’t make it into the visualization. I might work it into another project in the future…
Instead of using the weights, I ended up just drawing each color as a single line.
void addClusterBarCodePage(String pageFile, int k) {
PImage image = loadImage(pageFile);
Mat m = new Mat(image.height, image.width, CvType.CV_8UC4);
OpenCV.toCv(image, m);
Map<Integer, Float> colorWeights = cluster(m, k);
PGraphics pg = createGraphics(colorWeights.size(), height);
pg.noSmooth();
pg.beginDraw();
int lineX = 0;
for (color c : colorWeights.keySet()) {
pg.stroke(c);
pg.line(lineX, 0, lineX, pg.height);
lineX++;
}
pg.endDraw();
pageGraphics.add(pg);
}
Now that I had code that handled a single page, I wrote a program that looped over the first 100 pages of a given comic and stitched together each page’s visualization to create the end result.
ArrayList<PGraphics> pageGraphics = new ArrayList<PGraphics>();
int cellWidth;
int cellHeight;
enum PageConversionMode {
AVERAGE, WEIGHTED_CLUSTER, LINE_CLUSTER;
}
enum PageOutputMode {
GRID, BARCODE;
}
PageConversionMode pageConversionMode = PageConversionMode.AVERAGE;
PageOutputMode pageOutputMode = PageOutputMode.GRID;
String comic = "Saga-1";
void setup() {
size(600, 900);
loadOpenCv();
cellWidth = width / 10;
cellHeight = height / 10;
addPages("C:/Users/kevin/Desktop/comics/" + comic + "/pages");
if (pageOutputMode == PageOutputMode.GRID) {
drawPagesAsGrid();
} else {
drawPagesAsBarCode();
}
save(comic + "-averages-1.png");
}
void drawPagesAsGrid() {
int pageIndex = 0;
for (int y = 0; y < 10; y++) {
for (int x = 0; x < 10; x++) {
PGraphics pg = pageGraphics.get(pageIndex);
image(pg, x*cellWidth, y*cellHeight, cellWidth, cellHeight);
pageIndex++;
noFill();
noStroke();
rect(x*cellWidth, y*cellHeight, cellWidth, cellHeight);
}
}
}
void drawPagesAsBarCode() {
background(255, 0, 0);
int pgX = 0;
for (int i = 0; i < pageGraphics.size(); i++) {
PGraphics pg = pageGraphics.get(i);
image(pg, pgX, 0);
pgX += pg.width;
}
}
void addPages(String pageDirectory) {
File pageDir = new File(pageDirectory);
for (File pageFile : pageDir.listFiles()) {
// AVERAGE, WEIGHTED_CLUSTER, LINE_CLUSTER;
if(pageConversionMode == PageConversionMode.AVERAGE){
addAveragePage(pageFile.getAbsolutePath());
}
else if(pageConversionMode == PageConversionMode.WEIGHTED_CLUSTER){
addClusterBarCodeWeightedPage(pageFile.getAbsolutePath(), 4, 10);
}
else{
addClusterBarCodePage(pageFile.getAbsolutePath(), 4);
}
println(pageFile.getName());
if (pageGraphics.size() >= 100) {
return;
}
}
}
This code is pretty messy, a result of hacking different things together at different times. But I could use this to create all of the visualizations ahead of time.
Now that I had the visualizations for each comic book, I put it all together using HTML, JavaScript, and P5.js.
var comicName = 'invincible';
var averagesImage;
var thumbnails = [];
function setup() {
var canvas = createCanvas(400, 400);
canvas.parent("sketch-holder");
loadComic();
windowResized();
noLoop();
}
function draw() {
background(32);
stroke(255);
fill(255);
textSize(36);
text("Loading...", width*.25, height/2);
image(averagesImage, 0, 0, width, height);
drawThumbnails();
}
function drawThumbnails(){
thumbnails.forEach( (thumbImage, index) => {
const cellIndexX = index % 10;
const cellIndexY = int(index / 10);
const cellWidth = width / 10;
const cellHeight = height / 10;
const cellX = cellIndexX * cellWidth;
const cellY = cellIndexY * cellHeight;
image(thumbnails[index], cellX, cellY, cellWidth, cellHeight);
} );
}
function mousePressed(){
if(mouseX < 0 || mouseX > width || mouseY < 0 || mouseY > height){
return true;
}
const cellWidth = width / 10;
const cellHeight = height / 10;
const cellX = int(mouseX/cellWidth);
const cellY = int(mouseY/cellHeight);
const thumbnailIndex = cellY * 10 + cellX;
if(thumbnails[thumbnailIndex]){
delete thumbnails[thumbnailIndex];
}
else{
thumbnails[thumbnailIndex] = loadImage('//s3.happycoding.io/gallery/comic-book-colors/images/comics/' + comicName + '/thumbnails/' + comicName + '-thumbnail-' + thumbnailIndex.toString().padStart(3, '0') + '.png', redraw);
}
redraw();
return false;
}
function windowResized(){
var sketchHolder = select('#sketch-holder').elt;
resizeCanvas(sketchHolder.clientWidth, sketchHolder.clientWidth * 1.5);
}
function loadComic(){
averagesImage = loadImage('//s3.happycoding.io/gallery/comic-book-colors/images/comics/' + comicName + '/' + comicName + '-averages-1.png', redraw);
select('#line-clusters').elt.src = '//s3.happycoding.io/gallery/comic-book-colors/images/comics/' + comicName + '/' + comicName + '-line-clusters-1.png';
select('#name').html(comics.get(comicName).title);
}
function setComic(comic){
comicName = comic;
thumbnails = [];
loadComic();
redraw();
}
This JavaScript code is what makes the interactive tool respond to user input, changing the images when the user clicks the buttons or in the averages grid.
All of this code is released under a Creative Commons Attribution open-source license. That means you can do whatever you want with it, just please credit a link back here so people can learn more about coding.
Here are a few ideas for how you could take this code and do something interesting:
If you come up with something cool, please post about it on the forum so I can see it!