001 Notes

ClusterLens Algorithms: PCA, KMeans, FAISS, HDBSCAN, And Graphs

ClusterLens-style image clustering is not one algorithm. It is a pipeline of representation choices, distance choices, clustering choices, review policies, and runtime constraints.

The clustering backend produces labels. A useful product still has to decide whether those labels are stable, explainable, safe to act on, and fast enough for an interactive desktop app.

This article explains that pipeline from first principles: how vectors are prepared, why PCA is conditional, how KMeans and MiniBatchKMeans work, what FAISS actually accelerates, how HDBSCAN discovers density, how graph clustering differs from centroid clustering, and why post-processing matters as much as the algorithm.

1. The Real Pipeline

A naive description says:

images -> clustering algorithm -> groups

The real system looks closer to this:

flowchart TD
  A[Image files] --> B[Decode and preprocess]
  B --> C[Embedding model]
  C --> D[Embedding cache]
  D --> E[Matrix: N images x D dimensions]
  E --> F[Normalize vectors]
  F --> G{Semantic mode?}
  G -- yes --> H[Optional PCA projection]
  G -- no --> I[Full normalized vectors]
  H --> J[Prepared matrix]
  I --> J
  J --> K{Backend choice}
  K --> L[KMeans]
  K --> M[MiniBatchKMeans]
  K --> N[FAISS KMeans]
  K --> O[HDBSCAN]
  K --> P[Similarity graph]
  L --> Q[Raw labels]
  M --> Q
  N --> Q
  O --> Q
  P --> Q
  Q --> R[Outlier policy]
  R --> S[Tiny cluster policy]
  S --> T[Centroid/member reranking]
  T --> U[Explanations and review UI]

The important detail is that the backend is only the middle of the system.

Backend output:
  row 0 -> cluster 3
  row 1 -> cluster 3
  row 2 -> cluster 8
  row 3 -> cluster -1

Product output:
  Cluster 3 looks like: beach, coast, blue sky
  Representative images: row 1, row 0, row 12
  Cohesion: high
  Nearest competing cluster: cluster 4
  Outliers: 18 images

The algorithm creates structure. The product turns structure into a reviewable object.

2. The Matrix Contract

After embedding, every image is represented by one vector:

image_0001.jpg -> [ 0.12, -0.44,  0.08, ...]
image_0002.jpg -> [ 0.10, -0.41,  0.11, ...]
image_0003.jpg -> [-0.72,  0.03,  0.55, ...]

Stacked together, these vectors form a matrix:

N = number of images
D = embedding dimension
matrix shape = N x D

For example:

20,000 images x 512 dimensions = 10,240,000 float values

At float32, the raw matrix costs about:

10,240,000 values * 4 bytes = about 40 MB

That is only the matrix. A desktop app also has image paths, thumbnails, UI objects, caches, indexes, and model runtime memory. The algorithm design has to account for this.

The matrix also needs a strict row contract:

RowMeaning
0first image path in the run
1second image path in the run
127exactly one stable image path

The clustering backend usually returns row numbers, not file paths. If the row-to-path mapping changes after clustering, the UI can show the wrong images. That bug is easy to create when scan results, cache hits, and UI filtering are mixed too early.

A safer contract is:

1. scan files
2. freeze ordered image list
3. build embeddings in that order
4. cluster matrix rows
5. map labels back to frozen image list
6. apply UI filters after labels are stable

This is not algorithmically glamorous, but it is what keeps the product correct.

3. Normalization And Cosine Geometry

Most image embedding models are used with cosine similarity. Cosine similarity compares direction:

cosine(a, b) = dot(a, b) / (length(a) * length(b))

If the vectors are L2-normalized first:

normalized_v = v / length(v)

Then every vector has length 1, and cosine similarity becomes:

cosine(a, b) = dot(a, b)

That makes many operations simpler and faster.

Before normalization:
  magnitude can dominate
  long vectors may look more important
  dot product is not the same as cosine

After normalization:
  direction dominates
  dot product matches cosine
  vector scores are easier to explain

There is another useful fact. For normalized vectors, cosine similarity and Euclidean distance are linked:

distance_squared(a, b) = 2 - 2 * cosine(a, b)

So a high cosine score means a low Euclidean distance. This is why Euclidean clustering backends can still be useful on normalized semantic vectors.

Example:

Cosine similaritySquared Euclidean distance
1.000.00
0.900.20
0.700.60
0.002.00
-1.004.00

The practical rule is simple:

Normalize before cosine-style clustering.
Record that normalization happened.
Do not mix normalized and unnormalized scores in the same explanation.

4. Similarity Modes

Not every grouping task asks the same question.

Semantic clustering:
  "Put conceptually similar images together."

Visual clustering:
  "Put visually similar images together."

Duplicate review:
  "Find images that are identical or near-identical."

These modes can use related machinery, but they should not be treated as the same product feature.

ModeBetter signalExample
semanticCLIP/OpenCLIP/SigLIP/DINO-style embeddingsdog, puppy, pet photos
visualCNN/vision embeddings, color/layout featuresscreenshots, UI captures, similar scenes
duplicateperceptual hashes, ORB, exact file hashesresized copy, edited crop, repeated export

The most common mistake is to use a duplicate detector as a semantic clustering system. A perceptual hash can catch visually close images. It does not understand that a sketch of a dog and a photo of a dog are semantically related.

The opposite mistake is also common. Semantic embeddings can say two images are related, but they should not be used alone for destructive duplicate deletion. Similar meaning is not identical content.

5. PCA From First Principles

PCA means Principal Component Analysis. It is a linear projection.

It asks:

Which directions in this dataset explain the most variation?

Imagine a cloud of points:

        .
      .   .
    .       .
  .           .
.               .

The cloud is stretched along one main direction. PCA finds that direction first. Then it finds the next strongest direction that is perpendicular to the first, and so on.

In matrix terms:

input matrix:       N x D
centered matrix:    subtract column means
components:         directions of high variance
projected matrix:   N x K

Where K is smaller than D.

512 dimensions -> PCA 50 dimensions

Why this helps:

fewer dimensions -> faster distance calculations
less redundant signal -> sometimes cleaner clusters
smaller matrix -> lower memory pressure

Why this can hurt:

too few samples -> PCA learns noise
too small K -> useful distinctions collapse
wrong mode -> projection removes details the user wanted

A safe PCA policy is conditional:

if semantic_mode:
  if sample_count >= minimum_samples and input_dimension > target_dimension:
    fit PCA
    project vectors
    normalize again
  else:
    use full normalized vectors
else:
  use full normalized vectors

The second normalization matters. PCA changes vector lengths, so cosine-style comparisons should normalize again after projection.

A product should also expose the transformation:

semantic projection | input 512 -> PCA 50 | normalized

That explanation is useful when the user asks why one run differs from another.

6. KMeans Internals

KMeans assumes the data can be represented as k compact groups around k centers.

It asks:

Can these points be divided into k groups so each point is close to its group's center?

The objective is:

minimize sum(distance(point, assigned_center)^2)

The usual algorithm is Lloyd’s algorithm:

choose k initial centers
repeat:
  assign each point to the nearest center
  update each center to the mean of assigned points
until convergence or iteration limit

Worked example:

PointCoordinatesDistance to C1 (1,1)Distance to C2 (6,6)Label
A(1.0, 1.1)0.17.0C1
B(1.2, 0.9)0.27.0C1
C(6.1, 5.9)7.00.1C2
D(5.8, 6.2)7.10.3C2

Then the centers move:

C1 = mean(A, B)
C2 = mean(C, D)

KMeans is a good product default when the user wants a fixed number of groups:

"Make 12 review groups."
"Split this folder into 30 buckets."
"Give me manageable chunks to inspect."

It is also easy to explain:

This cluster is centered around these representative images.
Images are ordered by closeness to the center.

But KMeans has clear failure modes.

FailureWhy it happens
needs kthe algorithm cannot infer the right group count
forces assignmentevery point belongs somewhere, even bad outliers
prefers round/compact groupselongated or irregular groups split poorly
sensitive to initializationdifferent seeds can produce different clusters
not naturally hierarchicalno built-in “group within group” structure

That is why KMeans needs post-processing. It should not be the entire product.

7. Choosing k

The number of clusters is not a universal truth. It is a workflow decision.

For review UI, k should be connected to human workload:

too few clusters:
  each group is huge
  review is slow
  mixed concepts hide inside clusters

too many clusters:
  groups become tiny
  UI becomes noisy
  review becomes fragmented

Useful heuristics:

k = sqrt(N)
k = N / target_images_per_cluster
k = user-selected review count

For example:

N = 10,000 images
target_images_per_cluster = 100
k = 100 clusters

This is not mathematically perfect, but it matches product behavior. The user usually wants inspectable chunks, not a proof of optimality.

There are metrics like inertia, elbow curves, and silhouette score. They are useful for diagnostics, but they should not blindly override the user’s workflow.

8. MiniBatchKMeans

MiniBatchKMeans is KMeans optimized for responsiveness.

Full KMeans uses every point in each iteration:

iteration 1: all 20,000 vectors
iteration 2: all 20,000 vectors
iteration 3: all 20,000 vectors

MiniBatchKMeans uses samples:

iteration 1: 512 vectors
iteration 2: 512 vectors
iteration 3: 512 vectors

Each batch nudges the centers:

new_center = old_center + learning_rate * (batch_mean - old_center)

The result is approximate, but often good enough for preview.

flowchart LR
  A[Prepared matrix] --> B[Sample batch]
  B --> C[Assign batch to centers]
  C --> D[Update centers]
  D --> B
  D --> E[Final labels]

Product wording should be honest:

Good wording:
  Fast approximate grouping for large folders.

Bad wording:
  More advanced clustering.

The tradeoff is speed versus stability. If the same folder produces slightly different grouping under MiniBatchKMeans, that is expected. The UI should avoid making approximate preview results look more authoritative than they are.

9. FAISS

FAISS is a high-performance vector search and clustering library. It is useful because clustering and retrieval workloads spend most of their time computing nearest neighbors, dot products, and distances.

FAISS does not change the meaning of KMeans. FAISS KMeans still trains centroids and assigns vectors to the nearest centroid.

Conceptually:

prepared matrix
-> train FAISS centroids
-> assign vectors to nearest centroid
-> return labels
-> product post-processing

The difference is implementation. FAISS uses optimized native vector routines and indexing structures. That can matter when the folder is large.

FAISS should be optional in a desktop product:

DesignConsequence
FAISS requiredharder install, harder packaging, more native dependency risk
FAISS optionalbaseline remains portable
CPU path keptcorrectness can be tested without acceleration
backend visibleusers can understand why runs differ

The useful architecture is:

baseline:
  NumPy + scikit-learn

optional acceleration:
  FAISS CPU

separate GPU packaging:
  Torch CUDA runtime for model inference when shipped

The product should say “FAISS backend” or “FAISS CPU backend” when that is what is actually used. It should not imply custom CUDA clustering if no custom CUDA kernel exists.

10. HDBSCAN From First Principles

HDBSCAN is density-based clustering.

KMeans asks:

Can I split all points into k compact groups?

HDBSCAN asks:

Where are the stable dense regions, and which points are noise?

That is a better fit when the folder has unknown structure:

many vacation photos
some screenshots
some receipts
some one-off memes
some accidental duplicates
some unrelated downloaded images

HDBSCAN can mark points as noise:

cluster 0: 120 images
cluster 1: 88 images
cluster 2: 31 images
cluster -1: 247 outliers/noise images

The internal idea has several steps.

First, estimate local density. A point in a crowded region has nearby neighbors. A point in sparse space does not.

dense:
  o o o o
  o o o o

sparse:
          .       .            .

Second, use core distance. The core distance of a point is roughly the distance to its min_samples-th nearest neighbor.

small core distance -> dense area
large core distance -> sparse area

Third, build mutual reachability distance. This prevents sparse bridges from incorrectly joining dense groups.

mutual_reachability(a, b) =
  max(core_distance(a), core_distance(b), distance(a, b))

Fourth, build a hierarchy of clusters across density levels. HDBSCAN does not choose one fixed distance threshold immediately. It sees which groups persist as the density threshold changes.

Fifth, choose stable clusters from that hierarchy.

That is why HDBSCAN can find clusters without a predefined k.

Key parameters:

ParameterProduct meaning
min_cluster_sizesmallest group worth showing
min_sampleshow conservative noise detection should be
cluster_selection_epsilonhow much nearby density can merge
allow_single_clusterwhether one giant group is acceptable

Useful explanation:

HDBSCAN is not better KMeans.
It answers a different question.

11. Similarity Graph Clustering

Graph clustering treats images as nodes.

image = node
similarity above threshold = edge
connected region = cluster

Example:

A -- B -- C        D -- E

F

This becomes:

cluster 0: A, B, C
cluster 1: D, E
outlier/singleton: F

The core decision is how to create edges:

threshold graph:
  connect A and B if similarity >= 0.82

kNN graph:
  connect A to its top k nearest neighbors

mutual kNN graph:
  connect A and B only if each is in the other's neighbor list

