Color Whisperer: Build a Java App That Sees the Soul of Your Images
Combine K-Means clustering, a local LLM, and Quarkus to transform image uploads into beautiful, named color palettes.
Every color has a story. But hex codes like #2a4d69
or #ffcb05
rarely tell it well. What if your Java app could look at an image, extract its most prominent colors, and ask a local AI to name them creatively? That’s exactly what we’re building today.
In this tutorial, you’ll create a fully local, AI-enhanced Quarkus application that analyzes uploaded images using K-Means clustering and then uses a large language model (LLM) to give each dominant color a poetic name, like Midnight Blush or Cyberpunk Teal. No cloud APIs, no extra subscriptions. Just Java, pixels, and creativity.
Let’s build Color Palette Pro.
The Mission: Extract Dominant Colors from an Image
Every image is a sea of pixels, each a tiny RGB value. Our goal is to run a clustering algorithm on those pixels to identify the most common, representative colors, the dominant hues, that make the image tick.
The hero of this story? K-Means Clustering.
Here’s the plot:
We take the image’s pixels and represent them as RGB vectors in 3D space.
We randomly pick
K
of them to act as cluster centroids.Each pixel is assigned to its nearest centroid.
We recompute the average color of each cluster.
Repeat until the centroids stop changing (or we hit an iteration limit).
Return those centroids as the palette.
Setting the Scene: Quarkus Setup
Make sure you have:
JDK 11+
Maven 3.8.1+
An IDE (IntelliJ IDEA or VS Code with Quarkus plugin)
Podman for Dev Services or Ollama installed locally for the llm.
And if you don’t want to go through the few steps yourself, take a look at my Github repository and grab the working example.
Now run:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=color-palette-extractor \
-DclassName="org.acme.ColorPaletteResource" \
-Dpath="/colors" \
-Dextensions="quarkus-rest-jackson, quarkus-langchain4j-ollama"
cd color-palette-extractor
We’re using Quarkus REST for image uploads and Jackson for serializing JSON responses. And Quarkus Langchain4j integration for working with the LLM.
Configure your LLM in src/main/resources/application.properties
:
quarkus.langchain4j.ollama.chat-model.model-id=llama3.2:latest
quarkus.langchain4j.ollama.timeout=60s
Extracting Colors with K-Means
Create ColorExtractorService.java
. This class does the clustering math. It samples the image’s pixels, picks random starting points, and iteratively groups pixels into clusters to find the dominant hues. It does not need to know anything about LLMs.
package org.acme;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import javax.imageio.ImageIO;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ColorExtractorService {
public List<String> extractColors(InputStream inputStream, int numColors) throws Exception {
BufferedImage image = ImageIO.read(inputStream);
int[][] pixels = getPixelArray(image);
List<int[]> centroids = initializeCentroids(pixels, numColors);
List<List<int[]>> clusters;
// Iterate a few times for the K-Means algorithm to converge
for (int i = 0; i < 10; i++) {
clusters = assignToClusters(pixels, centroids);
centroids = updateCentroids(clusters);
}
List<String> hexColors = new ArrayList<>();
for (int[] centroid : centroids) {
hexColors.add(String.format("#%02x%02x%02x", centroid[0], centroid[1], centroid[2]));
}
return hexColors;
}
private int[][] getPixelArray(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
// For performance, we can sample the pixels instead of using all of them
int sampleRate = Math.max(1, (width * height) / 10000); // Sample ~10,000 pixels
List<int[]> pixelList = new ArrayList<>();
int count = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (count % sampleRate == 0) {
int rgb = image.getRGB(x, y);
int[] pixel = new int[3];
pixel[0] = (rgb >> 16) & 0xFF; // Red
pixel[1] = (rgb >> 8) & 0xFF; // Green
pixel[2] = rgb & 0xFF; // Blue
pixelList.add(pixel);
}
count++;
}
}
return pixelList.toArray(new int[0][]);
}
private List<int[]> initializeCentroids(int[][] pixels, int numColors) {
List<int[]> centroids = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < numColors; i++) {
centroids.add(pixels[random.nextInt(pixels.length)]);
}
return centroids;
}
private List<List<int[]>> assignToClusters(int[][] pixels, List<int[]> centroids) {
List<List<int[]>> clusters = new ArrayList<>();
for (int i = 0; i < centroids.size(); i++) {
clusters.add(new ArrayList<>());
}
for (int[] pixel : pixels) {
double minDistance = Double.MAX_VALUE;
int closestCentroidIndex = 0;
for (int i = 0; i < centroids.size(); i++) {
double distance = getDistance(pixel, centroids.get(i));
if (distance < minDistance) {
minDistance = distance;
closestCentroidIndex = i;
}
}
clusters.get(closestCentroidIndex).add(pixel);
}
return clusters;
}
private List<int[]> updateCentroids(List<List<int[]>> clusters) {
List<int[]> newCentroids = new ArrayList<>();
for (List<int[]> cluster : clusters) {
if (cluster.isEmpty()) {
// if a cluster is empty, re-initialize its centroid randomly
newCentroids.add(
new int[] { new Random().nextInt(256), new Random().nextInt(256), new Random().nextInt(256) });
continue;
}
long[] sum = new long[3];
for (int[] pixel : cluster) {
sum[0] += pixel[0];
sum[1] += pixel[1];
sum[2] += pixel[2];
}
newCentroids.add(new int[] { (int) (sum[0] / cluster.size()), (int) (sum[1] / cluster.size()),
(int) (sum[2] / cluster.size()) });
}
return newCentroids;
}
private double getDistance(int[] p1, int[] p2) {
return Math.sqrt(Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2) + Math.pow(p1[2] - p2[2], 2));
}
}
The output is a list of hex color codes, like #6a1b9a
, each representing a centroid of clustered pixels.
Giving Colors a Name (AI Style)
Define your AI service in just a few lines:
package org.acme;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface ColorNamer {
@SystemMessage("""
You are a creative assistant.
Your job is to provide a short,
evocative name for a given color.
Use two to three words at most.
""")
@UserMessage("""
What is a creative name for a color
with the hex code {{hex}}?
Give me just the name, nothing else.
""")
String nameColor(String hex);
}
Quarkus + LangChain4j turn this interface into an auto-wired, AI-powered service behind the scenes.
The Orchestration API
Now, let's update the REST resource. It will act as an orchestrator, first calling the ColorExtractorService
and then looping through the results to call our ColorNamer
AI service.
Replace the content of src/main/java/org/acme/ColorPaletteResource.java
with this:
package org.acme;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.List;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/colors")
public class ColorPaletteResource {
@Inject
ColorExtractorService colorExtractorService;
@Inject
ColorNamer colorNamer;
public static Logger LOG = Logger.getLogger(ColorPaletteResource.class);
@POST
@Path("/extract")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
public Response extractColorPalette(FileUploadInput file) throws IOException {
// 0. Check if we received a file
if (file == null) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"error\":\"Error: No file uploaded.\"}").build();
}
try {
// 1. Read image bytes from the uploaded file
if (file.file == null || file.file.isEmpty()) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"error\":\"Error: No file uploaded.\"}").build();
}
InputStream inputStream = Files.newInputStream(file.file.get(0).uploadedFile());
// 2. Extract the hex color codes from the image
List<String> hexCodes = colorExtractorService.extractColors(inputStream, 6); // Extract 6 colors
// 3. For each hex code, call the AI service to get a name
List<NamedColor> namedPalette = hexCodes.parallelStream() // Use parallelStream for efficiency
.map(hex -> {
String name = colorNamer.nameColor(hex).replace("\"", ""); // Clean up quotes from LLM output
return new NamedColor(hex, name);
})
.collect(Collectors.toList());
// 4. Return the combined data as JSON
return Response.ok(namedPalette).build();
} catch (Exception e) {
LOG.error("Error processing the image", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"error\":\"Could not process the image.\"}").build();
}
}
public static class FileUploadInput {
@FormParam("text")
public String text;
@FormParam("file")
public List<FileUpload> file;
}
public static class NamedColor {
public String hex;
public String name;
public NamedColor(String hex, String name) {
this.hex = hex;
this.name = name;
}
}
}
The REST endpoint:
Accepts a multipart file upload.
Extracts hex colors using the K-Means service.
Calls the LLM for each color (in parallel).
Returns a JSON array of
{ hex, name }
.
The Frontend That Makes It Shine
Add index.html
to src/main/resources/META-INF/resources/
.
Grab the styles.css from my Github repository and put it into the same directory.
It features:
A friendly UI for image upload.
Live preview of your selected image.
A loading spinner.
Interactive color cards with copy-to-clipboard support.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Color Palette Pro</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>AI Color Palette Pro</h1>
<p>Upload an image to extract a beautiful color palette with AI-generated names.</p>
<label for="imageInput" class="upload-label">Choose an Image</label>
<input type="file" id="imageInput" accept="image/*">
<p id="fileName"></p>
<img id="preview" src="" alt="Your image preview will appear here." style="display:none;"/>
<div id="loader"></div>
<div id="palette"></div>
</div>
<script>
const imageInput = document.getElementById('imageInput');
const preview = document.getElementById('preview');
const paletteDiv = document.getElementById('palette');
const loader = document.getElementById('loader');
const fileNameSpan = document.getElementById('fileName');
imageInput.addEventListener('change', async function(event) {
const file = event.target.files[0];
if (!file) return;
fileNameSpan.textContent = file.name;
const reader = new FileReader();
reader.onload = function(e) {
preview.src = e.target.result;
preview.style.display = 'block';
}
reader.readAsDataURL(file);
await extractColors(file);
});
async function extractColors(file) {
const formData = new FormData();
formData.append('file', file);
paletteDiv.innerHTML = '';
loader.style.display = 'block';
try {
const response = await fetch('/api/colors/extract', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Server error: ' + response.statusText);
}
const namedColors = await response.json();
displayPalette(namedColors);
} catch (error) {
paletteDiv.innerHTML = `<p style="color: red;">Error: Could not extract colors. Please try again.</p>`;
console.error(error);
} finally {
loader.style.display = 'none';
}
}
function displayPalette(namedColors) {
namedColors.forEach(({ hex, name }) => {
const card = document.createElement('div');
card.className = 'color-card';
const colorBox = document.createElement('div');
colorBox.className = 'color-box';
colorBox.style.backgroundColor = hex;
colorBox.title = `Copy ${hex}`;
colorBox.onclick = () => navigator.clipboard.writeText(hex);
const colorInfo = document.createElement('div');
colorInfo.className = 'color-info';
colorInfo.innerHTML = `${name}<br><span class="color-hex">${hex}</span>`;
card.appendChild(colorBox);
card.appendChild(colorInfo);
paletteDiv.appendChild(card);
});
}
</script>
</body>
</html>
No frameworks, no builds: just native browser APIs and vanilla JavaScript.
Run It Locally
quarkus dev
The first time will take a while as it downloads the model. Patience is an engineering virtue.
Go to http://localhost:8080
and upload an image. You’ll see your dominant color palette appear, each swatch labeled with a matching phantasy name:
Why This Matters
This isn’t just a toy. It’s a blueprint for building creative, AI-powered Java applications without relying on external services. You get:
Full control over model behavior.
Local privacy and performance.
Seamless Java-native development with Quarkus.
Want to extend it? You could:
Add a download button to export the palette as a JSON or CSS file.
Store named palettes in a database.
Expose the LLM color naming as a standalone service.
Closing Thought
Java isn’t just for enterprise middleware and boring APIs. With Quarkus and local AI tooling, it becomes a canvas. And now, with this project, your app can literally paint with colors and imagination.
Welcome to the post-hex-code era.