Created
April 12, 2023 17:51
-
-
Save visy/b01ce0c311d0c30b48933b8505c65c14 to your computer and use it in GitHub Desktop.
320x200 image to 40x25 charmode generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import java.util.*; | |
import java.util.Comparator; | |
PImage img; | |
PImage ditheredImg; | |
int blockSize = 8; | |
int numChars = 128; | |
int ditherSize = 128; | |
ArrayList<PImage> customCharset; | |
int[][] pseudographics; | |
final color[] C64_PALETTE = { | |
color(0, 0, 0), // Black | |
color(255, 255, 255), // White | |
color(136, 0, 0), // Red | |
color(170, 255, 238), // Cyan | |
color(204, 68, 204), // Purple | |
color(0, 204, 85), // Green | |
color(0, 0, 170), // Blue | |
color(238, 238, 119), // Yellow | |
color(221, 136, 85), // Orange | |
color(102, 68, 0), // Brown | |
color(255, 119, 119), // Light Red | |
color(51, 51, 51), // Dark Gray | |
color(119, 119, 119), // Gray | |
color(170, 255, 102), // Light Green | |
color(0, 136, 255), // Light Blue | |
color(187, 187, 187) // Light Gray | |
}; | |
byte[][] colorRam; | |
void setup() { | |
size(320, 200); | |
img = loadImage("image.jpg"); | |
colorRam = generateColorRamFromImage(img, 40, 25); | |
redither(); | |
} | |
void draw() { | |
displayPseudographics(pseudographics, customCharset); | |
// image(ditheredImg,0,0); | |
} | |
byte[][] generateColorRamFromImage(PImage img, int width, int height) { | |
byte[][] colorRam = new byte[height][width]; | |
for (int y = 0; y < height; y++) { | |
for (int x = 0; x < width; x++) { | |
PImage cell = img.get(x * blockSize, y * blockSize, blockSize, blockSize); | |
ArrayList<Integer> dominantColors = findTopTwoColors(cell); | |
byte[] c64Colors = new byte[2]; | |
for (int i = 0; i < dominantColors.size(); i++) { | |
c64Colors[i] = (byte)dominantColors.get(i).byteValue(); | |
} | |
colorRam[y][x] = (byte) ((0<<4) | (c64Colors[0] & 0x0F)); | |
} | |
} | |
return colorRam; | |
} | |
ArrayList<Integer> findTopTwoColors(PImage img) { | |
HashMap<Integer, Integer> colorCounts = new HashMap<Integer, Integer>(); | |
for (int y = 0; y < img.height; y++) { | |
for (int x = 0; x < img.width; x++) { | |
int c = img.get(x, y); | |
int c64ColorIndex = findClosestC64ColorIndex(c); | |
colorCounts.put(c64ColorIndex, colorCounts.getOrDefault(c64ColorIndex, 0) + 1); | |
} | |
} | |
List<Map.Entry<Integer, Integer>> sortedEntries = new ArrayList<Map.Entry<Integer, Integer>>(colorCounts.entrySet()); | |
Collections.sort(sortedEntries, new Comparator<Map.Entry<Integer, Integer>>() { | |
@Override | |
public int compare(Map.Entry<Integer, Integer> entry1, Map.Entry<Integer, Integer> entry2) { | |
return entry2.getValue().compareTo(entry1.getValue()); | |
} | |
}); | |
ArrayList<Integer> topTwoColors = new ArrayList<Integer>(); | |
topTwoColors.add(sortedEntries.get(0).getKey()); | |
if (sortedEntries.size() == 1) { | |
} else { | |
topTwoColors.add(sortedEntries.get(1).getKey()); | |
} | |
return topTwoColors; | |
} | |
float colorDistance(color c1, color c2) { | |
float rDiff = red(c1) - red(c2); | |
float gDiff = green(c1) - green(c2); | |
float bDiff = blue(c1) - blue(c2); | |
return sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); | |
} | |
byte findClosestC64ColorIndex(color c) { | |
float minDistance = 100000; | |
byte minIndex = -1; | |
for (int i = 0; i < C64_PALETTE.length; i++) { | |
float distance = colorDistance(c, C64_PALETTE[i]); | |
if (distance < minDistance) { | |
minDistance = distance; | |
minIndex = (byte) i; | |
} | |
} | |
return minIndex; | |
} | |
void redither() { | |
ditheredImg = ditherImage(img,ditherSize); // Convert to 1-bit black and white with Atkinson dithering | |
customCharset = generateCustomCharset(ditheredImg, blockSize, numChars); | |
pseudographics = generatePseudographics(ditheredImg, customCharset); | |
} | |
void saveCharsetImage(ArrayList<PImage> customCharset, String filename) { | |
PImage charsetImage = createImage(128, 128, RGB); | |
charsetImage.loadPixels(); | |
for (int i = 0; i < customCharset.size(); i++) { | |
PImage charImg = customCharset.get(i); | |
int x = (i % 16) * blockSize; | |
int y = (i / 16) * blockSize; | |
charImg.loadPixels(); | |
for (int row = 0; row < blockSize; row++) { | |
for (int col = 0; col < blockSize; col++) { | |
charsetImage.pixels[(x + col) + (y + row) * 128] = charImg.pixels[col + row * blockSize]; | |
} | |
} | |
} | |
charsetImage.updatePixels(); | |
charsetImage.save(filename); | |
} | |
void saveIndexTable(int[][] pseudographics, String filename) { | |
PrintWriter output = createWriter(filename); | |
for (int y = 0; y < pseudographics.length; y++) { | |
String line = ""; | |
for (int x = 0; x < pseudographics[y].length; x++) { | |
line += pseudographics[y][x] + (x < pseudographics[y].length - 1 ? "," : ""); | |
} | |
output.println(line); | |
} | |
output.flush(); | |
output.close(); | |
} | |
PImage ditherImage(PImage inputImg, int threshold) { | |
PImage dithered = inputImg.copy(); | |
dithered.filter(GRAY); | |
dithered.loadPixels(); | |
float[][] atkinsonMatrix = { | |
{0, 0, 1 / 8.0, 1 / 8.0}, | |
{1 / 8.0, 1 / 8.0, 1 / 8.0, 0}, | |
{0, 1 / 8.0, 0, 0} | |
}; | |
for (int y = 0; y < dithered.height; y++) { | |
for (int x = 0; x < dithered.width; x++) { | |
int idx = x + y * dithered.width; | |
float oldPixel = brightness(dithered.pixels[idx]); | |
float newPixel = oldPixel < threshold ? 0 : 255; | |
dithered.pixels[idx] = color(newPixel); | |
float quantError = oldPixel - newPixel; | |
for (int j = 0; j < atkinsonMatrix.length; j++) { | |
for (int i = 0; i < atkinsonMatrix[j].length; i++) { | |
int newY = y + j; | |
int newX = x + i - 1; | |
if (newX >= 0 && newY >= 0 && newX < dithered.width && newY < dithered.height) { | |
int newIdx = newX + newY * dithered.width; | |
float newValue = brightness(dithered.pixels[newIdx]) + quantError * atkinsonMatrix[j][i]; | |
dithered.pixels[newIdx] = color(constrain(newValue, 0, 255)); | |
} | |
} | |
} | |
} | |
} | |
dithered.updatePixels(); | |
return dithered; | |
} | |
ArrayList<PImage> generateCustomCharset(PImage ditheredImg, int blockSize, int numChars) { | |
ArrayList<PImage> blocks = getBlocks(ditheredImg, blockSize); | |
ArrayList<PImage> centroids = initCentroids(blocks, numChars); | |
boolean changed = true; | |
int iterations = 0; | |
while (changed && iterations < 50) { | |
ArrayList<ArrayList<PImage>> clusters = createEmptyClusters(numChars); | |
for (PImage block : blocks) { | |
int minIndex = findClosestCentroidIndex(block, centroids); | |
clusters.get(minIndex).add(block); | |
} | |
ArrayList<PImage> newCentroids = new ArrayList<PImage>(); | |
for (ArrayList<PImage> cluster : clusters) { | |
newCentroids.add(calculateCentroid(cluster, blockSize)); | |
} | |
if (compareCentroids(centroids, newCentroids)) { | |
changed = false; | |
} else { | |
centroids = newCentroids; | |
} | |
iterations++; | |
} | |
PImage whiteTile = createImage(8, 8, RGB); | |
whiteTile.loadPixels(); | |
for (int i = 0; i < whiteTile.pixels.length; i++) { | |
whiteTile.pixels[i] = color(255); | |
} | |
whiteTile.updatePixels(); | |
String whiteTileHash = getImageHash(whiteTile); | |
for (int i = centroids.size() - 1; i >= 0; i--) { | |
if (getImageHash(centroids.get(i)).equals(whiteTileHash)) { | |
centroids.remove(i); | |
} | |
} | |
// Sort the charset by black to white fill ratio | |
centroids.sort(new Comparator<PImage>() { | |
public int compare(PImage img1, PImage img2) { | |
float fillRatio1 = getBlackFillRatio(img1); | |
float fillRatio2 = getBlackFillRatio(img2); | |
return Float.compare(fillRatio1, fillRatio2); | |
} | |
}); | |
return centroids; | |
} | |
float getBlackFillRatio(PImage img) { | |
img.loadPixels(); | |
int blackPixels = 0; | |
for (int i = 0; i < img.pixels.length; i++) { | |
if (img.pixels[i] == color(0)) { | |
blackPixels++; | |
} | |
} | |
return (float) blackPixels / img.pixels.length; | |
} | |
ArrayList<PImage> getBlocks(PImage img, int blockSize) { | |
ArrayList<PImage> blocks = new ArrayList<PImage>(); | |
for (int y = 0; y < img.height; y += blockSize) { | |
for (int x = 0; x < img.width; x += blockSize) { | |
PImage block = img.get(x, y, blockSize, blockSize); | |
blocks.add(block); | |
} | |
} | |
return blocks; | |
} | |
ArrayList<PImage> initCentroids(ArrayList<PImage> blocks, int numChars) { | |
ArrayList<PImage> centroids = new ArrayList<PImage>(); | |
HashSet<String> uniqueCentroids = new HashSet<String>(); | |
while (centroids.size() < numChars) { | |
int randomIndex = int(random(blocks.size())); | |
PImage block = blocks.get(randomIndex); | |
String blockHash = getImageHash(block); | |
if (!uniqueCentroids.contains(blockHash)) { | |
centroids.add(block); | |
uniqueCentroids.add(blockHash); | |
} | |
blocks.remove(randomIndex); | |
} | |
return centroids; | |
} | |
String getImageHash(PImage img) { | |
img.loadPixels(); | |
StringBuilder hash = new StringBuilder(); | |
for (int i = 0; i < img.pixels.length; i++) { | |
hash.append(img.pixels[i]); | |
} | |
return hash.toString(); | |
} | |
ArrayList<ArrayList<PImage>> createEmptyClusters(int numChars) { | |
ArrayList<ArrayList<PImage>> clusters = new ArrayList<ArrayList<PImage>>(); | |
for (int i = 0; i < numChars; i++) { | |
clusters.add(new ArrayList<PImage>()); | |
} | |
return clusters; | |
} | |
int findClosestCentroidIndex(PImage block, ArrayList<PImage> centroids) { | |
int minIndex = 0; | |
float minDist = Float.MAX_VALUE; | |
for (int i = 0; i < centroids.size(); i++) { | |
float dist = calculateBlockDistance(block, centroids.get(i)); | |
if (dist < minDist) { | |
minDist = dist; | |
minIndex = i; | |
} | |
} | |
return minIndex; | |
} | |
float calculateBlockDistance(PImage block1, PImage block2) { | |
float dist = 0; | |
for (int y = 0; y < block1.height; y++) { | |
for (int x = 0; x < block1.width; x++) { | |
float diff = abs(brightness(block1.get(x, y)) - brightness(block2.get(x, y))); | |
dist += diff; | |
} | |
} | |
return dist; | |
} | |
PImage calculateCentroid(ArrayList<PImage> cluster, int blockSize) { | |
PImage centroid = createImage(blockSize, blockSize, RGB); | |
centroid.loadPixels(); | |
for (int y = 0; y < blockSize; y++) { | |
for (int x = 0; x < blockSize; x++) { | |
float sum = 0; | |
for (PImage block : cluster) { | |
sum += brightness(block.get(x, y)); | |
} | |
float averageBrightness = sum / cluster.size(); | |
centroid.pixels[x + y * blockSize] = color(averageBrightness < 128 ? 0 : 255); | |
} | |
} | |
centroid.updatePixels(); | |
return centroid; | |
} | |
boolean compareCentroids(ArrayList<PImage> centroids1, ArrayList<PImage> centroids2) { | |
for (int i = 0; i < centroids1.size(); i++) { | |
if (calculateBlockDistance(centroids1.get(i), centroids2.get(i)) > 0.01) { | |
return false; | |
} | |
} | |
return true; | |
} | |
int[][] generatePseudographics(PImage ditheredImg, ArrayList<PImage> customCharset) { | |
int[][] pseudographics = new int[ditheredImg.height / blockSize][ditheredImg.width / blockSize]; | |
for (int y = 0; y < ditheredImg.height; y += blockSize) { | |
for (int x = 0; x < ditheredImg.width; x += blockSize) { | |
PImage block = ditheredImg.get(x, y, blockSize, blockSize); | |
int minIndex = findClosestCentroidIndex(block, customCharset); | |
pseudographics[y / blockSize][x / blockSize] = minIndex; | |
} | |
} | |
return pseudographics; | |
} | |
void displayPseudographics(int[][] pseudographics, ArrayList<PImage> customCharset) { | |
for (int y = 0; y < pseudographics.length; y++) { | |
for (int x = 0; x < pseudographics[y].length; x++) { | |
PImage block = customCharset.get(pseudographics[y][x]); | |
int colorData = colorRam[y][x]; | |
int fgColor = C64_PALETTE[(colorData & 0xF0) >> 4]; | |
int bgColor = C64_PALETTE[colorData & 0x0F]; | |
renderBlockWithColors(block, x * blockSize, y * blockSize, fgColor, bgColor); | |
} | |
} | |
} | |
void renderBlockWithColors(PImage block, int x, int y, color fgColor, color bgColor) { | |
block.loadPixels(); | |
for (int j = 0; j < block.height; j++) { | |
for (int i = 0; i < block.width; i++) { | |
if (block.pixels[j * block.width + i] == color(0)) { | |
set(x + i, y + j, fgColor); | |
} else { | |
set(x + i, y + j, bgColor); | |
} | |
} | |
} | |
} | |
void keyPressed() { | |
if (key == 'z') { | |
ditherSize-=16; | |
customCharset.clear(); | |
pseudographics = null; | |
redither(); | |
} | |
if (key == 'x') { | |
ditherSize+=16; | |
customCharset.clear(); | |
pseudographics = null; | |
redither(); | |
} | |
if (key == 'a') { | |
numChars-=1; | |
customCharset.clear(); | |
pseudographics = null; | |
redither(); | |
} | |
if (key == 's') { | |
numChars+=1; | |
customCharset.clear(); | |
pseudographics = null; | |
redither(); | |
} | |
if (key == 'o') { | |
saveCharsetImage(customCharset, "charset.png"); | |
saveIndexTable(pseudographics, "index_table.txt"); | |
} | |
if (key == 'q') { | |
saveFrame("frame.png"); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment