Created
October 4, 2026 15:16
-
-
Save glaforge/5e8fe1f52905b3bc65655a131ec61d4d to your computer and use it in GitHub Desktop.
OkPalette.java
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
| ///usr/bin/env jbang "$0" "$@" ; exit $? | |
| //JAVA 21+ | |
| // Guess color palette algorithm based on this website: | |
| // https://okpalette.color.pizza/ | |
| import java.awt.Graphics2D; | |
| import java.awt.RenderingHints; | |
| import java.awt.image.BufferedImage; | |
| import java.io.File; | |
| import java.io.IOException; | |
| import java.net.URI; | |
| import java.net.http.HttpClient; | |
| import java.net.http.HttpRequest; | |
| import java.net.http.HttpResponse; | |
| import java.time.Duration; | |
| import java.util.*; | |
| import java.util.regex.Matcher; | |
| import java.util.regex.Pattern; | |
| import javax.imageio.ImageIO; | |
| /** | |
| * OKpalette - Perceptual Color Extraction Tool | |
| * Replicates the extraction engine and color math from https://okpalette.color.pizza/ | |
| * | |
| * Implements: | |
| * - Oklab and OKLCH color spaces | |
| * - 3D cylindrical histogram quantization (16 L x 16 C x 48 h = 12,288 sectors) | |
| * - Automatic image statistical profiling (Lightness & Chroma distributions) | |
| * - Adaptive bias derivation (sparse accent detection & light/dark compensation) | |
| * - Non-linear logarithmic weighting & prominence scaling | |
| * - 3D Mean Shift clustering in Oklab for coherent saturated colors | |
| * - Physics-inspired iterative candidate selection with electrostatic repulsion | |
| * - Optical blend / transition edge rejection | |
| * - Voronoi pixel share allocation & perceived prominence ratio (Area^0.7 x Salience^0.3) | |
| * - Color Pizza API integration for color names & palette titles | |
| */ | |
| public class OkPalette { | |
| // ========================================================================= | |
| // Core Data Records | |
| // ========================================================================= | |
| public record Rgb(int r, int g, int b) { | |
| public String toHex() { | |
| return String.format("#%02x%02x%02x", r, g, b); | |
| } | |
| } | |
| public record Oklab(double L, double a, double b) {} | |
| public record Oklch(double L, double C, double h) {} | |
| public record PixelCoord(int x, int y) {} | |
| public record ImageStats( | |
| double avgLightness, | |
| double avgChroma, | |
| double stdLightness, | |
| double stdChroma | |
| ) {} | |
| public record AdaptiveParams( | |
| double mutedSaturatedBias, | |
| double lightDarkBias, | |
| boolean hasSparseColor | |
| ) {} | |
| public record BlendOptions( | |
| double shareThreshold, | |
| double tolerance, | |
| double totalPixels | |
| ) {} | |
| public static class Candidate { | |
| public Oklab lab; | |
| public Oklch lch; | |
| public Rgb rgb; | |
| public PixelCoord pixel; | |
| public double weight; | |
| public int pixelCount; | |
| public boolean accent; | |
| public Candidate(Oklab lab, Oklch lch, Rgb rgb, PixelCoord pixel, double weight, int pixelCount) { | |
| this.lab = lab; | |
| this.lch = lch; | |
| this.rgb = rgb; | |
| this.pixel = pixel; | |
| this.weight = weight; | |
| this.pixelCount = pixelCount; | |
| this.accent = false; | |
| } | |
| } | |
| public record ExtractedColor( | |
| Rgb rgb, | |
| Oklab oklab, | |
| Oklch oklch, | |
| PixelCoord pixel, | |
| int pixelCount, | |
| double percentage, | |
| double weight, | |
| double ratio, | |
| boolean accent, | |
| String name | |
| ) {} | |
| public record ExtractionResult( | |
| String paletteTitle, | |
| List<ExtractedColor> colors, | |
| ImageStats stats, | |
| AdaptiveParams adaptiveParams, | |
| int optimalColorCount, | |
| long elapsedMs | |
| ) {} | |
| // ========================================================================= | |
| // Color Space Transformations | |
| // ========================================================================= | |
| public static double srgbToLinear(double val255) { | |
| double t = val255 / 255.0; | |
| return t <= 0.04045 ? t / 12.92 : Math.pow((t + 0.055) / 1.055, 2.4); | |
| } | |
| public static double cbrt(double x) { | |
| return x >= 0 ? Math.pow(x, 1.0 / 3.0) : -Math.pow(-x, 1.0 / 3.0); | |
| } | |
| /** | |
| * Converts sRGB (0-255) into Oklab coordinates (L, a, b). | |
| */ | |
| public static Oklab srgbToOklab(double r, double g, double b) { | |
| double lr = srgbToLinear(r); | |
| double lg = srgbToLinear(g); | |
| double lb = srgbToLinear(b); | |
| double l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb; | |
| double m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb; | |
| double s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb; | |
| double l_ = cbrt(l); | |
| double m_ = cbrt(m); | |
| double s_ = cbrt(s); | |
| return new Oklab( | |
| 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, | |
| 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, | |
| 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ | |
| ); | |
| } | |
| public static double linearToSrgb(double n) { | |
| return n <= 0.0031308 ? n * 12.92 : 1.055 * Math.pow(n, 1.0 / 2.4) - 0.055; | |
| } | |
| /** | |
| * Converts Oklab coordinates (L, a, b) into clamped 8-bit sRGB. | |
| */ | |
| public static Rgb oklabToRgb(Oklab lab) { | |
| return oklabToRgb(lab.L, lab.a, lab.b); | |
| } | |
| public static Rgb oklabToRgb(double L, double a, double b) { | |
| double l_ = L + 0.3963377774 * a + 0.2158037573 * b; | |
| double m_ = L - 0.1055613458 * a - 0.0638541728 * b; | |
| double s_ = L - 0.0894841775 * a - 1.2914855480 * b; | |
| double l = l_ * l_ * l_; | |
| double m = m_ * m_ * m_; | |
| double s = s_ * s_ * s_; | |
| double rLin = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; | |
| double gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; | |
| double bLin = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; | |
| int r = (int) Math.round(Math.clamp(linearToSrgb(rLin) * 255.0, 0.0, 255.0)); | |
| int g = (int) Math.round(Math.clamp(linearToSrgb(gLin) * 255.0, 0.0, 255.0)); | |
| int bOut = (int) Math.round(Math.clamp(linearToSrgb(bLin) * 255.0, 0.0, 255.0)); | |
| return new Rgb(r, g, bOut); | |
| } | |
| /** | |
| * Converts Oklab into polar OKLCH coordinates (L, C, h in radians). | |
| */ | |
| public static Oklch oklabToOklch(double L, double a, double b) { | |
| double c = Math.sqrt(a * a + b * b); | |
| double h = Math.atan2(b, a); | |
| if (h < 0) h += 2.0 * Math.PI; | |
| return new Oklch(L, c, h); | |
| } | |
| public static double oklabDist(Oklab t, Oklab o) { | |
| double dL = t.L - o.L; | |
| double da = t.a - o.a; | |
| double db = t.b - o.b; | |
| return Math.sqrt(dL * dL + da * da + db * db); | |
| } | |
| public static double distinctnessDistance(Oklab t, Oklab o) { | |
| double dL = t.L - o.L; | |
| double da = (t.a - o.a) * 2.0; | |
| double db = (t.b - o.b) * 2.0; | |
| return Math.sqrt(dL * dL + da * da + db * db); | |
| } | |
| public static boolean sameOnScreen(Candidate t, Candidate o) { | |
| return Math.abs(t.rgb.r - o.rgb.r) < 14 && | |
| Math.abs(t.rgb.g - o.rgb.g) < 14 && | |
| Math.abs(t.rgb.b - o.rgb.b) < 14; | |
| } | |
| public static double neutralDistance(Candidate t, Candidate o) { | |
| double dL = t.lab.L - o.lab.L; | |
| double dC = t.lch.C - o.lch.C; | |
| return Math.sqrt(Math.pow(dL * 1.2, 2.0) + Math.pow(dC * 3.0, 2.0)); | |
| } | |
| public static double circularDistance(double t, double o) { | |
| double n = Math.abs(t - o) % (Math.PI * 2.0); | |
| return n > Math.PI ? (Math.PI * 2.0 - n) : n; | |
| } | |
| public static double applyBias(double t, double o) { | |
| if (Math.abs(o) < 0.01) return 1.0; | |
| double n = t; | |
| double a = o; | |
| if (a < 0) { | |
| n = 1.0 - n; | |
| a = -a; | |
| } | |
| double s = a * 3.14159; | |
| return Math.exp(s * (n - 0.5)); | |
| } | |
| // ========================================================================= | |
| // Algorithm Constants (from OKpalette engine) | |
| // ========================================================================= | |
| public static final int LIGHTNESS_SECTORS = 16; | |
| public static final int CHROMA_SECTORS = 16; | |
| public static final int HUE_SECTORS = 48; | |
| public static final int TOTAL_SECTORS = LIGHTNESS_SECTORS * CHROMA_SECTORS * HUE_SECTORS; // 12,288 | |
| public static final double CHROMA_CEILING = 0.4; | |
| public static final double COLOR_DISTANCE = 0.08; | |
| public static final double REPULSION_STRENGTH = 0.05; | |
| public static final double ACCENT_LOW_CHROMA = 0.03; | |
| public static final double ACCENT_CHROMA_CAP = 0.12; | |
| public static final double ACCENT_WEIGHT_SCALE = 0.25; | |
| public static final int MAX_ACCENT_SLOTS = 2; | |
| public static final double MIN_ACCENT_SCORE = 0.1; | |
| public static final int MAX_COLORS = 8; | |
| public static final int TRIM_THRESHOLD = 6; | |
| // ========================================================================= | |
| // Histogram & Image Analysis | |
| // ========================================================================= | |
| public static class Histogram { | |
| public int[] counts = new int[TOTAL_SECTORS]; | |
| public int[] occupied; | |
| public int[] pixelX = new int[TOTAL_SECTORS]; | |
| public int[] pixelY = new int[TOTAL_SECTORS]; | |
| public byte[] hasPixel = new byte[TOTAL_SECTORS]; | |
| public double[] labSumL = new double[TOTAL_SECTORS]; | |
| public double[] labSumA = new double[TOTAL_SECTORS]; | |
| public double[] labSumB = new double[TOTAL_SECTORS]; | |
| public double maxChroma = 0.0; | |
| public double minChroma = Double.POSITIVE_INFINITY; | |
| } | |
| public static ImageStats analyzeImage(byte[] rgba, int width, int height) { | |
| double sumL = 0.0, sumC = 0.0; | |
| int count = 0; | |
| int step = Math.max(1, (int) Math.floor(Math.sqrt((width * height) / 10000.0))); | |
| int numX = (int) Math.ceil((double) width / step); | |
| int numY = (int) Math.ceil((double) height / step); | |
| double[] sampleL = new double[numX * numY]; | |
| double[] sampleC = new double[numX * numY]; | |
| for (int y = 0; y < height; y += step) { | |
| for (int x = 0; x < width; x += step) { | |
| int idx = (y * width + x) * 4; | |
| if ((rgba[idx + 3] & 0xff) < 128) continue; | |
| int r = rgba[idx] & 0xff; | |
| int g = rgba[idx + 1] & 0xff; | |
| int b = rgba[idx + 2] & 0xff; | |
| Oklab lab = srgbToOklab(r, g, b); | |
| double c = Math.sqrt(lab.a * lab.a + lab.b * lab.b); | |
| sampleL[count] = lab.L; | |
| sampleC[count] = c; | |
| sumL += lab.L; | |
| sumC += c; | |
| count++; | |
| } | |
| } | |
| if (count == 0) return new ImageStats(0.5, 0.0, 0.0, 0.0); | |
| double avgL = sumL / count; | |
| double avgC = sumC / count; | |
| double varL = 0.0, varC = 0.0; | |
| for (int i = 0; i < count; i++) { | |
| varL += Math.pow(sampleL[i] - avgL, 2.0); | |
| varC += Math.pow(sampleC[i] - avgC, 2.0); | |
| } | |
| return new ImageStats(avgL, avgC, Math.sqrt(varL / count), Math.sqrt(varC / count)); | |
| } | |
| public static AdaptiveParams computeAdaptiveParams(ImageStats stats) { | |
| double o = Math.min(1.0, stats.avgChroma / 0.15); | |
| double n = stats.stdChroma / (stats.avgChroma + 0.001); | |
| boolean hasSparseColor = o < 0.35 && n > 0.7 && stats.stdChroma > 0.02; | |
| double s; | |
| if (o > 0.7) { | |
| s = 0.3 + o * 0.3; | |
| } else if (o > 0.3) { | |
| s = o * 0.5; | |
| } else if (n > 1.0) { | |
| s = Math.min(0.6, 0.2 + n * 0.05); | |
| } else if (n > 0.5) { | |
| s = (o - 0.1) * 1.5; | |
| } else { | |
| s = (o - 0.15) * 2.0; | |
| } | |
| double l = Math.min(1.0, stats.stdChroma / 0.08); | |
| if (o > 0.3) s *= (1.0 - l * 0.3); | |
| double c = (stats.avgLightness - 0.5) * 2.0 * 0.6; | |
| double e = Math.min(1.0, stats.stdLightness / 0.25); | |
| c *= (1.0 - e * 0.5); | |
| if (hasSparseColor) s = Math.max(s, 0.5); | |
| return new AdaptiveParams( | |
| Math.clamp(s, -0.8, 0.9), | |
| Math.clamp(c, -0.8, 0.8), | |
| hasSparseColor | |
| ); | |
| } | |
| public static Histogram buildHistogram(byte[] rgba, int width, int height) { | |
| Histogram hist = new Histogram(); | |
| int step = Math.max(1, (int) Math.floor(Math.sqrt((width * height) / 250000.0))); | |
| Random rng = new Random(42); | |
| for (int y = 0; y < height; y += step) { | |
| for (int x = 0; x < width; x += step) { | |
| int idx = (y * width + x) * 4; | |
| if ((rgba[idx + 3] & 0xff) < 128) continue; | |
| int r = rgba[idx] & 0xff; | |
| int g = rgba[idx + 1] & 0xff; | |
| int b = rgba[idx + 2] & 0xff; | |
| Oklab lab = srgbToOklab(r, g, b); | |
| double cVal = Math.sqrt(lab.a * lab.a + lab.b * lab.b); | |
| double hVal = Math.atan2(lab.b, lab.a); | |
| if (hVal < 0) hVal += 2.0 * Math.PI; | |
| if (cVal > hist.maxChroma) hist.maxChroma = cVal; | |
| if (cVal > 0 && cVal < hist.minChroma) hist.minChroma = cVal; | |
| int iL = Math.min(LIGHTNESS_SECTORS - 1, (int) Math.floor(lab.L * LIGHTNESS_SECTORS)); | |
| int iC = Math.min(CHROMA_SECTORS - 1, (int) Math.floor((cVal / CHROMA_CEILING) * CHROMA_SECTORS)); | |
| int iH = cVal < 0.01 ? 0 : Math.min(HUE_SECTORS - 1, (int) Math.floor((hVal / (2.0 * Math.PI)) * HUE_SECTORS)); | |
| int bin = (iL * CHROMA_SECTORS + iC) * HUE_SECTORS + iH; | |
| hist.counts[bin]++; | |
| if (hist.hasPixel[bin] == 0 || rng.nextDouble() < 1.0 / hist.counts[bin]) { | |
| hist.pixelX[bin] = x; | |
| hist.pixelY[bin] = y; | |
| hist.hasPixel[bin] = 1; | |
| } | |
| hist.labSumL[bin] += lab.L; | |
| hist.labSumA[bin] += lab.a; | |
| hist.labSumB[bin] += lab.b; | |
| } | |
| } | |
| if (!Double.isFinite(hist.minChroma)) hist.minChroma = 0.0; | |
| int numOccupied = 0; | |
| for (int c : hist.counts) if (c != 0) numOccupied++; | |
| hist.occupied = new int[numOccupied]; | |
| for (int i = 0, p = 0; i < hist.counts.length; i++) { | |
| if (hist.counts[i] != 0) hist.occupied[p++] = i; | |
| } | |
| return hist; | |
| } | |
| public static double computeBinWeight(double N, double U, double mutedSaturatedBias, double lightDarkBias, | |
| double maxChroma, double minChroma) { | |
| double l = maxChroma > 0 ? Math.min(maxChroma, CHROMA_CEILING) : CHROMA_CEILING; | |
| double c = Math.max(0.0, Math.min(l, minChroma)); | |
| double h = l > c ? l - c : 0.0; | |
| double u; | |
| if (h > 1e-4) { | |
| u = Math.min(1.0, Math.max(0.0, (U - c) / h)); | |
| } else if (l > 0) { | |
| u = Math.min(1.0, U / l); | |
| } else { | |
| u = 0.0; | |
| } | |
| double C = applyBias(u, mutedSaturatedBias); | |
| double S = applyBias(N, lightDarkBias); | |
| double x = Math.max(0.0, mutedSaturatedBias); | |
| double m = x > 0 ? Math.pow(Math.max(0.0, 1.0 - u / 0.35), 2.0) : 0.0; | |
| double A = 1.0 - x * 0.35 * m; | |
| double p = Math.pow(C, 1.5) * Math.pow(S, 1.5) * Math.max(0.25, A); | |
| double f = Math.max(N * 1.5, U * 8.0); | |
| double g; | |
| if (f <= 0.0) { | |
| g = 0.0; | |
| } else if (f >= 1.0) { | |
| g = 1.0; | |
| } else { | |
| g = 0.5 * (1.0 + (2.0 * f - 1.0) * (1.5 - 0.5 * Math.pow(2.0 * f - 1.0, 2.0))); | |
| } | |
| return Math.max(1e-4, p * g); | |
| } | |
| public record WeightedHistogram(float[] weights, int volume) {} | |
| public static WeightedHistogram weightHistogram(Histogram hist, double mutedSaturatedBias, double lightDarkBias) { | |
| float[] weights = new float[TOTAL_SECTORS]; | |
| double totalCounts = 0.0; | |
| for (int occ : hist.occupied) totalCounts += hist.counts[occ]; | |
| double S = Math.max(0.04, Math.min(0.12, hist.maxChroma * 0.6)); | |
| double chromaCount = 0.0; | |
| if (totalCounts > 0) { | |
| for (int occ : hist.occupied) { | |
| int chromaSec = (occ / HUE_SECTORS) % CHROMA_SECTORS; | |
| double centerC = ((chromaSec + 0.5) / CHROMA_SECTORS) * CHROMA_CEILING; | |
| if (centerC >= S) chromaCount += hist.counts[occ]; | |
| } | |
| } | |
| double m = totalCounts > 0 ? chromaCount / totalCounts : 0.0; | |
| double A = Math.max(0.0, 1.0 - m); | |
| double p = Math.max(0.0, mutedSaturatedBias); | |
| double f = p > 0 ? Math.min(3.0, 1.0 + p * A * 2.0) : 1.0; | |
| int lcSize = LIGHTNESS_SECTORS * CHROMA_SECTORS; | |
| double[] g = new double[lcSize]; | |
| byte[] b = new byte[lcSize]; | |
| double maxWeight = 0.0; | |
| for (int occ : hist.occupied) { | |
| int y = occ / HUE_SECTORS; | |
| if (b[y] == 0) { | |
| int N = y / CHROMA_SECTORS; | |
| int v = y - N * CHROMA_SECTORS; | |
| double T = (N + 0.5) / LIGHTNESS_SECTORS; | |
| double E = ((v + 0.5) / CHROMA_SECTORS) * CHROMA_CEILING; | |
| double O = computeBinWeight(T, E, mutedSaturatedBias, lightDarkBias, hist.maxChroma, hist.minChroma); | |
| g[y] = E >= S ? O * f : O; | |
| b[y] = 1; | |
| } | |
| weights[occ] = (float) (hist.counts[occ] * g[y]); | |
| if (weights[occ] > maxWeight) maxWeight = weights[occ]; | |
| } | |
| double iConst = 0.01; | |
| double w = maxWeight > iConst ? Math.log(maxWeight / iConst) : 0.0; | |
| int volume = 0; | |
| if (w > 0) { | |
| double d = 1.0 / w; | |
| for (int occ : hist.occupied) { | |
| if (weights[occ] == 0) continue; | |
| double N = weights[occ] > iConst ? Math.log(weights[occ] / iConst) * d : 0.0; | |
| if (N > 0) volume++; | |
| weights[occ] = (float) N; | |
| } | |
| } | |
| return new WeightedHistogram(weights, volume); | |
| } | |
| public static List<Candidate> buildCandidates(float[] weights, Histogram hist) { | |
| List<Candidate> candidates = new ArrayList<>(); | |
| for (int occ : hist.occupied) { | |
| float w = weights[occ]; | |
| if (w == 0) continue; | |
| int count = hist.counts[occ]; | |
| Oklab lab = new Oklab(hist.labSumL[occ] / count, hist.labSumA[occ] / count, hist.labSumB[occ] / count); | |
| Oklch lch = oklabToOklch(lab.L, lab.a, lab.b); | |
| Rgb rgb = oklabToRgb(lab); | |
| PixelCoord pixel = hist.hasPixel[occ] != 0 ? new PixelCoord(hist.pixelX[occ], hist.pixelY[occ]) : null; | |
| candidates.add(new Candidate(lab, lch, rgb, pixel, w, count)); | |
| } | |
| return candidates; | |
| } | |
| // ========================================================================= | |
| // Mean Shift Clustering in Oklab Space | |
| // ========================================================================= | |
| public record MeanShiftResult(List<Oklab> centers, int[] assignments) {} | |
| public static MeanShiftResult meanShiftLab(List<Candidate> pts, double bandwidth) { | |
| int n = pts.size(); | |
| if (n == 0) return new MeanShiftResult(Collections.emptyList(), new int[0]); | |
| Oklab[] sPts = new Oklab[n]; | |
| double[] weights = new double[n]; | |
| double[] cL = new double[n]; | |
| double[] hA = new double[n]; | |
| double[] uB = new double[n]; | |
| for (int i = 0; i < n; i++) { | |
| Candidate c = pts.get(i); | |
| sPts[i] = new Oklab(c.lab.L, c.lab.a, c.lab.b); | |
| weights[i] = c.weight * (0.6 + c.lch.C); | |
| cL[i] = c.lab.L; | |
| hA[i] = c.lab.a; | |
| uB[i] = c.lab.b; | |
| } | |
| final int C = 2048; | |
| final long S = 4096; | |
| Map<Long, List<Integer>> grid = new HashMap<>(); | |
| for (int i = 0; i < n; i++) { | |
| long d = (long) Math.floor(cL[i] / bandwidth); | |
| long M = (long) Math.floor(hA[i] / bandwidth); | |
| long y = (long) Math.floor(uB[i] / bandwidth); | |
| long key = ((d + C) * S + (M + C)) * S + (y + C); | |
| grid.computeIfAbsent(key, k -> new ArrayList<>()).add(i); | |
| } | |
| Map<Long, int[]> neighborCache = new HashMap<>(); | |
| byte[] converged = new byte[n]; | |
| double uSq = bandwidth * bandwidth; | |
| int maxIter = 300; | |
| double tol = 0.001; | |
| for (int iter = 0; iter < maxIter; iter++) { | |
| double maxShift = 0.0; | |
| Oklab[] nextPts = new Oklab[n]; | |
| for (int d = 0; d < n; d++) { | |
| Oklab pt = sPts[d]; | |
| if (converged[d] != 0) { | |
| nextPts[d] = pt; | |
| continue; | |
| } | |
| long dCell = (long) Math.floor(pt.L / bandwidth); | |
| long mCell = (long) Math.floor(pt.a / bandwidth); | |
| long yCell = (long) Math.floor(pt.b / bandwidth); | |
| long cellKey = ((dCell + C) * S + (mCell + C)) * S + (yCell + C); | |
| int[] neighbors = neighborCache.get(cellKey); | |
| if (neighbors == null) { | |
| List<Integer> list = new ArrayList<>(); | |
| for (int od = -1; od <= 1; od++) { | |
| for (int om = -1; om <= 1; om++) { | |
| for (int oy = -1; oy <= 1; oy++) { | |
| long nKey = (((dCell + od) + C) * S + ((mCell + om) + C)) * S + ((yCell + oy) + C); | |
| List<Integer> binList = grid.get(nKey); | |
| if (binList != null) list.addAll(binList); | |
| } | |
| } | |
| } | |
| list.sort(Integer::compareTo); | |
| neighbors = list.stream().mapToInt(Integer::intValue).toArray(); | |
| neighborCache.put(cellKey, neighbors); | |
| } | |
| double sumL = 0.0, sumA = 0.0, sumB = 0.0, totalW = 0.0; | |
| for (int k : neighbors) { | |
| double diffL = pt.L - cL[k]; | |
| double diffA = pt.a - hA[k]; | |
| double diffB = pt.b - uB[k]; | |
| if (diffL * diffL + diffA * diffA + diffB * diffB > uSq) continue; | |
| double w = weights[k]; | |
| sumL += w * cL[k]; | |
| sumA += w * hA[k]; | |
| sumB += w * uB[k]; | |
| totalW += w; | |
| } | |
| if (totalW > 0) { | |
| Oklab newPt = new Oklab(sumL / totalW, sumA / totalW, sumB / totalW); | |
| double diffL = newPt.L - pt.L; | |
| double diffA = newPt.a - pt.a; | |
| double diffB = newPt.b - pt.b; | |
| double shift = Math.sqrt(diffL * diffL + diffA * diffA + diffB * diffB); | |
| if (shift == 0) converged[d] = 1; | |
| nextPts[d] = newPt; | |
| if (shift > maxShift) maxShift = shift; | |
| } else { | |
| converged[d] = 1; | |
| nextPts[d] = pt; | |
| } | |
| } | |
| sPts = nextPts; | |
| if (maxShift < tol) break; | |
| } | |
| double mergeDist = bandwidth / 2.0; | |
| List<Oklab> centers = new ArrayList<>(); | |
| int[] assignments = new int[n]; | |
| for (int i = 0; i < n; i++) { | |
| int bestCenter = -1; | |
| double minDist = Double.POSITIVE_INFINITY; | |
| for (int ci = 0; ci < centers.size(); ci++) { | |
| double dist = oklabDist(sPts[i], centers.get(ci)); | |
| if (dist < minDist) { | |
| minDist = dist; | |
| bestCenter = ci; | |
| } | |
| } | |
| if (bestCenter != -1 && minDist < mergeDist) { | |
| assignments[i] = bestCenter; | |
| } else { | |
| assignments[i] = centers.size(); | |
| centers.add(sPts[i]); | |
| } | |
| } | |
| return new MeanShiftResult(centers, assignments); | |
| } | |
| public static void boostClusterWeights(List<Candidate> candidates, double mutedSaturatedBias, double maxChroma) { | |
| double a = Math.max(0.0, mutedSaturatedBias); | |
| if (a < 0.05 || candidates.isEmpty()) return; | |
| double s = maxChroma; | |
| for (Candidate c : candidates) { | |
| if (c.lch.C > s) s = c.lch.C; | |
| } | |
| if (s <= 0) return; | |
| double l = Math.max(0.05, s * 0.35); | |
| List<Candidate> u = new ArrayList<>(); | |
| for (Candidate c : candidates) { | |
| if (c.lch.C >= l && c.weight > 0) u.add(c); | |
| } | |
| if (u.size() < 3) return; | |
| MeanShiftResult ms = meanShiftLab(u, 0.05); | |
| if (ms.assignments == null || ms.assignments.length != u.size()) return; | |
| int numClusters = ms.centers.size(); | |
| double[] totalWeight = new double[numClusters]; | |
| double[] chromaSum = new double[numClusters]; | |
| for (int i = 0; i < u.size(); i++) { | |
| int p = ms.assignments[i]; | |
| Candidate cand = u.get(i); | |
| double w = cand.weight * (0.6 + cand.lch.C); | |
| totalWeight[p] += w; | |
| chromaSum[p] += cand.lch.C * w; | |
| } | |
| double maxAvgChroma = 0.0; | |
| for (int p = 0; p < numClusters; p++) { | |
| double avgC = totalWeight[p] > 0 ? chromaSum[p] / totalWeight[p] : 0.0; | |
| if (avgC > maxAvgChroma) maxAvgChroma = avgC; | |
| } | |
| if (maxAvgChroma <= l) return; | |
| double sumWeight = 0.0; | |
| for (double tw : totalWeight) sumWeight += tw; | |
| if (sumWeight == 0) sumWeight = 1.0; | |
| for (int i = 0; i < u.size(); i++) { | |
| int p = ms.assignments[i]; | |
| if (totalWeight[p] == 0) continue; | |
| double avgC = chromaSum[p] / totalWeight[p]; | |
| double b = totalWeight[p] / sumWeight; | |
| double B = Math.max(0.0, avgC / maxAvgChroma); | |
| double k = 1.0 + a * (0.45 + 0.55 * B) * (1.0 - b * 0.7); | |
| u.get(i).weight = Math.min(1.0, u.get(i).weight * k); | |
| } | |
| } | |
| // ========================================================================= | |
| // Color Selection & Repulsion | |
| // ========================================================================= | |
| public static int estimateOptimalColorCount(List<Candidate> candidates, double maxChroma) { | |
| if (candidates.isEmpty()) return 8; | |
| double minL = 1.0, maxL = 0.0; | |
| double minA = 0.0, maxA = 0.0; | |
| double minB = 0.0, maxB = 0.0; | |
| double sumPix = 0.0; | |
| double sumL = 0.0, sumA = 0.0, sumB = 0.0; | |
| for (Candidate c : candidates) { | |
| minL = Math.min(minL, c.lab.L); | |
| maxL = Math.max(maxL, c.lab.L); | |
| minA = Math.min(minA, c.lab.a); | |
| maxA = Math.max(maxA, c.lab.a); | |
| minB = Math.min(minB, c.lab.b); | |
| maxB = Math.max(maxB, c.lab.b); | |
| sumPix += c.pixelCount; | |
| sumL += c.lab.L * c.pixelCount; | |
| sumA += c.lab.a * c.pixelCount; | |
| sumB += c.lab.b * c.pixelCount; | |
| } | |
| double spanL = maxL - minL; | |
| double spanA = maxA - minA; | |
| double spanB = maxB - minB; | |
| double avgChroma = 0.0; | |
| for (Candidate c : candidates) avgChroma += c.lch.C; | |
| avgChroma /= candidates.size(); | |
| double b = maxChroma > 0 ? 1.0 - (avgChroma / maxChroma) * 0.5 : 0.75; | |
| double B = COLOR_DISTANCE * b; | |
| double iVol = spanL * spanA * spanB; | |
| double sphereVol = (4.0 / 3.0) * Math.PI * Math.pow(B, 3.0); | |
| int d = (int) Math.floor((iVol / sphereVol) * 0.4); | |
| return Math.clamp(d, 3, 8); | |
| } | |
| public static double accentChromaFor(List<Candidate> candidates) { | |
| double maxC = 0.0; | |
| for (Candidate c : candidates) if (c.lch.C > maxC) maxC = c.lch.C; | |
| return Math.min(ACCENT_CHROMA_CAP, Math.max(ACCENT_LOW_CHROMA * 2.0, maxC * 0.8)); | |
| } | |
| public static double chromaSalience(Candidate c, double accentChroma) { | |
| return Math.min(1.0, Math.max(0.0, (c.lch.C - ACCENT_LOW_CHROMA) / (accentChroma - ACCENT_LOW_CHROMA))); | |
| } | |
| public record AccentEval(double accent, double plainDistinct, double chromaSalience) {} | |
| public static AccentEval accentValue(Candidate cand, List<Candidate> chosen, double colorDist, | |
| double accentChroma, double hueFloor, double totalPixels) { | |
| double minDistinctDist = Double.POSITIVE_INFINITY; | |
| double minAbDist = Double.POSITIVE_INFINITY; | |
| double minHueDiff = Double.POSITIVE_INFINITY; | |
| for (Candidate m : chosen) { | |
| if (oklabDist(cand.lab, m.lab) < colorDist || sameOnScreen(cand, m)) { | |
| return null; | |
| } | |
| double distDist = distinctnessDistance(cand.lab, m.lab); | |
| if (distDist < minDistinctDist) minDistinctDist = distDist; | |
| double abDist = Math.hypot(cand.lab.a - m.lab.a, cand.lab.b - m.lab.b); | |
| if (abDist < minAbDist) minAbDist = abDist; | |
| if (m.lch.C >= hueFloor) { | |
| double diff = circularDistance(cand.lch.h, m.lch.h); | |
| if (diff < minHueDiff) minHueDiff = diff; | |
| } | |
| } | |
| double share = totalPixels > 0 ? (double) cand.pixelCount / totalPixels : 0.0; | |
| double e = Math.max(0.0, Math.min(1.0, (minAbDist - 0.02) / (0.05 - 0.02))); | |
| double r = (cand.lch.C >= hueFloor && share >= 1e-4 && cand.lab.L >= 0.2 && cand.lab.L <= 0.92 | |
| && minHueDiff != Double.POSITIVE_INFINITY && minHueDiff >= Math.PI / 2.0) ? 1.0 : 0.0; | |
| double h = Math.max(0.0, Math.min(1.0, (minDistinctDist - colorDist) / (2.0 * colorDist))); | |
| double C = Math.max(r, h); | |
| double S = chromaSalience(cand, accentChroma); | |
| double x = Math.max( | |
| S, | |
| Math.max( | |
| (cand.lab.L > 0.88 && share > 0.005) ? 0.7 : 0.0, | |
| Math.max( | |
| (cand.lab.L < 0.12 && share > 0.01) ? 0.6 : 0.0, | |
| Math.max( | |
| share > 0.001 ? 0.8 * e : 0.0, | |
| cand.weight >= 0.5 ? 0.8 * r : 0.0 | |
| ) | |
| ) | |
| ) | |
| ); | |
| return new AccentEval(ACCENT_WEIGHT_SCALE * C * x, h, S); | |
| } | |
| public static boolean isBlendOf(Candidate cand, List<Candidate> chosen, double tolerance) { | |
| double tolSq = tolerance * tolerance; | |
| int steps = 8; | |
| for (int c = 0; c < chosen.size(); c++) { | |
| Rgb e = chosen.get(c).rgb; | |
| double rLinR = srgbToLinear(e.r); | |
| double rLinG = srgbToLinear(e.g); | |
| double rLinB = srgbToLinear(e.b); | |
| for (int h = c + 1; h < chosen.size(); h++) { | |
| Rgb C = chosen.get(h).rgb; | |
| double sLinR = srgbToLinear(C.r); | |
| double sLinG = srgbToLinear(C.g); | |
| double sLinB = srgbToLinear(C.b); | |
| Oklab prevF = null; | |
| Oklab prevG = null; | |
| for (int step = 0; step <= steps; step++) { | |
| double p = (double) step / steps; | |
| Oklab f = srgbToOklab( | |
| e.r + (C.r - e.r) * p, | |
| e.g + (C.g - e.g) * p, | |
| e.b + (C.b - e.b) * p | |
| ); | |
| Oklab g = srgbToOklab( | |
| linearToSrgb(rLinR + (sLinR - rLinR) * p) * 255.0, | |
| linearToSrgb(rLinG + (sLinG - rLinG) * p) * 255.0, | |
| linearToSrgb(rLinB + (sLinB - rLinB) * p) * 255.0 | |
| ); | |
| if (prevF != null && distSegmentSq(cand.lab, prevF, f) <= tolSq) return true; | |
| if (prevG != null && distSegmentSq(cand.lab, prevG, g) <= tolSq) return true; | |
| prevF = f; | |
| prevG = g; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| private static double distSegmentSq(Oklab pt, Oklab start, Oklab end) { | |
| double h = end.L - start.L; | |
| double u = end.a - start.a; | |
| double d = end.b - start.b; | |
| double g = h * h + u * u + d * d; | |
| if (g < 1e-12) return Double.POSITIVE_INFINITY; | |
| double f = pt.L - start.L; | |
| double m = pt.a - start.a; | |
| double b = pt.b - start.b; | |
| double v = Math.clamp((f * h + m * u + b * d) / g, 0.0, 1.0); | |
| double y = f - v * h; | |
| double x = m - v * u; | |
| double p = b - v * d; | |
| return y * y + x * x + p * p; | |
| } | |
| public static List<Candidate> selectColors( | |
| List<Candidate> candidates, | |
| int targetCount, | |
| boolean autoSlots, | |
| boolean hasSparseColor, | |
| BlendOptions blendOptions, | |
| List<Candidate> preselected | |
| ) { | |
| List<Candidate> chosen = new ArrayList<>(preselected); | |
| Set<Integer> chosenIndices = new HashSet<>(); | |
| for (Candidate c : preselected) { | |
| int idx = candidates.indexOf(c); | |
| if (idx >= 0) chosenIndices.add(idx); | |
| } | |
| List<Integer> sortedIndices = new ArrayList<>(); | |
| for (int i = 0; i < candidates.size(); i++) sortedIndices.add(i); | |
| sortedIndices.sort((i1, i2) -> Double.compare(candidates.get(i2).weight, candidates.get(i1).weight)); | |
| double firstWeight = 0.0; | |
| int maxSlots = autoSlots ? Math.min(targetCount + MAX_ACCENT_SLOTS, MAX_COLORS) : targetCount; | |
| double totalPixels = 0.0; | |
| for (Candidate c : candidates) totalPixels += c.pixelCount; | |
| double accentChroma = accentChromaFor(candidates); | |
| double hueFloor = accentChroma * 0.4; | |
| for (int slot = chosen.size(); slot < maxSlots; slot++) { | |
| boolean isAccentSlot = slot >= targetCount; | |
| double bestCost = Double.POSITIVE_INFINITY; | |
| int bestIdx = -1; | |
| double bestWeight = 0.0; | |
| double bestAccentScore = 0.0; | |
| double curDist = Math.max(0.001, COLOR_DISTANCE); | |
| double minDistLimit = Math.max(0.001, curDist * 0.3); | |
| while (bestIdx < 0 && curDist >= minDistLimit) { | |
| for (int candIdx : sortedIndices) { | |
| if (chosenIndices.contains(candIdx)) continue; | |
| Candidate cand = candidates.get(candIdx); | |
| double cost = 1.0 - cand.weight; | |
| for (Candidate ch : chosen) { | |
| double d = oklabDist(cand.lab, ch.lab); | |
| if (d < curDist || sameOnScreen(cand, ch)) { | |
| cost = Double.POSITIVE_INFINITY; | |
| break; | |
| } | |
| double G = (cand.lch.C < 0.04 && ch.lch.C < 0.04) | |
| ? Math.max(1e-6, neutralDistance(cand, ch)) | |
| : d; | |
| double repulsion = Math.min(0.3, REPULSION_STRENGTH * (1.0 / G - 1.0 / curDist)); | |
| cost += repulsion; | |
| } | |
| double accentScore = 0.0; | |
| if (isAccentSlot && cost != Double.POSITIVE_INFINITY && !chosen.isEmpty()) { | |
| AccentEval eval = accentValue(cand, chosen, curDist, accentChroma, hueFloor, totalPixels); | |
| accentScore = eval != null ? eval.accent : 0.0; | |
| cost = 1.0 - accentScore - 0.25 * cand.weight; | |
| } | |
| if (cost < bestCost) { | |
| bestCost = cost; | |
| bestIdx = candIdx; | |
| bestWeight = cand.weight; | |
| bestAccentScore = accentScore; | |
| } | |
| } | |
| if (bestIdx < 0 && curDist > minDistLimit) { | |
| curDist = Math.max(minDistLimit, curDist * 0.85); | |
| bestCost = Double.POSITIVE_INFINITY; | |
| } else { | |
| break; | |
| } | |
| } | |
| if (bestIdx >= 0 && bestCost < Double.POSITIVE_INFINITY) { | |
| if (blendOptions != null && chosen.size() >= 2) { | |
| Candidate cand = candidates.get(bestIdx); | |
| double share = blendOptions.totalPixels > 0 ? (double) cand.pixelCount / blendOptions.totalPixels : 0.0; | |
| if (share < blendOptions.shareThreshold && isBlendOf(cand, chosen, blendOptions.tolerance)) { | |
| chosenIndices.add(bestIdx); | |
| slot--; | |
| continue; | |
| } | |
| } | |
| if (isAccentSlot) { | |
| if (bestAccentScore < MIN_ACCENT_SCORE) break; | |
| } else if (autoSlots && slot > 0) { | |
| Candidate cand = candidates.get(bestIdx); | |
| boolean dark = cand.lab.L < 0.3; | |
| boolean desat = cand.lch.C < 0.04; | |
| double minWeightFactor = hasSparseColor ? 0.1 : 0.25; | |
| double candidateFactor = minWeightFactor; | |
| if (!hasSparseColor) { | |
| if (dark && desat) candidateFactor = 0.6; | |
| else if (dark) candidateFactor = 0.35; | |
| } | |
| if (bestCost > 1.0 || bestWeight < firstWeight * minWeightFactor || bestWeight < 0.05) { | |
| break; | |
| } | |
| if (bestWeight < firstWeight * candidateFactor) { | |
| chosenIndices.add(bestIdx); | |
| slot--; | |
| continue; | |
| } | |
| } | |
| if (slot == 0) firstWeight = bestWeight; | |
| if (isAccentSlot) candidates.get(bestIdx).accent = true; | |
| chosen.add(candidates.get(bestIdx)); | |
| chosenIndices.add(bestIdx); | |
| } else { | |
| break; | |
| } | |
| } | |
| return chosen; | |
| } | |
| public static List<ExtractedColor> finalizeColors( | |
| List<Candidate> chosen, | |
| Histogram hist, | |
| int width, | |
| int height, | |
| double accentChroma | |
| ) { | |
| if (chosen.isEmpty()) return Collections.emptyList(); | |
| double[] assignedCounts = new double[chosen.size()]; | |
| double totalAssigned = 0.0; | |
| for (int occ : hist.occupied) { | |
| int count = hist.counts[occ]; | |
| if (count == 0) continue; | |
| double bL = hist.labSumL[occ] / count; | |
| double bA = hist.labSumA[occ] / count; | |
| double bB = hist.labSumB[occ] / count; | |
| int bestIdx = 0; | |
| double bestDistSq = Double.POSITIVE_INFINITY; | |
| for (int i = 0; i < chosen.size(); i++) { | |
| Oklab lab = chosen.get(i).lab; | |
| double dL = lab.L - bL; | |
| double da = lab.a - bA; | |
| double db = lab.b - bB; | |
| double distSq = dL * dL + da * da + db * db; | |
| if (distSq < bestDistSq) { | |
| bestDistSq = distSq; | |
| bestIdx = i; | |
| } | |
| } | |
| assignedCounts[bestIdx] += count; | |
| totalAssigned += count; | |
| } | |
| int step = Math.max(1, (int) Math.floor(Math.sqrt((width * height) / 250000.0))); | |
| int pixelFactor = step * step; | |
| double[] rawProminence = new double[chosen.size()]; | |
| double sumProminence = 0.0; | |
| for (int i = 0; i < chosen.size(); i++) { | |
| Candidate c = chosen.get(i); | |
| double share = totalAssigned > 0 ? assignedCounts[i] / totalAssigned : 0.0; | |
| double salience = 0.25 + 0.75 * chromaSalience(c, accentChroma); | |
| rawProminence[i] = Math.pow(share, 0.7) * Math.pow(salience, 0.3); | |
| sumProminence += rawProminence[i]; | |
| } | |
| List<ExtractedColor> result = new ArrayList<>(); | |
| for (int i = 0; i < chosen.size(); i++) { | |
| Candidate c = chosen.get(i); | |
| double share = totalAssigned > 0 ? (assignedCounts[i] / totalAssigned) * 100.0 : 0.0; | |
| double ratio = sumProminence > 0 ? rawProminence[i] / sumProminence : 0.0; | |
| result.add(new ExtractedColor( | |
| c.rgb, | |
| c.lab, | |
| c.lch, | |
| c.pixel, | |
| (int) Math.round(assignedCounts[i] * pixelFactor), | |
| share, | |
| c.weight, | |
| ratio, | |
| c.accent, | |
| null | |
| )); | |
| } | |
| return result; | |
| } | |
| public static ExtractionResult extract( | |
| BufferedImage img, | |
| Integer numColorsOverride, | |
| boolean autoBias, | |
| Double userMutedSaturated, | |
| Double userLightDark | |
| ) { | |
| long t0 = System.currentTimeMillis(); | |
| // 1. Clamp maximum dimension to 1200 | |
| int w = img.getWidth(); | |
| int h = img.getHeight(); | |
| if (w > 1200 || h > 1200) { | |
| double scale = 1200.0 / Math.max(w, h); | |
| int newW = (int) Math.round(w * scale); | |
| int newH = (int) Math.round(h * scale); | |
| BufferedImage scaled = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_ARGB); | |
| Graphics2D g2d = scaled.createGraphics(); | |
| g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); | |
| g2d.drawImage(img, 0, 0, newW, newH, null); | |
| g2d.dispose(); | |
| img = scaled; | |
| w = newW; | |
| h = newH; | |
| } | |
| // 2. Extract RGBA pixel data | |
| byte[] rgba = new byte[w * h * 4]; | |
| int idx = 0; | |
| for (int y = 0; y < h; y++) { | |
| for (int x = 0; x < w; x++) { | |
| int argb = img.getRGB(x, y); | |
| rgba[idx++] = (byte) ((argb >> 16) & 0xff); | |
| rgba[idx++] = (byte) ((argb >> 8) & 0xff); | |
| rgba[idx++] = (byte) (argb & 0xff); | |
| rgba[idx++] = (byte) ((argb >> 24) & 0xff); | |
| } | |
| } | |
| // 3. Statistical image analysis & adaptive biases | |
| ImageStats stats = analyzeImage(rgba, w, h); | |
| AdaptiveParams adaptive = computeAdaptiveParams(stats); | |
| double mutedSaturatedBias = (userMutedSaturated != null) ? userMutedSaturated | |
| : (autoBias ? adaptive.mutedSaturatedBias : 0.0); | |
| double lightDarkBias = (userLightDark != null) ? userLightDark | |
| : (autoBias ? adaptive.lightDarkBias : 0.0); | |
| // 4. Build 3D OKLCH cylindrical histogram & non-linear weights | |
| Histogram hist = buildHistogram(rgba, w, h); | |
| WeightedHistogram weighted = weightHistogram(hist, mutedSaturatedBias, lightDarkBias); | |
| List<Candidate> candidates = buildCandidates(weighted.weights, hist); | |
| int optimalCount = estimateOptimalColorCount(candidates, hist.maxChroma); | |
| boostClusterWeights(candidates, mutedSaturatedBias, hist.maxChroma); | |
| boolean hasExplicitColors = numColorsOverride != null; | |
| int regularSlots = hasExplicitColors ? Math.max(1, numColorsOverride) : optimalCount; | |
| double totalHistCounts = 0.0; | |
| for (int occ : hist.occupied) totalHistCounts += hist.counts[occ]; | |
| BlendOptions blendOptions = new BlendOptions(0.02, 0.035, totalHistCounts); | |
| // 5. Select colors using repulsion and accent scoring | |
| List<Candidate> chosen = selectColors(candidates, regularSlots, !hasExplicitColors, adaptive.hasSparseColor, blendOptions, Collections.emptyList()); | |
| double accentChroma = accentChromaFor(candidates); | |
| List<ExtractedColor> finalColors = finalizeColors(chosen, hist, w, h, accentChroma); | |
| // 6. Dominance trimming | |
| if (chosen.size() > TRIM_THRESHOLD) { | |
| double maxPct = 0.0; | |
| for (ExtractedColor ec : finalColors) if (ec.percentage > maxPct) maxPct = ec.percentage; | |
| if (maxPct > 75.0) { | |
| List<Candidate> trimmed = new ArrayList<>(chosen); | |
| for (int p = trimmed.size() - 1; p >= 0 && trimmed.size() > TRIM_THRESHOLD; p--) { | |
| if (!trimmed.get(p).accent) { | |
| trimmed.remove(p); | |
| } | |
| } | |
| chosen = trimmed; | |
| finalColors = finalizeColors(chosen, hist, w, h, accentChroma); | |
| } | |
| } | |
| // 7. Re-adjust if explicit count was requested | |
| if (hasExplicitColors && chosen.size() != numColorsOverride) { | |
| if (chosen.size() > numColorsOverride) { | |
| chosen = new ArrayList<>(chosen.subList(0, numColorsOverride)); | |
| } else { | |
| chosen = selectColors(candidates, numColorsOverride, false, adaptive.hasSparseColor, blendOptions, chosen); | |
| } | |
| finalColors = finalizeColors(chosen, hist, w, h, accentChroma); | |
| } | |
| long elapsed = System.currentTimeMillis() - t0; | |
| return new ExtractionResult("", finalColors, stats, adaptive, optimalCount, elapsed); | |
| } | |
| // ========================================================================= | |
| // Color Sorting (Oklab TSP nearest-neighbor path) | |
| // ========================================================================= | |
| public static List<ExtractedColor> sortColors(List<ExtractedColor> colors) { | |
| if (colors.size() <= 2) return colors; | |
| List<ExtractedColor> remaining = new ArrayList<>(colors); | |
| // Start with the darkest color | |
| remaining.sort(Comparator.comparingDouble(c -> c.oklab.L)); | |
| List<ExtractedColor> path = new ArrayList<>(); | |
| ExtractedColor current = remaining.remove(0); | |
| path.add(current); | |
| while (!remaining.isEmpty()) { | |
| int bestIdx = 0; | |
| double bestDist = Double.POSITIVE_INFINITY; | |
| for (int i = 0; i < remaining.size(); i++) { | |
| double d = oklabDist(current.oklab, remaining.get(i).oklab); | |
| if (d < bestDist) { | |
| bestDist = d; | |
| bestIdx = i; | |
| } | |
| } | |
| current = remaining.remove(bestIdx); | |
| path.add(current); | |
| } | |
| return path; | |
| } | |
| // ========================================================================= | |
| // Color Pizza API Names Query | |
| // ========================================================================= | |
| public record ColorNamingResult(String paletteTitle, Map<String, String> names) {} | |
| public static ColorNamingResult fetchColorNames(List<ExtractedColor> colors) { | |
| Map<String, String> nameMap = new HashMap<>(); | |
| String paletteTitle = ""; | |
| if (colors.isEmpty()) return new ColorNamingResult(paletteTitle, nameMap); | |
| try { | |
| String hexes = String.join(",", colors.stream().map(c -> c.rgb.toHex().replace("#", "")).toList()); | |
| String url = "https://api.color.pizza/v1/?values=" + hexes + "&list=bestOf&noduplicates=true"; | |
| HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(4)).build(); | |
| HttpRequest request = HttpRequest.newBuilder() | |
| .uri(URI.create(url)) | |
| .header("Accept", "application/json") | |
| .header("X-Referrer", "https://okpalette.color.pizza/") | |
| .timeout(Duration.ofSeconds(4)) | |
| .GET() | |
| .build(); | |
| HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); | |
| if (response.statusCode() == 200) { | |
| String body = response.body(); | |
| Matcher titleMatcher = Pattern.compile("\"paletteTitle\"\\s*:\\s*\"([^\"]+)\"").matcher(body); | |
| if (titleMatcher.find()) paletteTitle = titleMatcher.group(1); | |
| Matcher m = Pattern.compile("\\{\"name\"\\s*:\\s*\"([^\"]+)\".*?\"requestedHex\"\\s*:\\s*\"#?([a-fA-F0-9]+)\"", Pattern.DOTALL).matcher(body); | |
| while (m.find()) { | |
| nameMap.put(m.group(2).toLowerCase(), m.group(1)); | |
| } | |
| } | |
| } catch (Exception e) { | |
| // Gracefully ignore network timeout or failure | |
| } | |
| return new ColorNamingResult(paletteTitle, nameMap); | |
| } | |
| // ========================================================================= | |
| // Main CLI Application | |
| // ========================================================================= | |
| public static void main(String[] args) { | |
| if (args.length == 0 || args[0].equals("-h") || args[0].equals("--help")) { | |
| printHelp(); | |
| return; | |
| } | |
| String imagePath = null; | |
| Integer numColors = null; | |
| boolean autoBias = true; | |
| Double userMutedSaturated = null; | |
| Double userLightDark = null; | |
| boolean jsonOutput = false; | |
| boolean sort = false; | |
| boolean fetchNames = true; | |
| boolean verbose = false; | |
| for (int i = 0; i < args.length; i++) { | |
| String arg = args[i]; | |
| switch (arg) { | |
| case "-c", "--colors" -> { | |
| if (i + 1 < args.length) numColors = Integer.parseInt(args[++i]); | |
| } | |
| case "--no-auto-bias" -> autoBias = false; | |
| case "--muted-saturated" -> { | |
| if (i + 1 < args.length) userMutedSaturated = Double.parseDouble(args[++i]); | |
| } | |
| case "--light-dark" -> { | |
| if (i + 1 < args.length) userLightDark = Double.parseDouble(args[++i]); | |
| } | |
| case "--json" -> jsonOutput = true; | |
| case "--sort" -> sort = true; | |
| case "--no-names" -> fetchNames = false; | |
| case "-v", "--verbose" -> verbose = true; | |
| default -> { | |
| if (!arg.startsWith("-") && imagePath == null) { | |
| imagePath = arg; | |
| } | |
| } | |
| } | |
| } | |
| if (imagePath == null) { | |
| System.err.println("Error: No image file specified."); | |
| printHelp(); | |
| System.exit(1); | |
| } | |
| File imgFile = new File(imagePath); | |
| if (!imgFile.exists() || !imgFile.isFile()) { | |
| System.err.println("Error: File not found: " + imagePath); | |
| System.exit(1); | |
| } | |
| try { | |
| BufferedImage img = ImageIO.read(imgFile); | |
| if (img == null) { | |
| System.err.println("Error: Could not read image format: " + imagePath); | |
| System.exit(1); | |
| } | |
| ExtractionResult result = extract(img, numColors, autoBias, userMutedSaturated, userLightDark); | |
| List<ExtractedColor> colors = result.colors(); | |
| if (sort) colors = sortColors(colors); | |
| ColorNamingResult naming = fetchNames ? fetchColorNames(colors) : new ColorNamingResult("", Collections.emptyMap()); | |
| if (jsonOutput) { | |
| printJson(naming.paletteTitle(), colors, naming.names(), result); | |
| } else { | |
| printTerminalPalette( | |
| imgFile.getName(), | |
| img.getWidth(), | |
| img.getHeight(), | |
| naming.paletteTitle(), | |
| colors, | |
| naming.names(), | |
| result, | |
| verbose | |
| ); | |
| } | |
| } catch (Exception e) { | |
| System.err.println("Extraction failed: " + e.getMessage()); | |
| e.printStackTrace(); | |
| System.exit(1); | |
| } | |
| } | |
| private static void printHelp() { | |
| System.out.println(""" | |
| OKpalette CLI (Java / JBang) | |
| Extracts beautiful, perceptually balanced color palettes using Oklab & OKLCH. | |
| Usage: | |
| jbang OkPalette.java <image-file> [options] | |
| Options: | |
| -c, --colors <N> Target number of colors (default: auto, 3 to 8) | |
| --no-auto-bias Disable automatic saturation and lightness bias detection | |
| --muted-saturated <val> Override saturation bias (-1.0 to 1.0) | |
| --light-dark <val> Override lightness bias (-1.0 to 1.0) | |
| --sort Sort palette along perceptual Oklab color path | |
| --no-names Skip querying Color Pizza API for color names | |
| --json Output raw JSON | |
| -v, --verbose Print detailed image statistics & bias analysis | |
| -h, --help Show this help message | |
| """); | |
| } | |
| private static void printTerminalPalette( | |
| String fileName, | |
| int width, | |
| int height, | |
| String title, | |
| List<ExtractedColor> colors, | |
| Map<String, String> names, | |
| ExtractionResult result, | |
| boolean verbose | |
| ) { | |
| String titleDisplay = (title != null && !title.isEmpty()) ? " β \"" + title + "\"" : ""; | |
| System.out.printf("\nπ¨ \u001B[1mOKpalette Extraction\u001B[0m%s\n", titleDisplay); | |
| System.out.printf(" File: %s (%dx%d) β’ Extraction time: %dms\n", fileName, width, height, result.elapsedMs()); | |
| if (verbose) { | |
| ImageStats s = result.stats(); | |
| AdaptiveParams a = result.adaptiveParams(); | |
| System.out.println("β".repeat(84)); | |
| System.out.printf(" Stats: Avg Lightness: %.1f%%, Avg Chroma: %.3f, Sparse Colors: %s\n", | |
| s.avgLightness * 100.0, s.avgChroma, a.hasSparseColor ? "Yes" : "No"); | |
| System.out.printf(" Biases: Saturation: %+.2f, Lightness: %+.2f β’ Auto-Count: %d\n", | |
| a.mutedSaturatedBias, a.lightDarkBias, result.optimalColorCount); | |
| } | |
| System.out.println("β".repeat(84)); | |
| // Horizontal proportional color swatch bar | |
| StringBuilder swatchBar = new StringBuilder(); | |
| for (ExtractedColor c : colors) { | |
| int blockWidth = Math.max(3, (int) Math.round(c.ratio * 44.0)); | |
| swatchBar.append(String.format("\u001B[48;2;%d;%d;%dm%s\u001B[0m", c.rgb.r, c.rgb.g, c.rgb.b, " ".repeat(blockWidth))); | |
| } | |
| System.out.println("Palette Bar: " + swatchBar + "\n"); | |
| System.out.printf("%-4s %-8s %-9s %-24s %-8s %-7s %s\n", | |
| "#", "Swatch", "HEX", "Oklab (L, a, b)", "Share", "Ratio", "Name"); | |
| System.out.println("β".repeat(84)); | |
| int idx = 1; | |
| for (ExtractedColor c : colors) { | |
| String hex = c.rgb.toHex(); | |
| String swatch = String.format("\u001B[48;2;%d;%d;%dm \u001B[0m", c.rgb.r, c.rgb.g, c.rgb.b); | |
| String oklabStr = String.format("%.2f, %+.2f, %+.2f", c.oklab.L, c.oklab.a, c.oklab.b); | |
| String cleanHex = hex.replace("#", "").toLowerCase(); | |
| String name = names.getOrDefault(cleanHex, ""); | |
| String accentBadge = c.accent ? " \u001B[33m[accent]\u001B[0m" : ""; | |
| System.out.printf("%-4d %s %-9s (%-21s) %5.1f%% %5.3f \u001B[1m%s\u001B[0m%s\n", | |
| idx++, swatch, hex, oklabStr, c.percentage, c.ratio, name, accentBadge); | |
| } | |
| System.out.println("β".repeat(84) + "\n"); | |
| } | |
| private static void printJson(String title, List<ExtractedColor> colors, Map<String, String> names, ExtractionResult result) { | |
| StringBuilder sb = new StringBuilder(); | |
| sb.append("{\n"); | |
| sb.append(String.format(" \"title\": \"%s\",\n", title != null ? title : "")); | |
| sb.append(String.format(" \"extractionTimeMs\": %d,\n", result.elapsedMs())); | |
| sb.append(" \"colors\": [\n"); | |
| for (int i = 0; i < colors.size(); i++) { | |
| ExtractedColor c = colors.get(i); | |
| String hex = c.rgb.toHex(); | |
| String name = names.getOrDefault(hex.replace("#", "").toLowerCase(), ""); | |
| sb.append(String.format(""" | |
| { | |
| "hex": "%s", | |
| "name": "%s", | |
| "rgb": {"r": %d, "g": %d, "b": %d}, | |
| "oklab": {"L": %.4f, "a": %.4f, "b": %.4f}, | |
| "oklch": {"L": %.4f, "C": %.4f, "h": %.4f}, | |
| "sharePercentage": %.2f, | |
| "prominenceRatio": %.4f, | |
| "isAccent": %b | |
| }%s | |
| """, | |
| hex, name, c.rgb.r, c.rgb.g, c.rgb.b, | |
| c.oklab.L, c.oklab.a, c.oklab.b, | |
| c.oklch.L, c.oklch.C, c.oklch.h, | |
| c.percentage, c.ratio, c.accent, | |
| (i < colors.size() - 1 ? "," : "") | |
| )); | |
| } | |
| sb.append(" ]\n"); | |
| sb.append("}\n"); | |
| System.out.print(sb); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment