From 5904814e69a37ae7362f149b13ff0c386de8aacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 4 Nov 2026 11:29:32 +0100 Subject: [PATCH] reduce harsh dithering: constrained error diffusion in Lab space Colors the map palette cannot represent (light sky blue, sunset tones) were approximated by mixing far-apart palette colors, producing a high-contrast checkerboard in the sky and stray speckles elsewhere. Diffuse the error in CIELAB and only allow palette colors nearly as close to the original as the best match, so the dither blends between neighbouring colors only. --- .../pixelpics/utils/MapColorPalette.java | 8 +- .../pixelpics/utils/MapImageDither.java | 93 +++++++++++++------ 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java index 75fd606..6d0aae2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java @@ -74,6 +74,12 @@ public final class MapColorPalette { return bestPos; } + /** CIELAB of the palette color at {@code pos} (do not modify). */ + public static double[] lab(int pos) { + ensure(); + return lab[pos]; + } + public static byte mapIndex(int pos) { return indices[pos]; } @@ -82,7 +88,7 @@ public final class MapColorPalette { return rgb[pos]; } - private static double[] rgbToLab(int r, int g, int b) { + public static double[] rgbToLab(int r, int g, int b) { double rl = ColorUtil.toLinear(r), gl = ColorUtil.toLinear(g), bl = ColorUtil.toLinear(b); // sRGB -> XYZ (D65) double x = rl * 0.4124 + gl * 0.3576 + bl * 0.1805; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java index ff592c4..d0b76d4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java @@ -3,10 +3,16 @@ package eu.mhsl.minecraft.pixelpics.utils; import java.awt.image.BufferedImage; /** - * Quantizes a rendered image to Minecraft map color indices using dampened Floyd–Steinberg error - * diffusion with perceptual (CIELAB) nearest-color matching. The result is a {@code 128*128} byte + * Quantizes a rendered image to Minecraft map color indices. The result is a {@code 128*128} byte * array of palette indices, ready to blit onto a {@link org.bukkit.map.MapCanvas}. * + *

Uses dampened Floyd–Steinberg error diffusion in CIELAB space with constrained + * candidates: a pixel may only take a palette color that is nearly as close to its original color + * as the best match. Colors the map palette cannot represent well (e.g. light sky blue, sunset + * tones) would otherwise be approximated by mixing far-apart colors, producing a harsh, + * high-contrast checkerboard; the constraint keeps the dither between neighbouring colors only, + * so flat areas stay calm while gradients still blend. + * *

This is the expensive step; computing it once and caching the byte array lets map re-rendering * after a restart be a cheap copy instead of a full re-quantization. */ @@ -14,8 +20,17 @@ public final class MapImageDither { public static final int SIZE = 128; - /** Fraction of quantization error diffused. Low values keep gradients smooth without visible noise. */ - private static final float DITHER_STRENGTH = 0.35f; + /** Fraction of quantization error diffused. */ + private static final double DITHER_STRENGTH = 0.6; + + /** + * A candidate is allowed when its Lab distance to the original pixel is at most + * {@code best * CANDIDATE_RATIO + CANDIDATE_SLACK}, where {@code best} is the distance of the + * nearest palette color. + */ + private static final double CANDIDATE_RATIO = 1.1; + + private static final double CANDIDATE_SLACK = 1.5; private MapImageDither() {} @@ -23,51 +38,69 @@ public final class MapImageDither { public static byte[] dither(BufferedImage image) { int w = Math.min(SIZE, image.getWidth()); int h = Math.min(SIZE, image.getHeight()); + int paletteSize = MapColorPalette.size(); byte[] out = new byte[SIZE * SIZE]; - float[][] buf = new float[h][w * 3]; + double[][] original = new double[w * h][]; + double[][] buf = new double[w * h][]; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { int argb = image.getRGB(x, y); - buf[y][x * 3] = (argb >> 16) & 0xFF; - buf[y][x * 3 + 1] = (argb >> 8) & 0xFF; - buf[y][x * 3 + 2] = argb & 0xFF; + double[] lab = MapColorPalette.rgbToLab((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF); + original[y * w + x] = lab; + buf[y * w + x] = lab.clone(); } } for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { - int i = x * 3; - int r = clamp(buf[y][i]); - int g = clamp(buf[y][i + 1]); - int b = clamp(buf[y][i + 2]); + int i = y * w + x; + double[] orig = original[i]; + double[] want = buf[i]; - int pos = MapColorPalette.nearestPos(r, g, b); - out[y * SIZE + x] = MapColorPalette.mapIndex(pos); + double bestToOrig = Double.MAX_VALUE; + for (int p = 0; p < paletteSize; p++) { + bestToOrig = Math.min(bestToOrig, dist2(MapColorPalette.lab(p), orig)); + } + double limit = Math.sqrt(bestToOrig) * CANDIDATE_RATIO + CANDIDATE_SLACK; + limit *= limit; - int chosen = MapColorPalette.mapRgb(pos); - float er = (r - ((chosen >> 16) & 0xFF)) * DITHER_STRENGTH; - float eg = (g - ((chosen >> 8) & 0xFF)) * DITHER_STRENGTH; - float eb = (b - (chosen & 0xFF)) * DITHER_STRENGTH; + int chosen = 0; + double chosenDist = Double.MAX_VALUE; + for (int p = 0; p < paletteSize; p++) { + double[] lab = MapColorPalette.lab(p); + if (dist2(lab, orig) > limit) continue; + double d = dist2(lab, want); + if (d < chosenDist) { + chosenDist = d; + chosen = p; + } + } + out[y * SIZE + x] = MapColorPalette.mapIndex(chosen); - spread(buf, y, x + 1, w, h, er, eg, eb, 7f / 16f); - spread(buf, y + 1, x - 1, w, h, er, eg, eb, 3f / 16f); - spread(buf, y + 1, x, w, h, er, eg, eb, 5f / 16f); - spread(buf, y + 1, x + 1, w, h, er, eg, eb, 1f / 16f); + double[] c = MapColorPalette.lab(chosen); + double el = (want[0] - c[0]) * DITHER_STRENGTH; + double ea = (want[1] - c[1]) * DITHER_STRENGTH; + double eb = (want[2] - c[2]) * DITHER_STRENGTH; + spread(buf, w, h, x + 1, y, el, ea, eb, 7.0 / 16.0); + spread(buf, w, h, x - 1, y + 1, el, ea, eb, 3.0 / 16.0); + spread(buf, w, h, x, y + 1, el, ea, eb, 5.0 / 16.0); + spread(buf, w, h, x + 1, y + 1, el, ea, eb, 1.0 / 16.0); } } return out; } - private static void spread(float[][] buf, int y, int x, int w, int h, float er, float eg, float eb, float f) { - if (x < 0 || x >= w || y < 0 || y >= h) return; - int i = x * 3; - buf[y][i] += er * f; - buf[y][i + 1] += eg * f; - buf[y][i + 2] += eb * f; + private static double dist2(double[] a, double[] b) { + double dl = a[0] - b[0], da = a[1] - b[1], db = a[2] - b[2]; + return dl * dl + da * da + db * db; } - private static int clamp(float v) { - return v < 0 ? 0 : (v > 255 ? 255 : Math.round(v)); + private static void spread(double[][] buf, int w, int h, int x, int y, double el, double ea, double eb, double f) { + if (x < 0 || x >= w || y >= h) return; + double[] v = buf[y * w + x]; + v[0] += el * f; + v[1] += ea * f; + v[2] += eb * f; } }