Created
January 5, 2021 15:55
-
-
Save tomwhoiscontrary/337cb8aaef013327a8909967970adf48 to your computer and use it in GitHub Desktop.
Crappy threshold maps for dithering, following https://news.ycombinator.com/item?id=25645286
This file contains 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 javax.imageio.ImageIO; | |
import java.awt.image.BufferedImage; | |
import java.awt.image.WritableRaster; | |
import java.io.IOException; | |
import java.nio.file.Files; | |
import java.nio.file.Path; | |
import java.util.ArrayList; | |
import java.util.Arrays; | |
import java.util.List; | |
import java.util.Objects; | |
import java.util.Random; | |
import java.util.stream.Collectors; | |
import java.util.stream.IntStream; | |
import java.util.stream.Stream; | |
public class ThresholdMap { | |
public static void main(String[] args) throws IOException { | |
int width = 400; | |
int height = 256; | |
String mode = "hp"; | |
int diameter = 5; | |
long seed = new Random().nextLong(); | |
for (String arg : args) { | |
int split = arg.indexOf("="); | |
String name = arg.substring(0, split); | |
String value = arg.substring(split + 1); | |
switch (name) { | |
case "width": | |
width = Integer.parseInt(value); | |
break; | |
case "height": | |
height = Integer.parseInt(value); | |
break; | |
case "mode": | |
mode = value; | |
break; | |
case "diameter": | |
diameter = Integer.parseInt(value); | |
break; | |
case "seed": | |
seed = Long.parseLong(value); | |
break; | |
default: | |
throw new IllegalArgumentException(name); | |
} | |
} | |
main(width, height, mode, diameter, seed); | |
} | |
private static void main(int width, | |
int height, | |
String mode, | |
int diameter, | |
long seed) throws IOException { | |
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); | |
WritableRaster raster = image.getRaster(); | |
Random random = new Random(seed); | |
List<Object> label = new ArrayList<>(); | |
label.add(mode); | |
switch (mode) { | |
case "w": { | |
whiteNoise(random, raster); | |
} | |
break; | |
case "sb": { | |
label.add(diameter); | |
shuffledBlockBlueNoise(diameter, random, raster); | |
} | |
break; | |
case "hp": { | |
label.add(diameter); | |
highPassFilterBlueNoise(diameter, random, raster); | |
} | |
break; | |
default: { | |
throw new IllegalArgumentException(mode); | |
} | |
} | |
label.add(String.format("%016x", seed)); | |
write(label, image); | |
} | |
// whiteNoise | |
private static void whiteNoise(Random random, WritableRaster raster) { | |
for (int x = 0; x < raster.getWidth(); x++) { | |
for (int y = 0; y < raster.getHeight(); y++) { | |
raster.setSample(x, y, 0, random.nextInt(256)); | |
} | |
} | |
} | |
// shuffledBlockBlueNoise | |
private static void shuffledBlockBlueNoise(int blockDiameter, Random random, WritableRaster raster) { | |
int blockArea = square(blockDiameter); | |
int step = 259 / blockArea; // oversize numerator to mitigate bias from rounding down | |
int[] ramp = IntStream.range(0, blockArea).map(v -> v * step).toArray(); | |
for (int blockX = 0; blockX < raster.getWidth(); blockX += blockDiameter) { | |
for (int blockY = 0; blockY < raster.getHeight(); blockY += blockDiameter) { | |
shuffle(ramp, random); | |
for (int offsetX = 0; offsetX < blockDiameter; offsetX++) { | |
for (int offsetY = 0; offsetY < blockDiameter; offsetY++) { | |
int x = blockX + offsetX; | |
int y = blockY + offsetY; | |
if (!raster.getBounds().contains(x, y)) continue; | |
int i = index(offsetX, offsetY, blockDiameter, blockDiameter); | |
raster.setSample(x, y, 0, ramp[i]); | |
} | |
} | |
} | |
} | |
} | |
private static void shuffle(int[] array, Random random) { | |
for (int i = 0; i < array.length - 1; i++) { | |
int j = i + random.nextInt(array.length - i); | |
int t = array[i]; | |
array[i] = array[j]; | |
array[j] = t; | |
} | |
} | |
// highPassFilterBlueNoise | |
private static void highPassFilterBlueNoise(int kernelDiameter, Random random, WritableRaster raster) { | |
int width = raster.getWidth(); | |
int height = raster.getHeight(); | |
int size = width * height; | |
double[] noise = random.doubles().limit(size).toArray(); | |
double[] kernel = gaussianKernel(1, kernelDiameter); // sigma is always 1 despite diameter, which is a bit useless! | |
double[] filtered = IntStream.range(0, noise.length).mapToDouble(i -> noise[i] - convolve(width, height, noise, kernelDiameter, kernel, i)).toArray(); | |
double[] cdf = Arrays.stream(filtered).sorted().toArray(); | |
double[] thresholds = IntStream.range(0, 256).mapToDouble(i -> cdf[i * size / 256]).toArray(); | |
int[] equalized = Arrays.stream(filtered).mapToInt(v -> indexOfFloor(thresholds, v)).toArray(); | |
raster.setSamples(0, 0, width, height, 0, equalized); | |
} | |
static double[] gaussianKernel(double sigma, int diameter) { | |
// thanks https://www.geeksforgeeks.org/gaussian-filter-generation-c/ | |
double[] kernel = new double[square(diameter)]; | |
double s = 2.0 * sigma * sigma; | |
double sum = 0; | |
for (int x = 0; x < diameter; x++) { | |
for (int y = 0; y < diameter; y++) { | |
int r = square(x - diameter / 2) + square(y - diameter / 2); | |
double v = Math.exp(-(double) r / s) / Math.PI / s; | |
kernel[index(x, y, diameter, diameter)] = v; | |
sum += v; | |
} | |
} | |
for (int x = 0; x < diameter; ++x) { | |
for (int y = 0; y < diameter; ++y) { | |
kernel[index(x, y, diameter, diameter)] /= sum; | |
} | |
} | |
return kernel; | |
} | |
private static double convolve(int width, int height, double[] a, int kernelSize, double[] kernel, int i) { | |
int x = i % width; | |
int y = i / width; | |
double sum = 0; | |
for (int kx = 0; kx < kernelSize; kx++) { | |
for (int ky = 0; ky < kernelSize; ky++) { | |
int dx = kx - kernelSize / 2; | |
int dy = ky - kernelSize / 2; | |
int sx = (x + dx + width) % width; | |
int sy = (y + dy + height) % height; | |
sum += a[index(sx, sy, width, height)] * kernel[index(kx, ky, kernelSize, kernelSize)]; | |
} | |
} | |
return sum; | |
} | |
private static int indexOfFloor(double[] a, double key) { | |
int i = Arrays.binarySearch(a, key); | |
return i >= 0 ? i : -(i + 1) - 1; | |
} | |
private static int index(int x, int y, int width, int height) { | |
Objects.checkIndex(x, width); | |
Objects.checkIndex(y, height); | |
return x + y * width; | |
} | |
// common | |
private static int square(int a) { | |
return a * a; | |
} | |
// write | |
private static void write(List<Object> label, BufferedImage image) throws IOException { | |
String name = Stream.of(Stream.of(ThresholdMap.class.getSimpleName()), | |
label.stream().map(String::valueOf), | |
Stream.of("png")) | |
.reduce(Stream.empty(), Stream::concat) | |
.collect(Collectors.joining(".")); | |
Path path = findTempDir().resolve(name); | |
System.out.println(path); | |
ImageIO.write(image, "PNG", path.toFile()); | |
} | |
private static Path findTempDir() throws IOException { | |
Path tempFile = Files.createTempFile("", ""); | |
Path tempDir = tempFile.getParent(); | |
Files.delete(tempFile); | |
return tempDir; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment