Unsupervised image clustering with modern deep embeddings.
tasnif turns a folder of images into clusters you can browse on disk - no
labels required. It uses modern pretrained vision backbones (timm by default,
optionally CLIP via open_clip), reduces the embedding with PCA, and runs
K-Means on top.
- Modern backbones - any
timmmodel (ResNet, ConvNeXt, ViT, DINOv2, ...) or CLIP via the[clip]extra. - GPU / MPS / CPU with automatic device detection.
- scikit-learn-style API:
fit,predict,fit_predict,transform. - Rich export: per-cluster folders, CSV manifest, JSON summary, preview grids, raw embeddings.
- Multiple materialization modes:
copy,symlink,move, ornone(metadata only). - First-class CLI powered by Typer.
- Pluggable embedders - register your own backend.
- Deterministic with a
random_stateseed.
tasnif is built and packaged with uv, but plain pip works too.
pip install tasnif # core
pip install "tasnif[cli]" # core + CLI
pip install "tasnif[clip]" # core + CLIP backend
pip install "tasnif[all]" # everythingDevelopment setup:
git clone https://github.com/cobanov/tasnif
cd tasnif
uv sync --extra dev
uv run pytestfrom tasnif import TasnifClusterer
clf = TasnifClusterer(n_clusters=5, embedder="timm", pca_dim=16)
clf.fit("photos/")
result = clf.result_
# Inspect without exporting
print(result.counts) # e.g. [42, 31, 28, 19, 7]
print(result.silhouette) # None unless compute_silhouette=True
mapping = result.as_dict() # {Path('photos/a.jpg'): 2, ...}
buckets = result.by_cluster() # {0: [Path(...), ...], 1: [...]}
# Export to disk
clf.export("output/", mode="copy")from tasnif import cluster_directory
cluster_directory("photos/", "output/", n_clusters=5, mode="symlink")# Cluster a directory with the default ResNet-50 (timm) backbone
tasnif cluster photos/ -k 5 -o output/
# Use CLIP and copy via symlink (fast, non-destructive)
tasnif cluster photos/ -k 8 --embedder clip --model ViT-B-32 --mode symlink
# Just compute embeddings, save .npy + .json
tasnif embed photos/ --embedder timm --model convnext_base -o embeddings.npy
# List available backends
tasnif backendsRun tasnif --help to see all options.
Default is timm:resnet50. Pass any timm model name:
clf = TasnifClusterer(
n_clusters=8,
embedder="timm",
embedder_kwargs={"model": "vit_base_patch14_dinov2.lvd142m", "device": "cuda"},
)CLIP:
clf = TasnifClusterer(
n_clusters=8,
embedder="clip",
embedder_kwargs={"model": "ViT-L-14", "pretrained": "laion2b_s32b_b82k"},
)Anything implementing the Embedder protocol works:
import numpy as np
from tasnif import TasnifClusterer, register_embedder
class MyEncoder:
name = "my-encoder"
@property
def dim(self): return 128
@property
def device(self): return "cpu"
def embed(self, images, *, batch_size=32, show_progress=True):
return np.stack([extract(img) for img in images])
register_embedder("my-encoder", lambda: MyEncoder())
clf = TasnifClusterer(n_clusters=5, embedder="my-encoder")All pieces are independently usable:
from tasnif import (
discover_images, create_embedder,
reduce_pca, fit_kmeans, export_clusters, ClusterResult,
)
paths = discover_images("photos/")
embedder = create_embedder("timm", model="resnet50")
embeddings = embedder.embed([open_pil(p) for p in paths])
reduced = reduce_pca(embeddings, n_components=16)
fit = fit_kmeans(reduced, n_clusters=5, compute_silhouette=True)
result = ClusterResult(
labels=fit.labels, centroids=fit.centroids, counts=fit.counts,
paths=tuple(paths), n_clusters=5, inertia=fit.inertia, silhouette=fit.silhouette,
embedder=embedder.name, pca_dim=16,
)
export_clusters(result, "output/")The 0.1.x API (Tasnif().read().calculate().export()) was removed in 0.2.0.
Replace with:
- from tasnif import Tasnif
- c = Tasnif(num_classes=5, pca_dim=16, use_gpu=False)
- c.read("photos/")
- c.calculate()
- c.export("output/")
+ from tasnif import TasnifClusterer
+ clf = TasnifClusterer(n_clusters=5, pca_dim=16, embedder_kwargs={"device": "auto"})
+ clf.fit("photos/")
+ clf.export("output/")See CHANGELOG.md for the full list of breaking changes.
Issues and PRs welcome. Before submitting, run:
uv run ruff check . && uv run ruff format --check .
uv run mypy
uv run pytestPre-commit is configured - install hooks with uv run pre-commit install.
MIT - see LICENSE.