Threshold graphs are easy to explain but sensitive to the threshold. kNN graphs keep graph degree bounded but can force links even in sparse areas. Mutual kNN is more conservative.

Graph clustering is useful for review because the explanation is intuitive:

These images are grouped because each image is linked to at least one similar neighbor.

It is also useful for near-duplicate lanes because duplicates often form tight connected components.

The main cost is neighbor search. A full pairwise similarity matrix costs:

N x N comparisons

For 50,000 images, that is too expensive to treat casually. A graph backend should either restrict candidates, use approximate nearest neighbor search, or build edges incrementally.

12. Outlier Policy

Outliers are not an afterthought. They are part of the product semantics.

Different backends produce outliers differently:

BackendNatural outlier behavior
KMeansno natural outliers; every point is assigned
MiniBatchKMeanssame as KMeans
HDBSCANreturns noise label -1
graph clusteringisolated nodes or tiny components

KMeans needs explicit outlier extraction if the product wants an outlier lane. One simple policy is centroid distance:

for each point:
  score = similarity(point, assigned_centroid)
  if score < threshold:
    move to outliers

The threshold should be visible or at least described, because outlier policy changes user trust. A photo moved to outliers is not “bad”; it is just not close enough to any chosen group under the current settings.

Good UI wording:

Outliers: images not confidently assigned to a cluster.

Bad UI wording:

Bad images.

13. Tiny Cluster Policy

Tiny clusters are unavoidable.

cluster 0: 310 images
cluster 1: 244 images
cluster 2: 2 images
cluster 3: 1 image
cluster 4: 197 images

Whether this is good depends on the workflow. A two-image cluster could be a real duplicate pair. It could also be UI clutter.

Policy options:

PolicyWhen useful
keep tiny clustersduplicate review, anomaly review
merge into nearest larger clusterbroad organization
move to outlierssimplify review
collapse in UIkeep data without visual clutter

Merging should be based on cluster centroids, not arbitrary cluster IDs.

tiny_cluster_centroid -> nearest_large_cluster_centroid -> merge

But automatic merging should be conservative. A tiny cluster that is far from all large clusters may be more honest as an outlier.

14. Member Reranking

Raw cluster members often arrive in row order. Row order is not meaningful to the user.

A better policy is to rank members by similarity to the cluster centroid:

centroid = mean(member_vectors)
centroid = normalize(centroid)

for each member:
  score = dot(member_vector, centroid)

sort by score descending

Then the first images in the UI are representative.

Before rerank:
  random scan order

After rerank:
  most central images first
  edge cases later

This one step can make clusters feel much more coherent because users judge a group by the first thumbnails they see.

15. Quality Scores

Quality scores are useful, but they are not truth.

Silhouette score is common. For each point, it compares:

a = average distance to points in the same cluster
b = average distance to points in the nearest other cluster

silhouette = (b - a) / max(a, b)

High silhouette means points are closer to their own cluster than to other clusters.

But image embeddings can be messy:

semantic overlap is real
visual overlap is real
folders may contain mixed concepts
duplicate and semantic similarity can disagree

So quality scores should be diagnostics, not final authority.

Better explanations combine several signals:

cluster size
mean cohesion
median cohesion
minimum cohesion
nearest competing cluster
separation margin
outlier count
backend used
PCA/full vector space used

This lets the user inspect why a cluster looks weak instead of seeing a mysterious score.

16. CUDA And GPU Wording

CUDA can help when the workload is massively parallel:

embedding inference
large matrix multiplication
nearest-neighbor search
distance calculations
batched image preprocessing

But CUDA wording must be precise. If the shipped app uses a Torch CUDA build for model inference and FAISS CPU for vector acceleration, say that. Do not imply custom CUDA clustering kernels unless they actually exist, ship, and are benchmarked.

Accurate wording:

GPU builds can accelerate embedding inference through Torch CUDA.
FAISS CPU can accelerate vector operations without requiring CUDA.
Custom CUDA clustering kernels are a future optimization unless shipped and measured.

Conceptually, a CUDA distance kernel could work like this:

grid:
  one block handles a tile of images and centroids

threads:
  each thread computes partial dot products over dimensions

shared memory:
  cache centroid tiles

reduction:
  sum partial dot products

output:
  nearest centroid for each image

But writing that kernel is only worth it if profiling proves distance computation dominates runtime.

Reasons not to write a custom kernel too early:

