Build an AI Image Palette Generator
Build and deploy a FastAPI app that extracts dominant image colors with K-Means.
Introduction
30 Second Summary
Choosing colors from a photograph by eye can turn into guesswork. A useful palette needs to capture the full balance of the image.
In this project, you will build a web app that turns an uploaded image into a reusable color palette. The journey takes you from a muddy one-color baseline to a public app with up to five useful swatches.
What You'll Build
You upload a colorful PNG or JPEG to reveal its preview beside up to five dominant swatches labelled with HEX, RGB, and estimated share.
By the end of this project, you'll have:
- A visual upload experience that previews an image beside its generated palette.
- A clear comparison between an average-color baseline and a K-Means palette, powered by a FastAPI upload endpoint you can test through /docs.
- A public Render app with a shareable URL that lets anyone generate a palette from their browser.
- Secret Mission: Let the user choose between two and ten colors before generating the palette.
Do I need anything before starting?
You need a Windows computer with Git plus existing GitHub and Render accounts. The guide starts with a Python version check.
It also covers Visual Studio Code setup.
Before We Start
Start by locking in what you are about to create and who it can help. This commitment gives the hands-on work ahead a clear purpose.
Set Up the Windows Project
Your image palette generator depends on NumPy 2.5.4. This release requires Python 3.12 or newer.
An isolated virtual environment keeps the project's pinned packages separate from your other Python work. This gives every later step a predictable foundation.
In this step, get ready to:
- Confirm the required Windows tools.
- Create an isolated virtual environment.
- Install the pinned project packages.
Check Python and VS Code
The PowerShell terminal lets you check the tools that Windows can run. Start by confirming which Python version the Windows launcher finds.
- Press the Windows key to open Windows search.
- Type PowerShell and press Enter to open it.
- Check the installed Python 3 version by running:
py -3 --version
What does this check?
The Windows Python launcher locates your installed Python 3 interpreter. The version in the output determines which tab applies to you.
✔️ I see Python 3.12 or newer
Your Python version supports every pinned package in this project. Python is ready for the virtual environment.
ⓧ I see an older version
Your installed version cannot run the pinned NumPy release. The Windows installer opens a setup wizard before adding the newer interpreter.
- Open the official Python releases for Windows page.
- Download a Windows release for Python 3.12 or newer.
- Run the downloaded Windows installer.
- Complete the installer prompts.
- Close PowerShell after the installation completes.
- Reopen PowerShell through Windows search.
- Repeat the Python version check by running:
py -3 --version
What should change?
The output should now report Python 3.12 or newer. The Windows launcher selects the newer installed Python 3 interpreter.
Still seeing the older version?
Close every PowerShell window before checking again. A terminal that stayed open during installation may still be using its previous environment.
Confirm that the newer installation completed successfully. Help me find why the Python launcher still selects my older version
ⓧ The command is unavailable
Windows cannot currently find the Python launcher. Installing Python adds the interpreter you need for this project.
- Open the official Python releases for Windows page.
- Download a Windows release for Python 3.12 or newer.
- Run the downloaded Windows installer.
- Complete the installer prompts.
- Close PowerShell after the installation completes.
- Reopen PowerShell through Windows search.
- Check the installed version by running:
py -3 --version
What should change?
The output should report Python 3.12 or newer. This confirms that Windows can now find the Python launcher.
Still unable to run the check?
Restart Windows if the installer finished but a new PowerShell window still cannot find Python. This refreshes the commands available to your user account.
Help me troubleshoot the missing Python launcher
Visual Studio Code gives you one place to edit the project files and use the integrated project view. Its command-line shortcut should open the folder represented by your current PowerShell location.
- Test the VS Code command by running:
code .
What does this command do?
The . represents the current folder. A working VS Code command opens that folder in the editor.
✔️ VS Code opens
Good. The editor window confirms that Windows can run VS Code from PowerShell.
- Close the current VS Code window.
- Return to PowerShell.
ⓧ The command is unavailable
The recommended User setup installs VS Code without requiring administrator permissions. Restarting PowerShell makes the new command available.
- Open the official Visual Studio Code setup guide for Windows.
- Download the recommended User setup.
- Run the downloaded installer.
- Complete the installer prompts.
- Close PowerShell after the installation completes.
- Reopen PowerShell through Windows search.
- Test the VS Code command again by running:
code .
What should change?
VS Code should open the folder represented by your current PowerShell location. This confirms that the installer added the editor command successfully.
- Close the current VS Code window.
- Return to PowerShell.
Still unable to open VS Code?
Confirm that you restarted PowerShell after installing VS Code. The terminal needs a fresh session before it can find newly added commands.
Help me fix the missing VS Code command
Create and activate the project environment
The project folder gives every file a predictable home. The .venv folder stores an isolated copy of the Python environment for this app.
- Move PowerShell to your Desktop by running:
cd ~/Desktop
What does this command do?
This changes the current PowerShell location to your Desktop. The project folder you create next stays easy to find.
- Create the image-palette-generator folder and enter it by running:
mkdir image-palette-generator
cd image-palette-generator
What do these commands do?
- The first line creates the project folder on your Desktop.
- The second line changes PowerShell's current location to that folder.
Your PowerShell prompt now ends with image-palette-generator. This confirms that later files and commands stay inside the project folder.
VS Code may show a workspace trust prompt when it opens this new folder. This folder contains only the files you create during the project.
- Open the current project folder in VS Code by running:
code .
Why open the folder this way?
VS Code inherits the exact folder already selected in PowerShell. This keeps the editor and terminal pointed at the same project location.
- Confirm that you trust the folder if VS Code asks.
You'll see image-palette-generator at the top of the Explorer sidebar. The folder is ready for its first project file.
- Return to the PowerShell window from earlier.
- Create the virtual environment by running:
python -m venv .venv
What does this command do?
Python's venv module creates an isolated environment inside .venv. Packages installed while it is active stay tied to this project.
You'll see the .venv folder appear in the VS Code Explorer. That folder is the visible proof that the environment exists.
- Activate the virtual environment by running:
.\.venv\Scripts\Activate.ps1
What does activation change?
Activation points python and installed package commands at the environment inside .venv. Your other Python projects remain separate.
You'll see (.venv) at the start of the PowerShell prompt. Your isolated environment is now active.
Did PowerShell block activation?
PowerShell can block local activation scripts through its execution policy. Run this documented current-user fallback once:
# Allow local activation scripts for your Windows user
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
This changes the policy for your Windows user account. Other users on the computer keep their existing policy.
- Confirm the policy change if PowerShell asks.
- Run the activation command from above again.
Help me activate my Windows virtual environment
Install the pinned dependencies
The requirements.txt file records the exact package versions used by this project. Pinning them prevents an unexpected package update from changing the app while you build it.
- Select the New File control in the VS Code Explorer.
- Enter requirements.txt as the file name.
You'll see an empty requirements.txt file open in the editor. The file also appears in the Explorer sidebar.
- Add the pinned dependency list by copying the code below:
fastapi==0.143.3
uvicorn==0.54.0
python-multipart==0.0.32
Pillow==13.0.0
numpy==2.5.4
scikit-learn==1.9.1
What do these packages provide?
- FastAPI defines the web application and upload endpoint.
- Uvicorn runs the FastAPI application as a local web server.
- python-multipart lets FastAPI receive uploaded files.
- Pillow opens the uploaded image and converts its color mode.
- NumPy turns the image into rows of RGB pixel values.
- scikit-learn provides the clustering model used later in the project.
- Press Ctrl+S to save requirements.txt.
- Confirm that all six pinned packages remain visible in the saved file.
✔️ Awesome, I've got everything!
Your saved requirements.txt file contains the complete dependency list for this project.
ⓧ I'd like to double check the full code
fastapi==0.143.3
uvicorn==0.54.0
python-multipart==0.0.32
Pillow==13.0.0
numpy==2.5.4
scikit-learn==1.9.1
How should I compare this file?
Compare each package name and version with your saved file. Every character must match before installation.
Installing all six packages can take a few minutes while Python downloads the required files. A stream of installation messages means the process is still working.
- Return to the activated PowerShell window.
- Install the project dependencies by running:
python -m pip install -r requirements.txt
What does this command do?
Python runs pip inside the active virtual environment. Pip reads requirements.txt and installs each pinned package into .venv.
The installation finishes at a fresh PowerShell prompt without an installation error. The (.venv) prefix remains visible.
Did the installation fail?
Confirm that the PowerShell prompt begins with (.venv). An absent prefix means the packages may be targeting a different Python environment.
Check every line in requirements.txt against the full-code tab. A changed package name or version can stop installation.
Help me troubleshoot the dependency installation
Take a moment to predict whether the final check finds the Uvicorn command inside your active environment.
- Verify the Uvicorn installation by running:
uvicorn --help
What should you see?
You'll see Uvicorn usage and options. This proves that the pinned server package is installed in the active virtual environment.
That's the setup complete. Your Windows project now has the interpreter, editor, isolated environment, and dependencies needed to build the image API.
Your development environment is ready. Next up, you'll build the first upload endpoint and turn an image into one visible color result.
Build the First Color API
Your Windows workspace is ready. The palette generator now needs a FastAPI service that can receive an image.
A single arithmetic mean gives you a visible end-to-end baseline. Uvicorn exposes that result through a local server.
In this step, get ready to:
- Define an image upload route with clear file validation.
- Convert a valid image into rows of RGB pixels.
- Return one average color through the interactive API documentation.
Why FastAPI for this app?
A framework such as Flask can handle file uploads. FastAPI provides the UploadFile type for receiving the image stream.
FastAPI also generates an interactive upload form from the route definition. That keeps this step focused on turning an image into color data.
Create the upload API shell
An upload route connects an HTTP request to a Python function. The POST /analyze route accepts one file through the file parameter.
- Switch back to the activated PowerShell window from the previous step.
- Open the image-palette-generator folder in Visual Studio Code by running this command:
code .
What does this command do?
The command opens the current image-palette-generator folder in Visual Studio Code. Your activated PowerShell session stays available for the server.
- Select the image-palette-generator folder in the Visual Studio Code file sidebar.
- Select the new-file control beside the folder name.
- Type main.py as the new file name.
- Press Enter to create the file.
You will see main.py inside the image-palette-generator folder.
- Build the upload API shell by pasting this code into main.py:
import numpy as np
from fastapi import FastAPI, HTTPException, UploadFile
from PIL import Image
app = FastAPI(title="AI Image Palette Generator")
@app.post("/analyze")
async def analyze_image(file: UploadFile):
if file.content_type not in {"image/jpeg", "image/png"}:
raise HTTPException(
status_code=400,
detail="Upload a PNG or JPEG image.",
)
What does this code do?
- The numpy import becomes np for later pixel calculations.
- The Image import makes Pillow's image reader available.
- The app object creates the FastAPI service with a title for its documentation.
- The @app.post("/analyze") decorator registers an upload endpoint.
- The UploadFile parameter provides the uploaded file's metadata plus its file stream.
- The content type check rejects files outside the supported PNG and JPEG formats.
- Save main.py.
- Start the local development server from the activated PowerShell window by running:
uvicorn main:app --reload --port 5000
What does this command do?
Uvicorn imports app from main.py. The reload option watches saved code changes.
The server listens on port 5000. This PowerShell window stays busy while the server runs.
- Open http://127.0.0.1:5000/docs in your browser.
- Confirm the page lists POST /analyze.
Before you test the filter, do you think the route accepts the project's requirements.txt file?
- Expand POST /analyze in the documentation page.
- Click Try it out.
- Click Choose File.
- Select requirements.txt from the image-palette-generator folder.
- Click Execute.
You will see a 400 response containing Upload a PNG or JPEG image. This proves the route blocks unsupported uploads.
Server or route missing?
Confirm that the PowerShell prompt showed (.venv) before you started the server. Check that main.py is inside the same image-palette-generator folder as requirements.txt.
If the documentation page does not list the route, compare the decorator with @app.post("/analyze").
Help me diagnose my missing FastAPI upload route.
Convert the image into RGB pixels
Images can arrive with different sizes or color modes. Pillow standardizes the image before NumPy reshapes it into RGB pixels.
- In main.py, find the error block containing detail="Upload a PNG or JPEG image.",.
- Place your cursor below the closing parenthesis of that raise HTTPException(...) block.
- Add the image preprocessing logic by pasting this code:
try:
with Image.open(file.file) as image:
image = image.convert("RGB")
image.thumbnail((200, 200))
pixels = np.asarray(image).reshape(-1, 3)
except OSError as error:
raise HTTPException(
status_code=400,
detail="The uploaded file could not be read as an image.",
) from error
How does image preprocessing work?
- Image.open(file.file) reads the uploaded stream without saving a copy to the project folder.
- convert("RGB") gives every pixel red, green, and blue values.
- thumbnail((200, 200)) limits the image dimensions while preserving its proportions.
- reshape(-1, 3) creates one row per pixel with three color values.
- The error handler gives unreadable image data a clear response.
- Save main.py.
Switch to the running PowerShell window. You will see Uvicorn reload the application without a Python error.
Seeing a reload error?
Make sure the try: line sits inside analyze_image(). Its image-processing lines need one additional level of indentation.
Check that the except OSError as error: line aligns with try:.
Help me fix my image preprocessing block.
Return one average color
Each row in pixels holds one red value, one green value, and one blue value. Taking the mean of each column produces one representative color.
- In main.py, find the line ending with ) from error.
- Place your cursor below the completed error handler.
- Calculate the mean color by pasting this code:
red, green, blue = (int(round(value)) for value in pixels.mean(axis=0))
color = {
"hex": f"#{red:02X}{green:02X}{blue:02X}",
"rgb": [red, green, blue],
}
return {"filename": file.filename, "colors": [color]}
What does this code return?
- pixels.mean(axis=0) calculates separate averages for the red, green, and blue columns.
- round() converts each channel average to the nearest whole number.
- The hex value formats those channels as an uppercase color code.
- The rgb value preserves the three numerical channels.
- The response wraps color in a colors array so later steps can return more than one result.
- Save main.py.
Before you submit a colorful image, what single color do you expect all of its pixels to average into?
- Switch back to http://127.0.0.1:5000/docs.
- Refresh the documentation page.
- Expand POST /analyze.
- Click Try it out.
- Click Choose File.
- Select a colorful PNG or JPEG from your computer.
- Click Execute.
You will see a successful JSON response containing the uploaded filename. The colors array contains one object with hex and rgb values.
Image upload not succeeding?
Confirm that the selected file ends in .png, .jpg, or .jpeg. Try another image if the response says the file cannot be read.
If the response has no color values, check that the final return line remains inside analyze_image().
Help me debug my average-color API response.
✔️ Awesome, I've got everything!
Great. Save main.py once more so your working API matches the checkpoint.
ⓧ I'd like to double check the full code
import numpy as np
from fastapi import FastAPI, HTTPException, UploadFile
from PIL import Image
app = FastAPI(title="AI Image Palette Generator")
@app.post("/analyze")
async def analyze_image(file: UploadFile):
if file.content_type not in {"image/jpeg", "image/png"}:
raise HTTPException(
status_code=400,
detail="Upload a PNG or JPEG image.",
)
try:
with Image.open(file.file) as image:
image = image.convert("RGB")
image.thumbnail((200, 200))
pixels = np.asarray(image).reshape(-1, 3)
except OSError as error:
raise HTTPException(
status_code=400,
detail="The uploaded file could not be read as an image.",
) from error
red, green, blue = (int(round(value)) for value in pixels.mean(axis=0))
color = {
"hex": f"#{red:02X}{green:02X}{blue:02X}",
"rgb": [red, green, blue],
}
return {"filename": file.filename, "colors": [color]}
How to use this reference
Compare this file with your saved main.py. Check the indentation inside analyze_image() carefully.
That is your first working image API. It now accepts real uploads and returns usable color data.
Next up, you will put this result beside the original image so the limits of one average color become visible.
Reveal the Average-Color Problem
Your FastAPI endpoint now turns an uploaded image into one average RGB color. That result proves the upload pipeline works.
The JSON response hides what averaging removes from a colorful image. A browser interface will place the original image beside its single swatch so you can see the lost color structure.
In this step, get ready to:
- Serve a browser page from the existing FastAPI application.
- Preview an uploaded PNG or JPEG before sending it to the API.
- Display the returned average color beside the original image.
Serve the browser shell
The API needs a visible page where someone can choose an image. The first version will confirm that FastAPI can serve an HTML file from the same process as the upload endpoint.
- Return to Visual Studio Code.
- Use the file sidebar to create index.html inside the image-palette-generator folder.
- Paste this starter page into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI Image Palette Generator</title>
<style>
</style>
</head>
<body>
<main>
<h1>AI Image Palette Generator</h1>
<p>Upload a colorful PNG or JPEG to extract its dominant colors.</p>
</main>
<script>
</script>
</body>
</html>
What does this page provide?
- The metadata tells the browser how to display the page on desktop and mobile screens.
- The empty style area gives the visual design a defined home.
- The empty script area gives the browser behavior a defined home.
A FileResponse lets the existing application return index.html when someone visits the root URL. The upload endpoint remains available at /analyze.
- In main.py, replace everything from import numpy as np through the line before @app.post("/analyze") with this code:
from pathlib import Path
import numpy as np
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
app = FastAPI(title="AI Image Palette Generator")
INDEX_FILE = Path(__file__).with_name("index.html")
@app.get("/")
async def home():
return FileResponse(INDEX_FILE)
What does this code do?
- The Path import provides a reliable path to the page beside main.py.
- The INDEX_FILE value points to index.html.
- The home() route returns that file for requests to /.
- Save index.html.
- Save main.py.
- Switch back to the activated PowerShell terminal from the previous step.
- Stop the previous Uvicorn process with Ctrl+C if it is still running.
- Start the updated application by running this command:
uvicorn main:app --reload --port 5000
Before you load the root URL, consider whether the browser will still show API documentation or the new page.
- Switch back to the browser tab from the previous step.
- Enter http://127.0.0.1:5000 in the address bar.
You should see the AI Image Palette Generator heading above the upload description. Your FastAPI process now serves both the browser page and the upload API.
Page missing or server stopped?
Confirm that index.html sits beside main.py inside image-palette-generator. Check that PowerShell still shows the activated .venv environment.
Make sure the code above @app.post("/analyze") matches the snippet exactly. A missing import or quote prevents Uvicorn from loading the application.
Help me troubleshoot the FastAPI home page
✔️ Awesome, I've got everything!
Your updated main.py serves index.html while preserving the average-color endpoint.
ⓧ I'd like to double check the full code
from pathlib import Path
import numpy as np
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
app = FastAPI(title="AI Image Palette Generator")
INDEX_FILE = Path(__file__).with_name("index.html")
@app.get("/")
async def home():
return FileResponse(INDEX_FILE)
@app.post("/analyze")
async def analyze_image(file: UploadFile):
if file.content_type not in {"image/jpeg", "image/png"}:
raise HTTPException(
status_code=400,
detail="Upload a PNG or JPEG image.",
)
try:
with Image.open(file.file) as image:
image = image.convert("RGB")
image.thumbnail((200, 200))
pixels = np.asarray(image).reshape(-1, 3)
except OSError as error:
raise HTTPException(
status_code=400,
detail="The uploaded file could not be read as an image.",
) from error
red, green, blue = (int(round(value)) for value in pixels.mean(axis=0))
color = {
"hex": f"#{red:02X}{green:02X}{blue:02X}",
"rgb": [red, green, blue],
}
return {"filename": file.filename, "colors": [color]}
Build the visual upload workspace
The page needs enough visual structure to make the comparison clear. The image preview and returned swatch will occupy separate columns on wider screens.
- In index.html, place this first style group between the existing <style> tags:
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
background: #eef2ff;
color: #172033;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: 32px 16px;
background: linear-gradient(135deg, #eef2ff, #fdf2f8);
}
main {
width: min(920px, 100%);
margin: 0 auto;
padding: 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 20px 60px rgba(41, 49, 87, 0.16);
}
h1 { margin-top: 0; }
What does this style group do?
- The root styles establish the page colors and font stack.
- The body creates the full-screen gradient background.
- The main container creates the centered white card that holds the application.
- Save index.html.
- Refresh http://127.0.0.1:5000 in your browser.
You should see the heading inside a rounded white card over a pale gradient background.
Still seeing a plain page?
Check that the CSS sits between the existing <style> tags. Confirm that every opening brace has a matching closing brace.
Help me fix the page styling
- Replace the current <main> section in index.html with this upload workspace:
<main>
<h1>AI Image Palette Generator</h1>
<p>Upload a colorful PNG or JPEG to extract its dominant colors.</p>
<form id="upload-form">
<input id="image-file" type="file" accept="image/png,image/jpeg" required>
<button type="submit">Generate palette</button>
</form>
<p id="status" role="status">Choose an image to begin.</p>
<div class="workspace">
<img id="preview" alt="Selected image preview" hidden>
<section id="palette" aria-label="Extracted color palette"></section>
</div>
</main>
What does this interface contain?
- The upload-form groups the required file input with the submit button.
- The status paragraph gives the learner feedback during each stage.
- The preview image starts hidden until a file is selected.
- The palette section provides an empty destination for the returned swatch.
- Save index.html.
- Refresh the browser page.
You should see a file input above the status message. You should also see a purple Generate palette button.
Upload controls missing?
Confirm that you replaced the complete old <main> section. Keep the surrounding <body> and <script> tags in place.
Help me restore the upload interface
- Add these control and workspace styles below the existing h1 rule:
form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin: 24px 0;
}
input, button {
font: inherit;
}
button {
border: 0;
border-radius: 12px;
padding: 11px 18px;
background: #4f46e5;
color: white;
cursor: pointer;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
How does the workspace take shape?
- The form keeps its controls aligned while allowing them to wrap on smaller screens.
- The button styles turn the default control into the main action.
- The workspace reserves equal columns for the image and its palette.
- Save index.html.
- Refresh the browser page.
You should see more space around the form. The upload workspace now reserves two columns below the status message.
Controls still look unstyled?
Confirm that these rules remain inside the same <style> element as the earlier styles. Check that the button and .workspace selectors match the snippet.
Help me check the form CSS
The image preview needs to preserve its proportions inside the left column. The palette column will later hold one or more swatch cards.
- Add these preview and palette styles below the .workspace rule:
#preview {
width: 100%;
max-height: 420px;
object-fit: contain;
border-radius: 18px;
background: #f3f4f6;
}
#palette {
display: grid;
gap: 12px;
}
.swatch {
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: white;
}
.color-chip { height: 82px; }
What will these styles control?
- The preview fills its column while preserving the image proportions.
- The palette keeps consistent spacing between returned colors.
- Each swatch contains a color chip inside a bordered card.
A small piece of JavaScript can turn the selected file into a temporary browser URL. Releasing the previous URL prevents unused previews from accumulating as the learner selects new files.
- Place this preview logic between the existing <script> tags:
const form = document.querySelector("#upload-form");
const fileInput = document.querySelector("#image-file");
const preview = document.querySelector("#preview");
const palette = document.querySelector("#palette");
const status = document.querySelector("#status");
let previewUrl;
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) return;
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(file);
preview.src = previewUrl;
preview.hidden = false;
palette.replaceChildren();
status.textContent = "Preview ready. Generate the palette when you are ready.";
});
How does the preview work?
- The variables connect the script to the form controls and output areas.
- The change listener reads the first selected file.
- The object URL gives the preview element a temporary source inside the browser.
- The status message confirms that the image is ready for analysis.
- Save index.html.
- Refresh the browser page.
- Select a colorful PNG or JPEG from your computer.
You should see the selected image in the left column. The status message should say that the preview is ready.
Image preview not appearing?
Confirm that the script sits before the closing </body> tag. Check that #image-file and #preview match the element IDs in the page.
Help me debug the image preview
Reveal the one-color limitation
The remaining browser code sends the selected file to /analyze as form data. It then builds visible swatch elements from the API response without inserting returned values as HTML.
- Add these final display and mobile styles below the .color-chip rule:
.color-info {
margin: 0;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.88rem;
}
#status { min-height: 24px; }
@media (max-width: 680px) {
.workspace { grid-template-columns: 1fr; }
main { padding: 20px; }
}
What do the final styles add?
- The color information uses a monospace font so HEX and RGB values remain easy to scan.
- The status area keeps a stable height as its message changes.
- The media rule stacks the workspace into one column on narrow screens.
- Save index.html.
- Refresh the browser page.
- Resize the browser window until it is narrower than 680px.
You should see the workspace switch from two columns to one column. Widen the browser again before the final comparison.
Layout not stacking?
Confirm that the media rule remains inside the main <style> element. Check that the selector is .workspace with a leading full stop.
Help me fix the responsive layout
The submit listener is split into two short segments for easier comparison. Paste both segments into the same listener before saving the file.
- Place this first submit segment directly below the existing file-input listener:
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
status.textContent = "Analyzing image colors...";
try {
const response = await fetch("/analyze", {
method: "POST",
body: formData,
});
const result = await response.json();
How does the request start?
- The listener prevents the form from reloading the page.
- The FormData object stores the selected file under the name expected by analyze_image().
- The request posts that form data to the existing upload route.
- The response body is converted from JSON into a browser object.
- Continue directly below const result = await response.json(); with this second segment:
if (!response.ok) {
throw new Error(result.detail || "The image could not be analyzed.");
}
const swatches = result.colors.map((color) => {
const swatch = document.createElement("article");
swatch.className = "swatch";
const chip = document.createElement("div");
chip.className = "color-chip";
chip.style.backgroundColor = color.hex;
const info = document.createElement("p");
info.className = "color-info";
info.textContent = `${color.hex} | rgb(${color.rgb.join(", ")})`;
swatch.append(chip, info);
return swatch;
});
palette.replaceChildren(...swatches);
status.textContent = `Palette generated from ${result.filename}.`;
} catch (error) {
palette.replaceChildren();
status.textContent = error.message;
}
});
How does the response become a swatch?
- The response check sends API details into the existing error path.
- The map operation creates one article for every object in the colors array.
- The chip uses the returned HEX value as its background color.
- The information line displays the returned HEX and RGB values.
- Save index.html.
- Refresh http://127.0.0.1:5000.
- Select a colorful PNG or JPEG with several obvious colors.
You should see the selected image preview beside an empty palette column. The status message should confirm that the preview is ready.
Before you generate the palette, consider whether one average swatch can represent every obvious color in your image.
- Click Generate palette.
You should see one swatch beside the image preview. The swatch includes one HEX value and one RGB value.
- Compare the single swatch with the distinct colors visible in the original image.
You now have the full upload path working. The single swatch exposes exactly why the arithmetic mean falls short.
Why does the average look muddy?
The arithmetic mean combines every red value into one result. It repeats that calculation for green and blue.
Bright regions and dark regions pull those channel averages toward the middle. Separate color groups disappear into one blended swatch.
Swatch not appearing?
Keep the Uvicorn process running in PowerShell. Confirm that the browser page uses fetch("/analyze") and that the form field is named file.
If the preview works but the request fails, compare both submit-listener segments with the snippets above. A missing brace or parenthesis stops the listener from running.
Help me debug the palette request
✔️ Awesome, I've got everything!
Great work. Save index.html before comparing it with the full file.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI Image Palette Generator</title>
<style>
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
background: #eef2ff;
color: #172033;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: 32px 16px;
background: linear-gradient(135deg, #eef2ff, #fdf2f8);
}
main {
width: min(920px, 100%);
margin: 0 auto;
padding: 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 20px 60px rgba(41, 49, 87, 0.16);
}
h1 { margin-top: 0; }
form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin: 24px 0;
}
input, button {
font: inherit;
}
button {
border: 0;
border-radius: 12px;
padding: 11px 18px;
background: #4f46e5;
color: white;
cursor: pointer;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
#preview {
width: 100%;
max-height: 420px;
object-fit: contain;
border-radius: 18px;
background: #f3f4f6;
}
#palette {
display: grid;
gap: 12px;
}
.swatch {
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: white;
}
.color-chip { height: 82px; }
.color-info {
margin: 0;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.88rem;
}
#status { min-height: 24px; }
@media (max-width: 680px) {
.workspace { grid-template-columns: 1fr; }
main { padding: 20px; }
}
</style>
</head>
<body>
<main>
<h1>AI Image Palette Generator</h1>
<p>Upload a colorful PNG or JPEG to extract its dominant colors.</p>
<form id="upload-form">
<input id="image-file" type="file" accept="image/png,image/jpeg" required>
<button type="submit">Generate palette</button>
</form>
<p id="status" role="status">Choose an image to begin.</p>
<div class="workspace">
<img id="preview" alt="Selected image preview" hidden>
<section id="palette" aria-label="Extracted color palette"></section>
</div>
</main>
<script>
const form = document.querySelector("#upload-form");
const fileInput = document.querySelector("#image-file");
const preview = document.querySelector("#preview");
const palette = document.querySelector("#palette");
const status = document.querySelector("#status");
let previewUrl;
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) return;
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(file);
preview.src = previewUrl;
preview.hidden = false;
palette.replaceChildren();
status.textContent = "Preview ready. Generate the palette when you are ready.";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
status.textContent = "Analyzing image colors...";
try {
const response = await fetch("/analyze", {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "The image could not be analyzed.");
}
const swatches = result.colors.map((color) => {
const swatch = document.createElement("article");
swatch.className = "swatch";
const chip = document.createElement("div");
chip.className = "color-chip";
chip.style.backgroundColor = color.hex;
const info = document.createElement("p");
info.className = "color-info";
info.textContent = `${color.hex} | rgb(${color.rgb.join(", ")})`;
swatch.append(chip, info);
return swatch;
});
palette.replaceChildren(...swatches);
status.textContent = `Palette generated from ${result.filename}.`;
} catch (error) {
palette.replaceChildren();
status.textContent = error.message;
}
});
</script>
</body>
</html>
The browser has made the missing color structure visible. Next, you will turn those hidden color groups into a useful palette.
Extract a Real Palette with K-Means
Your browser app now proves that an uploaded image can travel from the preview to the color API. The single swatch also exposes the weakness in the current calculation.
The arithmetic mean collapses every pixel into one often muddy color. K-Means preserves the image's structure by grouping similar RGB pixels into clusters. Each cluster center becomes a representative palette color.
In this step, get ready to:
- Replace the arithmetic mean with up to five K-Means clusters.
- Order the palette by each cluster's share of the image.
- Display HEX, RGB, and percentage values for every swatch.
Replace the average with K-Means
Each row in pixels represents one RGB sample. The model groups those samples around center points called centroids.
- Switch back to main.py in Visual Studio Code.
- Replace the import section at the top of main.py with this expanded import group:
from collections import Counter
from pathlib import Path
import numpy as np
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from sklearn.cluster import KMeans
What do these imports add?
- The KMeans class groups similar pixel rows around shared centers.
- The Counter class measures how many pixels belong to each cluster.
The cluster count needs one safeguard. A simple image may contain fewer than five unique pixels, so cluster_count uses the smaller available number.
- In analyze_image() find the calculation beginning with red, green, blue.
- Replace that calculation through the existing return statement with this palette algorithm:
unique_color_count = len({tuple(pixel) for pixel in pixels})
cluster_count = min(5, unique_color_count)
model = KMeans(
n_clusters=cluster_count,
n_init="auto",
random_state=42,
)
labels = model.fit_predict(pixels)
label_counts = Counter(int(label) for label in labels)
ordered_labels = sorted(
label_counts,
key=lambda label: label_counts[label],
reverse=True,
)
colors = []
for label in ordered_labels:
center = model.cluster_centers_[label]
red, green, blue = (int(round(value)) for value in center)
colors.append(
{
"hex": f"#{red:02X}{green:02X}{blue:02X}",
"rgb": [red, green, blue],
"percentage": round(label_counts[label] / len(labels) * 100, 1),
}
)
return {"filename": file.filename, "colors": colors}
How does the palette work?
- The unique_color_count value prevents the model from requesting more clusters than the image can support.
- The fit_predict() method learns the cluster centers. It also assigns a cluster label to every pixel.
- The label_counts value measures how many pixels belong to each cluster.
- The ordered_labels value places the most common cluster first.
- The loop converts every center into uppercase HEX values. It also calculates each cluster's percentage of the image.
- Save main.py.
Before you generate the palette, how many separate swatches do you expect from the same colorful image?
- Return to the browser app at http://127.0.0.1:5000.
- Select the colorful image you tested in the previous step.
- Click Generate palette.
You should see up to five separate swatches. Each swatch shows its uppercase HEX value plus its RGB values.
Still seeing one swatch?
Confirm that main.py imports KMeans. Check that the return statement uses colors without square brackets around it.
If the server reports a Python error, compare the indentation under for label in ordered_labels: with the reference below.
Help me debug my K-Means palette code
✔️ Awesome, I've got everything!
Your saved main.py now returns an ordered multi-color palette.
ⓧ I'd like to double check the full code
from collections import Counter
from pathlib import Path
import numpy as np
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from sklearn.cluster import KMeans
app = FastAPI(title="AI Image Palette Generator")
INDEX_FILE = Path(__file__).with_name("index.html")
@app.get("/")
async def home():
return FileResponse(INDEX_FILE)
@app.post("/analyze")
async def analyze_image(file: UploadFile):
if file.content_type not in {"image/jpeg", "image/png"}:
raise HTTPException(
status_code=400,
detail="Upload a PNG or JPEG image.",
)
try:
with Image.open(file.file) as image:
image = image.convert("RGB")
image.thumbnail((200, 200))
pixels = np.asarray(image).reshape(-1, 3)
except OSError as error:
raise HTTPException(
status_code=400,
detail="The uploaded file could not be read as an image.",
) from error
unique_color_count = len({tuple(pixel) for pixel in pixels})
cluster_count = min(5, unique_color_count)
model = KMeans(
n_clusters=cluster_count,
n_init="auto",
random_state=42,
)
labels = model.fit_predict(pixels)
label_counts = Counter(int(label) for label in labels)
ordered_labels = sorted(
label_counts,
key=lambda label: label_counts[label],
reverse=True,
)
colors = []
for label in ordered_labels:
center = model.cluster_centers_[label]
red, green, blue = (int(round(value)) for value in center)
colors.append(
{
"hex": f"#{red:02X}{green:02X}{blue:02X}",
"rgb": [red, green, blue],
"percentage": round(label_counts[label] / len(labels) * 100, 1),
}
)
return {"filename": file.filename, "colors": colors}
How to use this reference
This reference shows the complete main.py file after the clustering update. The import group and palette algorithm should match your saved file.
Show percentages in each swatch
The API now returns each cluster's share through percentage. The existing browser loop can display that value beside each color without changing its structure.
- Switch back to index.html in Visual Studio Code.
- Find the status line that currently says Analyzing image colors....
- Replace that line with the first line in this reference snippet.
- Find the info.textContent line inside the swatch loop.
- Replace that line with the second line in this reference snippet:
status.textContent = "Clustering image colors...";
info.textContent = `${color.hex} | rgb(${color.rgb.join(", ")}) | ${color.percentage}%`;
What changes in the browser?
The status message now names the clustering operation. The swatch label adds the percentage returned for each cluster.
The browser still builds every label with textContent. Each returned color automatically receives the same format.
- Save index.html.
- Refresh the browser app.
- Select the same colorful image.
- Click Generate palette.
You should see a percentage at the end of every swatch label. The largest color group appears first.
Missing the percentages?
Confirm that the swatch label uses color.percentage. Check that the template literal still uses backticks.
Help me fix the missing palette percentages
✔️ Awesome, I've got everything!
Your browser now renders the complete HEX, RGB, and percentage label for every returned color.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI Image Palette Generator</title>
<style>
:root { color-scheme: light; font-family: Inter, system-ui, sans-serif; background: #eef2ff; color: #172033; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; padding: 32px 16px; background: linear-gradient(135deg, #eef2ff, #fdf2f8); }
main { width: min(920px, 100%); margin: 0 auto; padding: 28px; border-radius: 24px; background: rgba(255, 255, 255, 0.94); box-shadow: 0 20px 60px rgba(41, 49, 87, 0.16); }
h1 { margin-top: 0; }
form { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin: 24px 0; }
input, button { font: inherit; }
button { border: 0; border-radius: 12px; padding: 11px 18px; background: #4f46e5; color: white; cursor: pointer; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
#preview { width: 100%; max-height: 420px; object-fit: contain; border-radius: 18px; background: #f3f4f6; }
#palette { display: grid; gap: 12px; }
.swatch { overflow: hidden; border: 1px solid #e5e7eb; border-radius: 14px; background: white; }
.color-chip { height: 82px; }
.color-info { margin: 0; padding: 10px 12px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.88rem; }
#status { min-height: 24px; }
@media (max-width: 680px) { .workspace { grid-template-columns: 1fr; } main { padding: 20px; } }
</style>
</head>
<body>
<main>
<h1>AI Image Palette Generator</h1>
<p>Upload a colorful PNG or JPEG to extract its dominant colors.</p>
<form id="upload-form">
<input id="image-file" type="file" accept="image/png,image/jpeg" required>
<button type="submit">Generate palette</button>
</form>
<p id="status" role="status">Choose an image to begin.</p>
<div class="workspace">
<img id="preview" alt="Selected image preview" hidden>
<section id="palette" aria-label="Extracted color palette"></section>
</div>
</main>
<script>
const form = document.querySelector("#upload-form");
const fileInput = document.querySelector("#image-file");
const preview = document.querySelector("#preview");
const palette = document.querySelector("#palette");
const status = document.querySelector("#status");
let previewUrl;
fileInput.addEventListener("change", () => { const file = fileInput.files[0]; if (!file) return; if (previewUrl) URL.revokeObjectURL(previewUrl); previewUrl = URL.createObjectURL(file); preview.src = previewUrl; preview.hidden = false; palette.replaceChildren(); status.textContent = "Preview ready. Generate the palette when you are ready."; });
form.addEventListener("submit", async (event) => { event.preventDefault(); const file = fileInput.files[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); status.textContent = "Clustering image colors..."; try { const response = await fetch("/analyze", { method: "POST", body: formData }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || "The image could not be analyzed."); const swatches = result.colors.map((color) => { const swatch = document.createElement("article"); swatch.className = "swatch"; const chip = document.createElement("div"); chip.className = "color-chip"; chip.style.backgroundColor = color.hex; const info = document.createElement("p"); info.className = "color-info"; info.textContent = `${color.hex} | rgb(${color.rgb.join(", ")}) | ${color.percentage}%`; swatch.append(chip, info); return swatch; }); palette.replaceChildren(...swatches); status.textContent = `Palette generated from ${result.filename}.`; } catch (error) { palette.replaceChildren(); status.textContent = error.message; } });
</script>
</body>
</html>
How to use this reference
This reference shows the complete index.html file after the percentage update. The clustering status and swatch label should match your saved file.
Test colorful and nearly solid images
A colorful image tests whether the clusters preserve distinct regions. A nearly solid image tests whether cluster_count stays within the number of unique pixels.
Before you run the final checks, do you expect both images to produce exactly five swatches?
- Switch back to the browser app.
- Select a colorful photo with several obvious colors.
- Click Generate palette.
You should see up to five distinct swatches. Every swatch should include an uppercase HEX value, an RGB value, and a percentage.
- Select a nearly solid PNG with only a small number of visible colors.
- Click Generate palette.
You should see only the colors available in the image. The request should finish without duplicate-cluster errors.
Why can the second palette be smaller?
K-Means needs enough unique samples to support its requested cluster count. The min(5, unique_color_count) safeguard lowers that count for simple images.
The palette remains useful for colorful photos. It also stays reliable for flat graphics.
That is the average-color problem solved. Your app now turns image pixels into an ordered palette that people can read and reuse.
Your local image tool is complete. Next up, you will publish it through GitHub and Render so anyone can generate a palette from a public URL.
Ship the App to Render
Your local palette generator now turns colorful images into useful sets of dominant colors. Right now, anyone else needs your Windows project to run it.
A GitHub repository provides a shareable snapshot of the project. Render turns that snapshot into a public Python service.
In this step, get ready to:
- Commit the completed project to a local main branch.
- Push the commit to an empty GitHub repository.
- Deploy the app as a Render Free Web Service.
Commit the completed project
Git stores a snapshot of your project files in a commit. That commit becomes the version that GitHub receives.
Your existing .gitignore keeps .venv plus Python cache files out of the commit. The .python-version file pins Render to Python 3.14.8.
- Switch back to the PowerShell terminal running Uvicorn.
- Press Ctrl+C to stop the local server.
- Record the completed project as its first Git commit by running these commands:
git init -b main
git add .
git commit -m "First commit"
What do these commands do?
- The first command initializes the repository on the main branch.
- The second command stages the project files for the snapshot.
- The existing ignore rules keep the virtual environment plus cache files outside the snapshot.
- The final command saves the staged files with the message First commit.
- Review the commit summary in PowerShell.
You should see a new commit named First commit with the project files listed in its summary.
Commit did not complete?
- Check that PowerShell still shows the image-palette-generator folder as its current location.
- Follow Git's identity setup guidance if the commit stops because no author name or email is configured.
- Run the three commit commands again after resolving the message.
Help me diagnose why my first Git commit did not complete.
Push the commit to GitHub
The remote repository must start empty for this workflow. Your local commit then becomes its first history entry.
- Open GitHub in your browser.
- Choose the option to create a repository.
You should see the repository creation form.
- Enter image-palette-generator in the Repository name field.
- Leave the README option unselected.
- Leave the .gitignore option unselected.
- Leave the license option unselected.
- Create the empty repository.
You should see the Quick Setup instructions for the empty repository.
- Copy the Quick Setup URL into this project field: your GitHub Quick Setup URL.
- Replace REMOTE-URL in the command below with your GitHub Quick Setup URL before running it:
git remote add origin REMOTE-URL
What does this command do?
This command names your GitHub repository origin. Git can then send your local commits to that destination.
GitHub may open a browser window for authentication when you push. Complete that sign-in request so Git can access your account.
Before you push, do you expect the empty repository to gain the same files that are in your local commit?
- Push the main branch to GitHub by running this command:
git push -u origin main
What does this command do?
The command uploads your local main branch to GitHub. It also links the local branch to the remote branch for future pushes.
- Complete the GitHub authentication flow if it opens.
- Refresh the repository page after the push completes.
You should see main.py, index.html, requirements.txt, .gitignore plus .python-version on the main branch.
That is your first shipping milestone complete. The full palette generator now has a remote home that Render can deploy.
Push did not reach GitHub?
- Check that you replaced REMOTE-URL with the Quick Setup URL from your repository.
- Complete any browser authentication request opened by GitHub.
- Confirm that the GitHub repository was created without starter files.
Help me troubleshoot why my Git push did not reach GitHub.
Deploy the public service
A Render Web Service pulls your repository files. It installs the dependencies before starting the FastAPI process at a public URL.
Connecting the repository may trigger a GitHub permission screen. Approving access lets Render read the project plus future commits.
- Open the Render Dashboard in your browser.
- Choose the option to create a Web Service.
You should see a list of repositories that Render can access.
- Connect your GitHub account if Render requests access.
- Select the image-palette-generator repository.
You should see the Web Service configuration form for your repository.
- Set the deployment branch to main.
- Select Python 3 for Language.
- Set the Build Command by pasting this value:
pip install -r requirements.txt
What does the build command do?
Render uses this command to install every pinned package from requirements.txt. This recreates the dependencies that power your local app.
- Set the Start Command by pasting this value:
uvicorn main:app --host 0.0.0.0 --port $PORT
What does the start command do?
Uvicorn loads app from main.py. The host value 0.0.0.0 makes the service reachable outside its container.
Render supplies $PORT when it starts the service. Using that value lets Render route public requests to FastAPI.
Choosing the Free plan does not add a monthly compute charge. It provides 0.1 CPU plus 512 MB RAM for this service.
- Select Free as the compute plan.
The first deployment can take a few minutes while Render installs the packages. Keep the dashboard open while the deploy log moves through its build steps.
- Select Create Web Service to begin the deployment.
- Watch the deploy log until the status changes to Live.
The Live status confirms that Render built the repository plus started the web process.
Deployment did not become live?
- Confirm that requirements.txt appears in the GitHub repository.
- Compare the Build Command field with the first command block above.
- Compare the Start Command field with the second command block above.
Help me diagnose my failed Render deployment.
- Copy the generated onrender.com URL into this project field: https://your-service.onrender.com.
Before you open the public URL, do you expect the same colorful image to produce the same palette outside your computer?
- Open https://your-service.onrender.com in a new browser tab.
- Select a colorful PNG or JPEG from your computer.
- Click Generate palette.
You should see the image preview beside up to five color swatches. Each swatch should show a HEX value plus RGB values plus a percentage.
- Change the public URL path to /docs.
You should see the FastAPI interactive documentation with POST /analyze available outside your computer.
You shipped the complete app. Other people can now upload an image plus generate its palette from a public URL.
Secret mission
Let the User Choose the Palette Size
Different design tasks need different levels of color detail. Add a palette-size control so each user can request between two and ten colors from the deployed app.
Clean Up Your Resources
Clean Up Your Resources
This project has no ongoing cost because it uses a Render Free Web Service. Choose whether to keep your resources available, pause the public app, or delete everything.
Resources you used:
- The local image-palette-generator folder containing your project files plus the .venv virtual environment.
- The GitHub repository containing the application.
- The Render Free Web Service hosting the public palette app.
Keep everything running
No action needed. Choose this if you want the palette generator to remain available for demonstrations or future improvements.
- Keep the local image-palette-generator folder available for future development.
- Leave the GitHub repository connected as your remote copy.
- Leave the Render Free Web Service available at your public onrender.com URL.
- Expect the first request after 15 minutes without traffic to take about one minute while the Free service spins back up.
Pause - I'll come back to this later
Suspend the public service while preserving its configuration. Your local folder plus the GitHub repository remain available.
- Return to the Render Dashboard from earlier.
- Select the checkbox beside the Web Service that hosts your palette app.
- Choose Suspend.
- Close the activated PowerShell terminal to end the local session.
Your public app stops accepting requests. Your service configuration remains saved in Render.
Resume the public app later:
- Return to the Render Dashboard.
- Select the suspended Web Service.
- Choose Resume.
Render starts serving the public palette app again.
Delete - I don't want to use this again
Permanent deletion is a big step. Each resource is handled separately so you can keep any copy that still matters to you.
Delete the Render service:
- Return to the Render Dashboard from earlier.
- Select the Web Service that hosts your palette app.
- Use Render's documented service deletion capability on that service's page.
- Confirm the permanent deletion in Render.
- Refresh the public onrender.com URL from earlier.
You should no longer see the palette app at that public URL.
Delete the GitHub repository:
- Return to the GitHub repository from earlier.
- Open the repository settings page.
- Find the repository deletion controls.
- Delete the repository.
- Refresh the repository page from earlier.
You should no longer be able to access the repository.
Delete the local project:
- Close the activated PowerShell terminal.
- Use the Windows file browser to locate image-palette-generator where you created it.
- Permanently delete the entire image-palette-generator folder through the file browser.
You should no longer see the project folder or its .venv virtual environment on your computer.
Nice Work!
Nice Work!
Mission accomplished! Your FastAPI image palette generator now turns uploaded pictures into useful color swatches at a public Render URL.
You've learned how to:
- Built a FastAPI upload endpoint for PNG and JPEG files. Its JSON response contains HEX, RGB, and percentage data.
- Converted uploaded images into RGB pixel matrices with Pillow and NumPy. Used K-Means centroids plus cluster counts to extract up to five dominant colors.
- Created a browser upload experience with an image preview plus FormData requests. Published the complete app from GitHub to a public Render URL.
- Secret Mission: Added a configurable palette size that lets people request between two and ten colors from the deployed app.
Ready to quiz yourself?