Cluster Your Art with Unsupervised ML
Use a pretrained ResNet-50 to cluster your artwork by visual similarity.
Introduction
30 Second Summary
Every artist develops patterns in their work over time. You might sort your pieces by subject or medium, but there could be deeper visual connections you have never noticed.
In this project, you will build a Python script that feeds your own artwork through a pretrained neural network and extracts visual features from each piece. It then maps those features onto a 2D scatter plot clustered by visual similarity.
What You'll Build
You run a single script, point it at a folder of your art, and watch your pieces arrange themselves in 2D space, color-coded by clusters the algorithm found on its own.
By the end of this project, you'll have:
- A pipeline that converts any folder of images into numerical feature vectors using a pretrained ResNet-50, letting you compare visual similarity across your entire portfolio with zero model training.
- Two scatter plots side by side, revealing firsthand why PCA alone falls flat and t-SNE brings hidden structure to life.
- A thumbnail scatter plot where your actual artwork sits at its 2D coordinates, each piece bordered by its K-Means cluster color, ready to share as a visual portfolio piece.
- Secret Mission: Add hierarchical clustering with a dendrogram to see the full tree of visual similarity across your art.
Are there any prerequisites?
This project assumes basic Python knowledge and some familiarity with machine learning concepts. You should be comfortable using pip and running scripts from the terminal.
Before We Start
Before you write any code, take a moment to think about what you expect the algorithm to find. You know your art better than any model does.
Set Up Your Environment
You know how you would sort your art. Now it is time to give an algorithm the same challenge. Before writing any pipeline code, you need a clean Python environment with the right libraries installed.
Three packages make this project possible. PyTorch provides the pretrained neural network that extracts visual features from your images. Scikit-learn handles dimensionality reduction and clustering. Matplotlib produces the scatter plots and visualizations.
In this step, get ready to:
- Create a virtual environment and install PyTorch, scikit-learn, and matplotlib.
- Set up your project folder with artwork images and an empty Python script.
- Verify that PyTorch and torchvision import successfully.
Create a virtual environment and install packages
A virtual environment isolates your project's packages from the rest of your system. This prevents version conflicts between different Python projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open a terminal inside VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir visual-clustering
cd visual-clustering
- Confirm you are in the right folder by running:
ls
The output should be empty because the folder is brand new.
Now create a virtual environment and activate it. The commands differ by operating system.
macOS
- Create and activate a virtual environment by running these commands:
python3 -m venv venv
source venv/bin/activate
Windows
- Create and activate a virtual environment by running these commands:
python -m venv venv
venv\Scripts\activate
Your terminal prompt now starts with (venv). This confirms the virtual environment is active.
Seeing a scripts disabled error on Windows?
Windows PowerShell may block the activation script with an error about running scripts being disabled on this system. Fix this by running the following command, then try the activation command again:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
help me fix the scripts disabled error when activating my Python virtual environment on Windows
- Install PyTorch (CPU-only), torchvision, and torchaudio by running:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
You should see download progress bars followed by a Successfully installed message listing the packages.
Why the CPU-only install?
The --index-url flag tells pip to download CPU-only versions of PyTorch. These are about 150-200 MB instead of the 2+ GB CUDA-enabled builds. This project does not need a GPU because you are using a pretrained model for inference, not training one from scratch.
The first time you run the script later, PyTorch also downloads the ResNet-50 model weights (about 98 MB) to a local cache folder. Subsequent runs reuse the cached file.
Install taking too long or failing?
Make sure your virtual environment is active (you see (venv) in your prompt). If the download fails midway, run the same pip install command again. pip picks up where it left off.
help me troubleshoot my PyTorch CPU install failing
- Install scikit-learn and matplotlib by running:
pip install scikit-learn matplotlib
You should see another Successfully installed message listing scikit-learn, matplotlib, and their dependencies.
Set up your project folder and verify
With the libraries installed, you need two more things in your project folder: a subfolder of artwork images for the algorithm to analyze, and an empty Python file to hold your pipeline code.
- Create the artwork folder by running:
mkdir artwork
- Copy 20-30 of your artwork images into the artwork folder. You can drag and drop them using Finder (macOS) or File Explorer (Windows).
What images should I use?
Use your own artwork, photography, or digital creations. The algorithm accepts .jpg, .jpeg, .png, and .webp formats.
Aim for variety. Include pieces from different styles, time periods, or subjects. The more variety you include, the more interesting the clustering results will be.
- Confirm your images are in place by running:
ls artwork
You should see your image filenames listed in the output.
- In VS Code's Explorer sidebar (left panel), click the New File icon at the top of the visual-clustering folder.
- Name the file cluster_art.py and press Enter.
Before you run the verification command, what do you expect to see if all three packages installed correctly?
- Verify your entire setup by running this command in the terminal:
python -c "import torch; from torchvision.models import resnet50; print(f'PyTorch {torch.__version__} ready')"
You should see output like PyTorch 2.7.0+cpu ready with no errors. The exact version number may differ, but the key is that the import completes and the message prints.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is still active. Check that your terminal prompt shows (venv) at the start. If it does not, re-activate it using the activation command from the earlier platform tabs.
If you see (venv) but still get the error, the packages may have installed outside the virtual environment. Rerun the pip install commands from the previous substep.
help me fix ModuleNotFoundError when importing torch in my virtual environment
Your environment is locked and loaded. Next up, you will write the code that feeds your artwork through a neural network and extracts the visual features that power the clustering.
Extract Visual Embeddings from Your Art
In Step 1, you set up your Python environment with all the ML libraries you need. Now it's time to put them to work.
Images are just grids of pixels. To find patterns across your artwork, you need to convert each image into a compact numerical representation that captures visual features like shapes, textures, and colors. A pretrained ResNet-50 can do this without any training on your part.
In this step, get ready to:
- Load your artwork images into the script.
- Extract 2048-dimensional feature vectors from each image using a pretrained ResNet-50.
Write the image loading code
Your script needs access to several libraries for deep learning, image processing, clustering, and visualization. You will import everything up front so the rest of the script can use them freely.
- Open cluster_art.py in VS Code by clicking it in the Explorer sidebar.
- Add all the imports your script needs by pasting this code:
import os
import numpy as np
import torch
import torch.nn as nn
from torchvision.models import resnet50, ResNet50_Weights
from PIL import Image
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
What are all these imports for?
- torch and torchvision give you access to the pretrained ResNet-50 model and its image preprocessing pipeline.
- PIL Image loads your artwork files as image objects Python can work with.
- The sklearn and matplotlib imports are for dimensionality reduction, clustering, and visualization that you will use in later steps.
- Save cluster_art.py and confirm the imports work by running this in your terminal:
python cluster_art.py
The script exits with no output and no errors. This confirms every ML package imported successfully.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- If the error names a specific package (like torch or sklearn), re-run the install commands from Step 1 with your virtual environment active.
Help me fix my ModuleNotFoundError when running cluster_art.py
Now that the imports are confirmed, add the configuration constants and image loading code. Paste this below the imports in cluster_art.py:
- Add the configuration and image loading code below the imports by pasting this:
# ---- Configuration ----
IMAGE_FOLDER = "artwork"
N_CLUSTERS = 4
RANDOM_STATE = 42
THUMB_SIZE = 64
# ---- Load images ----
image_paths = []
images_pil = []
for filename in sorted(os.listdir(IMAGE_FOLDER)):
if filename.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
path = os.path.join(IMAGE_FOLDER, filename)
image_paths.append(path)
images_pil.append(Image.open(path).convert("RGB"))
n_samples = len(images_pil)
print(f"Loaded {n_samples} images from '{IMAGE_FOLDER}/'")
What does this code do?
- The configuration block sets constants you will reuse throughout the script. IMAGE_FOLDER points to your artwork directory, and N_CLUSTERS sets how many groups the algorithm will look for.
- The loading loop scans the artwork folder alphabetically, filters for image file extensions, and opens each image as an RGB PIL Image.
- .convert("RGB") ensures every image has exactly 3 color channels, even if the original file is RGBA or grayscale.
- Save cluster_art.py.
- Run the script in your terminal:
python cluster_art.py
You should see a line like Loaded 25 images from 'artwork/' where the number matches how many images you placed in your artwork folder.
Seeing 0 images loaded?
- Check that your artwork folder is in the same directory as cluster_art.py, not inside a subfolder.
- Make sure your image files end in .png, .jpg, .jpeg, or .webp. Other formats like .bmp or .tiff are not included in the filter.
- Confirm your terminal's working directory matches the project folder. Run ls artwork (macOS/Linux) or dir artwork (Windows) to verify the images are there.
Help me debug why my script loaded 0 images from the artwork folder
Write the embedding extraction code
Your images are loaded, but they are still just pixel grids. You need to convert each one into a compact numerical fingerprint that captures what the image actually looks like.
A pretrained CNN called ResNet-50 already knows how to recognize shapes, textures, and colors from being trained on millions of photos. By removing its final classification layer, you turn it into a feature extraction engine that outputs a 2048-dimensional vector for each image.
- Add the embedding extraction code at the bottom of cluster_art.py, below the print(f"Loaded {n_samples}...") line:
# ---- Extract embeddings with ResNet-50 ----
weights = ResNet50_Weights.DEFAULT
model = resnet50(weights=weights)
model.fc = nn.Identity()
model.eval()
preprocess = weights.transforms()
embeddings = []
with torch.no_grad():
for img in images_pil:
tensor = preprocess(img).unsqueeze(0)
embedding = model(tensor).squeeze().numpy()
embeddings.append(embedding)
embeddings = np.array(embeddings)
print(f"Extracted {embeddings.shape[0]} embeddings of shape ({embeddings.shape[1]},)")
What does this code do?
- ResNet50_Weights.DEFAULT loads the model pretrained on ImageNet, a dataset of over a million labeled photos. The model already understands visual features.
- model.fc = nn.Identity() replaces the final classification layer with a pass-through. Instead of outputting "this is a cat" class probabilities, the model now outputs raw 2048-dimensional feature vectors.
- weights.transforms() returns the exact preprocessing pipeline the model expects: resizing, center-cropping, and normalizing pixel values.
- torch.no_grad() tells PyTorch to skip gradient tracking. You are only extracting features, not training, so this saves memory and speeds things up.
What about the model download?
The first time you run this code, PyTorch automatically downloads the ResNet-50 weights (~98 MB) to a local cache folder. This is a one-time download. Subsequent runs load the weights from cache instantly.
- Save cluster_art.py.
Before you run the script, think about what you expect to see. How many embeddings should it extract, and what shape should each one be?
- Run the full script in your terminal:
python cluster_art.py
You should see two lines of output. The first confirms how many images loaded. The second confirms the embedding extraction:
Loaded 25 images from 'artwork/'
Extracted 25 embeddings of shape (2048,)
Your numbers will match how many images you placed in the artwork folder. Each image is now a 2048-number fingerprint that captures its visual character.
Script hangs or takes a long time?
- The first run downloads the ResNet-50 weights (~98 MB). On a slow connection this can take a minute or two. You should see a download progress bar in the terminal.
- If you see an error about image files being corrupt or unreadable, one of your image files may be damaged. Try removing it from the artwork folder and running again.
Help me fix an error when extracting embeddings from my artwork images
✔️ Awesome, I've got everything!
Great. Double check that you have saved cluster_art.py and both print lines appear when you run the script.
ⓧ I'd like to double check the full code
Your cluster_art.py should contain three sections in this order. Scroll up and confirm each one is present:
- Imports (12 import lines at the top, starting with import os and ending with from matplotlib.offsetbox import OffsetImage, AnnotationBbox).
- Configuration + Load images (four constants under # ---- Configuration ----, then the for loop that scans the artwork folder, ending with the first print statement).
- Extract embeddings (model setup under # ---- Extract embeddings with ResNet-50 ----, then the torch.no_grad() loop, ending with the second print statement).
Every piece of your artwork now has a 2048-dimensional "visual DNA" vector. Next, you will project these vectors down to 2D so you can actually see where your art lands on a map.
Project to 2D with PCA
In Step 2, you turned each artwork image into a 2048-dimensional feature vector. Each vector captures the visual DNA of one piece. Now it is time to actually look at that data.
2048 dimensions are impossible to visualize directly. PCA (Principal Component Analysis) solves this by finding the two directions in your data with the most spread and projecting everything onto a flat 2D plane.
In this step, get ready to:
- Project the 2048-dim embeddings down to 2D using PCA.
- Create a scatter plot with each artwork labeled by filename.
Add PCA and plot the 2D projection
Your embeddings array holds every artwork as a row of 2048 numbers. PCA will compress each row down to just 2 numbers, which become the x and y coordinates on a scatter plot.
- In cluster_art.py, scroll to the bottom of the file (after the print line that shows embedding shape) and add this code:
# ---- PCA to 2D ----
# Project the 2048-dim embeddings down to just 2 dimensions
pca_2d = PCA(n_components=2, random_state=RANDOM_STATE)
coords_pca = pca_2d.fit_transform(embeddings)
# Create a scatter plot of the 2D projection
plt.figure(figsize=(10, 8))
plt.scatter(coords_pca[:, 0], coords_pca[:, 1], s=60, c="steelblue", edgecolors="white")
# Label each dot with its filename
for i, path in enumerate(image_paths):
name = os.path.basename(path)
plt.annotate(name, (coords_pca[i, 0], coords_pca[i, 1]), fontsize=7, alpha=0.7)
# Add a title and axis labels showing variance explained
plt.title("PCA Projection (2D) - Can you see clear groups?")
plt.xlabel(f"PC1 ({pca_2d.explained_variance_ratio_[0]:.1%} variance)")
plt.ylabel(f"PC2 ({pca_2d.explained_variance_ratio_[1]:.1%} variance)")
plt.tight_layout()
# Save the plot and display it
plt.savefig("pca_scatter.png", dpi=150)
plt.show()
print("Saved pca_scatter.png")
What does this code do?
- PCA(n_components=2) creates a PCA model that keeps only the two principal components with the highest variance. fit_transform() learns those directions from your embeddings and projects every artwork onto them in one call.
- The for loop adds a text label next to each dot so you can tell which artwork is where.
- The axis labels use explained_variance_ratio_ to show how much of the total data spread each axis captures (e.g., "PC1 (12.3% variance)"). These percentages tell you how much information PCA kept.
- Save cluster_art.py.
Before you run this, what do you think the scatter plot will look like? Will your art fall into clear, distinct groups, or will the dots blend together?
- Run the script to generate the PCA scatter plot:
python cluster_art.py
A scatter plot window opens showing your artwork as labeled dots. Close the plot window when you are done looking at it. The terminal prints "Saved pca_scatter.png" and the file appears in your project folder.
Now look closely at the scatter. Are the dots clumped into clear, separated groups, or spread out in an undifferentiated cloud?
Why does the scatter look so flat?
You probably see dots scattered without obvious group boundaries. That is not a bug in your code.
PCA picks the two directions with the most total spread. "Most spread" is not the same as "most useful for seeing clusters." PCA preserves global variance, but your art styles differ in subtle local patterns that get flattened into the same region.
This flat, hard-to-read scatter is exactly the limitation you will fix in the next step with a method designed to preserve local neighborhoods instead of global variance.
Plot window not appearing?
- If the script hangs or no plot window opens, your matplotlib backend may not support interactive display. Check that pca_scatter.png was saved to your project folder and open it manually.
- If you see an error about embeddings not being defined, make sure the PCA code is at the bottom of the file, after the embedding extraction section.
- If the plot only shows 1-2 dots, check your artwork folder. Confirm it has 20-30 images with supported extensions (.png, .jpg, .jpeg, .webp).
Still stuck? Help me debug why my PCA scatter plot is not showing up when I run cluster_art.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved cluster_art.py and that pca_scatter.png exists in your project folder.
ⓧ I'd like to double check the full code
Your cluster_art.py file is now longer than 30 lines, so here is a checklist of each section you should see, in order from top to bottom:
- Imports (os, numpy, torch, torchvision, PIL, sklearn, matplotlib).
- Configuration section with IMAGE_FOLDER, N_CLUSTERS, RANDOM_STATE, and THUMB_SIZE.
- Load images section that scans the artwork folder and prints the count.
- Extract embeddings with ResNet-50 section that loads the model, replaces model.fc with nn.Identity(), and collects embeddings.
- PCA to 2D section (the code you just added) that projects to 2D, creates a scatter plot, saves it as pca_scatter.png, and displays it.
Scroll through your file and confirm each section is present in this order.
That flat scatter is not a failure. It is a genuine limitation of PCA that you just experienced firsthand. In the next step, you will swap in a method called t-SNE that is specifically designed to preserve local neighborhoods, and pair it with K-Means clustering to finally see your art group itself into distinct visual families.
Reveal Structure with t-SNE and K-Means
You just saw PCA's best attempt at projecting your embeddings to 2D. The result was likely an undifferentiated cloud of dots with no clear grouping.
That happened because PCA preserves global variance, not local neighborhood structure. t-SNE takes the opposite approach by keeping nearby points close together. Combined with K-Means clustering, it will reveal the visual families hiding in your art.
In this step, get ready to:
- Reduce your 2048-dimensional embeddings to 2D using PCA pre-reduction followed by t-SNE.
- Cluster the reduced embeddings with K-Means and visualize the groups in a colored scatter plot.
Reduce dimensions then apply t-SNE
t-SNE works best when the input dimensionality is already reduced. Feeding it all 2048 raw features would be slow and noisy, especially with a small dataset of 20-30 images.
The first step is to use PCA to compress from 2048 dimensions down to around 50, then hand those compressed vectors to t-SNE for the final projection to 2D.
- In cluster_art.py, scroll to the very bottom of the file (after the print("Saved pca_scatter.png") line).
- Add the PCA pre-reduction and t-SNE projection code below it:
# ---- Dimensionality reduction + clustering ----
n_pca_components = min(50, n_samples - 1)
pca_reduce = PCA(n_components=n_pca_components, random_state=RANDOM_STATE)
embeddings_reduced = pca_reduce.fit_transform(embeddings)
perplexity = min(30, (n_samples - 1) // 3)
tsne = TSNE(
n_components=2,
perplexity=perplexity,
learning_rate="auto",
random_state=RANDOM_STATE,
init="pca",
)
coords_tsne = tsne.fit_transform(embeddings_reduced)
What does this code do?
- n_pca_components = min(50, n_samples - 1) caps the number of PCA components. With only 25 images, PCA can produce at most 24 components (one fewer than the sample count).
- pca_reduce compresses the 2048-dim embeddings down to those fewer dimensions, stripping out noise before t-SNE ever sees the data.
- perplexity controls how many neighbors t-SNE considers for each point. With a small dataset, perplexity must stay low (less than one-third of your sample count) or t-SNE will error out.
- init="pca" seeds t-SNE's starting positions from PCA, which gives more stable and reproducible results than a random start.
Why reduce dimensions before t-SNE?
scikit-learn's own t-SNE documentation recommends it: reduce the number of dimensions to around 50 first using PCA for dense data. This removes noise from the sparse high-dimensional space and speeds up computation.
- Save cluster_art.py.
- Run the script to confirm the new code executes without errors:
python cluster_art.py
- The PCA scatter plot from Step 3 appears first. Close the plot window.
- After a few seconds of t-SNE computation, the script finishes with no errors. You still see the same image count and embedding shape messages in the terminal.
Seeing a perplexity error?
- If you see an error about perplexity being too large, check that your artwork folder has at least 10 images. With very few images, even the dynamic perplexity calculation may not be low enough.
- Make sure the perplexity line reads min(30, (n_samples - 1) // 3) exactly. A typo in the division or parentheses will produce the wrong value.
Help me debug my t-SNE perplexity error.
Cluster with K-Means and visualize
Now that you have 2D coordinates from t-SNE, you need cluster labels to color-code each point. K-Means will assign every image to one of 4 groups based on visual similarity.
- In cluster_art.py, add the K-Means clustering and scatter plot code directly below the coords_tsne line you just wrote:
kmeans = KMeans(n_clusters=N_CLUSTERS, random_state=RANDOM_STATE, n_init=10)
labels = kmeans.fit_predict(embeddings_reduced)
cluster_colors = plt.cm.Set2(np.linspace(0, 1, N_CLUSTERS))
plt.figure(figsize=(10, 8))
for cluster_id in range(N_CLUSTERS):
mask = labels == cluster_id
plt.scatter(
coords_tsne[mask, 0],
coords_tsne[mask, 1],
c=[cluster_colors[cluster_id]],
label=f"Cluster {cluster_id + 1}",
s=80,
edgecolors="white",
linewidth=0.5,
)
plt.title("t-SNE Projection with K-Means Clusters")
plt.legend()
plt.tight_layout()
plt.savefig("tsne_clusters.png", dpi=150)
plt.show()
print("Saved tsne_clusters.png")
What does this code do?
- kmeans.fit_predict(embeddings_reduced) assigns each image to one of 4 clusters based on the PCA-reduced embeddings.
- The loop plots each cluster separately so that each group gets its own color and legend entry.
- plt.cm.Set2 picks a colormap with distinct, easy-to-distinguish colors for the cluster labels.
Why cluster on PCA-reduced embeddings instead of t-SNE output?
t-SNE distorts distances non-linearly to preserve local neighborhoods. Two points that look far apart in t-SNE space might actually be close in the original embedding space.
K-Means relies on Euclidean distance to form groups. Running it on t-SNE coordinates would produce unreliable clusters. The PCA-reduced embeddings still preserve real distance relationships, making them the right input for clustering.
Before you run the script, think back to your PCA scatter from Step 3. Do you think the t-SNE plot will show clearer, more separated groups than PCA did?
- Save cluster_art.py.
- Run the script:
python cluster_art.py
- Close the PCA scatter plot when it appears.
A second scatter plot appears with colored dots. Each color represents a K-Means cluster. The dots are now visibly grouped instead of spread in a flat cloud.
The plot saves as tsne_clusters.png. Compare this to your PCA scatter. The difference shows exactly why dimensionality reduction method matters.
Not seeing distinct groups?
- If the dots still look like a single blob, your artwork may be very visually homogeneous. Try adding images with more variety in style, color palette, or subject matter.
- Make sure N_CLUSTERS is set to 4 in your configuration section. Too many clusters can split natural groups into pieces.
- Confirm that K-Means runs on embeddings_reduced (the PCA-reduced embeddings), not on coords_tsne (the t-SNE output).
Help me understand why my t-SNE clusters are not separating clearly.
✔️ Awesome, I've got everything!
Great. Double check you have saved cluster_art.py and that tsne_clusters.png appeared in your project folder.
ⓧ I'd like to double check the full code
Your cluster_art.py is now too long for a single code block. Scroll up through your file and confirm these sections appear in order:
- Imports at the top (os, numpy, torch, PIL, sklearn, matplotlib).
- Configuration section with IMAGE_FOLDER, N_CLUSTERS, RANDOM_STATE, and THUMB_SIZE.
- Load images loop that scans the artwork folder and prints the count.
- Extract embeddings section that loads ResNet-50 and collects 2048-dim vectors.
- PCA to 2D section that projects to 2D, saves pca_scatter.png, and displays the scatter.
- Dimensionality reduction + clustering section with PCA pre-reduction, t-SNE, K-Means, and the colored scatter that saves as tsne_clusters.png.
Your art is now clustered into visual families. Next up, you will replace those colored dots with actual artwork thumbnails so you can see the clusters with your own eyes.
Build the Thumbnail Scatter Plot
In the last step, t-SNE revealed the hidden neighborhood structure in your embeddings, and K-Means labeled each piece with a cluster.
Colored dots tell you which cluster each image belongs to, but you can't actually see your art. In this step, you'll replace each dot with a thumbnail of the actual artwork, transforming an abstract chart into a visual map you can interpret and share.
In this step, get ready to:
- Place artwork thumbnails at each t-SNE coordinate using OffsetImage and AnnotationBbox.
- Polish and save the final visualization as thumbnail_scatter.png.
Place artwork thumbnails at t-SNE coordinates
Each image already has a position from t-SNE and a cluster label from K-Means. Now you need to shrink each image into a small thumbnail and draw it at its (x, y) coordinate, with a colored border showing which cluster it belongs to.
- In cluster_art.py, scroll to the very bottom of the file, after the line that says print("Saved tsne_clusters.png").
- Add the following code to create the figure and loop through each image, placing a thumbnail at its t-SNE position:
# ---- Thumbnail scatter plot ----
fig, ax = plt.subplots(figsize=(14, 10))
ax.set_title("Your Art, Clustered by Visual Similarity", fontsize=14, pad=15)
for i, (x, y) in enumerate(coords_tsne):
# Create a small copy of each artwork
img = images_pil[i].copy()
img.thumbnail((THUMB_SIZE, THUMB_SIZE))
img_array = np.array(img)
# Place the thumbnail at its t-SNE coordinate
imagebox = OffsetImage(img_array, zoom=0.8)
border_color = cluster_colors[labels[i]]
ab = AnnotationBbox(
imagebox,
(x, y),
frameon=True,
bboxprops=dict(edgecolor=border_color, linewidth=2.5),
pad=0.3,
)
ax.add_artist(ab)
What does this code do?
The loop walks through every image and its t-SNE (x, y) position at the same time.
- img.thumbnail((THUMB_SIZE, THUMB_SIZE)) resizes the image down to 64px (the THUMB_SIZE constant you set in the config section). It modifies the copy in place, so the original stays untouched.
- OffsetImage wraps the numpy array into an image that matplotlib can draw on a plot. AnnotationBbox places that image at the exact (x, y) coordinate.
- bboxprops controls the border around each thumbnail. The border color comes from cluster_colors[labels[i]], so each thumbnail is visually tagged with its K-Means cluster.
- Save cluster_art.py. The next substep sets the axis limits and saves the final plot.
Polish and save the final visualization
The thumbnails are placed, but the plot needs a few finishing touches. Without axis padding, thumbnails at the edges get clipped. The raw t-SNE coordinates on the tick marks are also meaningless to a viewer.
- Directly below the code you just added, paste the following to set axis limits, remove tick marks, and save the plot:
# Add padding so edge thumbnails aren't clipped
margin = 5
ax.set_xlim(coords_tsne[:, 0].min() - margin, coords_tsne[:, 0].max() + margin)
ax.set_ylim(coords_tsne[:, 1].min() - margin, coords_tsne[:, 1].max() + margin)
# Remove axis ticks (t-SNE coordinates have no meaning)
ax.set_xticks([])
ax.set_yticks([])
plt.tight_layout()
plt.savefig("thumbnail_scatter.png", dpi=150, bbox_inches="tight")
plt.show()
print("Saved thumbnail_scatter.png")
What does this code do?
- margin = 5 adds 5 units of padding around the outermost thumbnails. Without it, images near the edges would be cut off.
- set_xticks([]) and set_yticks([]) remove the numbers along the axes. The t-SNE coordinates are relative, so the exact values carry no meaning.
- bbox_inches="tight" trims extra whitespace around the saved image, keeping the file clean for sharing.
Before you run this, think back to Step 0. Do you think the algorithm's visual groups will match the categories you came up with, or will it find groupings you did not expect?
- Save cluster_art.py.
- Run the script by entering this command in your terminal:
python cluster_art.py
The PCA scatter and t-SNE cluster plot appear first (close each to continue). Then a third plot opens showing your actual artwork thumbnails positioned in 2D space, each bordered by its cluster color. The plot saves as thumbnail_scatter.png.
Look at the clusters. Did the algorithm group your art the way you would have? It might match your intuitive categories. It might find distinctions you never noticed, like grouping by color palette or composition rather than subject.
Not seeing thumbnails on the plot?
- Make sure you saved cluster_art.py before running it.
- Check that the thumbnail code is placed after the t-SNE cluster plot section, not inside it. It should come after print("Saved tsne_clusters.png").
- If the plot window appears blank or tiny, verify that the for loop's indentation matches the code above exactly. Every line inside the loop must be indented one level.
Still stuck? Help me debug why my thumbnail scatter plot is not rendering any images.
✔️ Awesome, I've got everything!
Great. Double check you've saved cluster_art.py and that thumbnail_scatter.png appears in your project folder alongside pca_scatter.png and tsne_clusters.png.
ⓧ I'd like to double check the full code
Your cluster_art.py file is now longer than what fits in a single block. Scroll through your file and confirm these sections appear in this order:
- Imports at the top: os, numpy, torch, PIL, sklearn, matplotlib.
- Configuration: IMAGE_FOLDER, N_CLUSTERS, RANDOM_STATE, THUMB_SIZE.
- Load images: the loop that reads files from the artwork folder.
- Extract embeddings: ResNet-50 setup, the torch.no_grad() loop, and the embeddings array.
- PCA to 2D: scatter plot saved as pca_scatter.png.
- Dimensionality reduction + clustering: PCA pre-reduction, t-SNE, K-Means, colored scatter saved as tsne_clusters.png.
- Thumbnail scatter plot: the new section you just added. The for loop placing thumbnails, followed by axis limits, tick removal, and save as thumbnail_scatter.png.
Your unsupervised visual clustering pipeline is complete. You have three output plots: a PCA projection, a t-SNE cluster map, and a thumbnail scatter plot that lets you see your art the way a neural network sees it.
Secret mission
Add a Dendrogram for Hierarchical Clustering
K-Means gives you flat clusters, but it does not show how those clusters relate to each other. Add hierarchical clustering with a dendrogram to see the full tree of visual similarity across your artwork. Find out which pairs of images the algorithm considers most similar and whether 4 clusters is really the right number.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine. There are no cloud resources or paid services, so there are no ongoing costs.
Resources you used:
- Your project folder containing cluster_art.py, the venv/ virtual environment, the artwork/ folder, and four output PNG files.
- Cached ResNet-50 model weights (~98 MB stored in PyTorch's cache directory outside your project folder).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your clustering pipeline or want to try it with new artwork.
- Your virtual environment stays in the venv/ folder, ready to reactivate any time.
- Your output plots (pca_scatter.png, tsne_clusters.png, thumbnail_scatter.png, dendrogram.png) remain in the project folder for sharing.
- The cached ResNet-50 weights stay on disk so future runs skip the download.
Pause - I'll come back to this later
Deactivate your virtual environment to free up your terminal. All files stay on disk so you can pick up where you left off.
- In your terminal, deactivate the virtual environment by running:
deactivate
- Close VS Code.
- When you come back, reopen VS Code, open a terminal in your project folder, and reactivate the environment with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).
Delete - I don't want to use this again
Remove all project files and start fresh. Make sure to back up any artwork images you want to keep before deleting.
- Copy any artwork images from the artwork/ folder to a safe location if they are your only copies.
- Copy any output plots (thumbnail_scatter.png, dendrogram.png, etc.) you want to keep to another folder.
- If your virtual environment is still active, deactivate it by running:
deactivate
- Before deleting the project folder, find your PyTorch model cache directory by running:
python -c "import torch; print(torch.hub.get_dir())"
- Note the path that prints. You will delete it after removing the project folder.
Now delete your project folder. In your terminal, navigate to the parent directory that contains your project folder (e.g. your Desktop), then run the appropriate command for your operating system.
macOS
- Delete the project folder by running:
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- Delete the cached ResNet-50 weights by deleting the directory that the earlier command printed. For example:
rm -rf [[TORCH_CACHE="the torch hub cache path printed earlier"]]
Windows
- Delete the project folder by running:
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
- Delete the cached ResNet-50 weights by deleting the directory that the earlier command printed. For example:
rmdir /s /q [[TORCH_CACHE="the torch hub cache path printed earlier"]]
- Confirm your project folder is gone by running ls (macOS) or dir (Windows) in the parent directory. Your project folder should no longer appear.
Nice Work!
Nice Work!
You just built a complete unsupervised machine learning pipeline that turns your own artwork into a visual clustering map. No labels, no training, no manual sorting. The algorithm found structure in your art all on its own.
You've learned how to:
- Extract 2048-dimensional feature vectors from your own artwork using a pretrained ResNet-50, with zero training required.
- Compare PCA and t-SNE on the same data to see firsthand why dimensionality reduction method matters, then use K-Means to discover visual clusters the algorithm found in your art.
- Build a thumbnail scatter plot that places your actual artwork at each point in 2D space, color-coded by cluster, turning abstract embeddings into a visual map you can interpret and share.
- Secret Mission: Add hierarchical clustering with a dendrogram to see the full tree of visual similarity across your portfolio.
Ready to quiz yourself?