data transfer can erase the gain
PyTorch/FAISS already provide optimized kernels
packaging CUDA increases release complexity
debugging numerical differences is expensive
CPU path still needs to exist

The pragmatic rule:

1. Make the CPU implementation correct.
2. Cache embeddings and avoid repeated work.
3. Use vectorized NumPy/scikit paths.
4. Add FAISS where vector search dominates.
5. Use Torch CUDA for model inference in GPU builds.
6. Write custom CUDA only after profiling proves it is necessary.

17. Backend Selection Guide

No backend is universally best.

NeedBetter backendWhy
fixed number of review groupsKMeanssimple objective and predictable count
fast preview on large foldersMiniBatchKMeansapproximate center updates
large vector workloadFAISS KMeansoptimized native vector routines
unknown number of groupsHDBSCANfinds density and noise
many outliersHDBSCAN or graphdoes not force every point into a centroid
near-duplicate reviewgraph plus perceptual hashesedges map well to candidate groups
explainable threshold groupinggraph”similar enough” edges are intuitive

Decision flow:

Does the user specify k?
  yes -> KMeans family
  no -> HDBSCAN or graph

Is the folder large and preview-oriented?
  yes -> MiniBatchKMeans or FAISS

Are many outliers expected?
  yes -> HDBSCAN or explicit outlier policy

Is this duplicate review?
  yes -> perceptual hash prefilter, graph, ORB/embedding rerank

Does the runtime have optional acceleration?
  yes -> choose faster backend, keep CPU fallback

18. Implementation Blueprint

A simple implementation shape:

scan image paths
freeze ordered paths

for each path:
  load cached embedding if valid
  otherwise compute embedding
  store embedding with model signature and file fingerprint

matrix = stack embeddings in frozen order
matrix = float32(matrix)

if similarity mode uses cosine:
  matrix = l2_normalize(matrix)

if semantic mode and PCA is safe:
  matrix = PCA(matrix)
  matrix = l2_normalize(matrix)

labels = run_backend(matrix, options)

clusters = group row indexes by label
clusters = apply_outlier_policy(matrix, clusters)
clusters = merge_or_keep_tiny_clusters(matrix, clusters)
clusters = rerank_members_by_centroid(matrix, clusters)

explanations = compute_cluster_explanations(matrix, clusters)
return clusters, explanations, metrics

The metrics should include:

backend
input dimension
prepared dimension
PCA components if used
normalization
outlier count
cluster quality score
runtime
model signature

This makes clustering inspectable instead of magical.

19. What Was Done And Why

L2 normalization is used because semantic vectors should be compared by direction. It makes cosine-style ranking consistent and turns dot product into a usable similarity score.

PCA is conditional because it can speed up clustering and reduce noise, but it can also destroy information on tiny datasets. The product should use it when enough samples exist and explain when it falls back to full vectors.

KMeans exists because it is fast, common, and explainable. It is the right default when the user wants a fixed number of review groups.

MiniBatchKMeans exists because responsiveness matters. It gives a fast approximate grouping path for large folders and preview workflows.

FAISS exists as an optional acceleration backend. It should improve vector-heavy operations without becoming the only way the app can run.

HDBSCAN exists because many real folders do not have a clean cluster count and contain outliers. It lets the product say “these are dense groups; these are noise” instead of forcing every image into a bucket.

Graph clustering exists because threshold-based “similar enough” grouping is easier to reason about for duplicate and review workflows.

Outlier handling, tiny-cluster policy, and reranking exist because raw labels are not a user experience.

20. What Was Used, Removed, Or Deferred

Used:

L2 normalization
semantic PCA projection when safe
KMeans
MiniBatchKMeans
optional FAISS CPU backend
optional HDBSCAN backend
similarity graph clustering
outlier policy
tiny-cluster policy
centroid/member reranking
cluster explanations
perceptual hash index for duplicate-oriented lanes

Removed or avoided:

treating one clustering algorithm as universal
forcing HDBSCAN into fixed-k workflows
hiding PCA and backend choices
using perceptual hashes as semantic labels
presenting approximate preview results as final truth
automatic duplicate deletion from clustering alone

Deferred:

custom CUDA clustering kernels
automatic parameter tuning that overrides user intent
guaranteed perfect clusters
GPU FAISS claims unless the shipped build actually includes them
destructive cleanup based only on embedding similarity

The main lesson is that clustering is a system, not a single function call. A good implementation makes representation, backend, post-processing, and review policy explicit. That is what turns vector labels into a tool people can safely use.

Comments