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.
Highlights
- 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.
Installation
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]" # everything
Development setup:
git clone https://github.com/cobanov/tasnif
cd tasnif
uv sync --extra dev
uv run pytestQuickstart - Python API
from 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")
One-shot helper
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 backends
Run tasnif --help to see all options.
Use a different model
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"}, )
Custom backend
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")
Building blocks
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/")
Migrating from 0.1.x
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.
Contributing
Issues and PRs welcome. Before submitting, run:
uv run ruff check . && uv run ruff format --check . uv run mypy uv run pytest
Pre-commit is configured - install hooks with uv run pre-commit install.
License
MIT - see LICENSE.
