Skip to content

Instantly share code, notes, and snippets.

@glaforge
Created October 4, 2026 15:16
Show Gist options
  • Select an option

  • Save glaforge/5e8fe1f52905b3bc65655a131ec61d4d to your computer and use it in GitHub Desktop.

Select an option

Save glaforge/5e8fe1f52905b3bc65655a131ec61d4d to your computer and use it in GitHub Desktop.
OkPalette.java
///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