Skip to main content

Tasnif

Unsupervised image clustering with modern deep embeddings.

PyPI Python CI License

tasnif turns a folder of images into clusters you can browse on disk - no labels required. It embeds each image with a pretrained vision backbone (ConvNeXt pretrained on ImageNet-22k by default, any timm model, CLIP via open_clip, or a face recognition model via deepface), reduces the embeddings with PCA, and runs K-Means on top.

Highlights

  • Modern backbones - any timm model (ConvNeXt by default, DINOv2, ViT, ResNet, ...) or CLIP via the [clip] extra, picked by benchmark.
  • Group photos by person - the face backend embeds the face in each image with DeepFace ([face] extra).
  • Picks the number of clusters for you with n_clusters="auto" (best silhouette score).
  • Any folder size - images are streamed and decoded a few at a time at reduced size; unreadable files are skipped, phone photos are turned upright from their EXIF orientation.
  • 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, or none (metadata only). Existing files are never replaced; clashing names get a numeric suffix.
  • First-class CLI powered by Typer.
  • Pluggable embedders - register your own backend.
  • Deterministic with a random_state seed.

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[face]"        # core + face backend (DeepFace)
pip install "tasnif[all]"         # everything

Development setup:

git clone https://github.com/cobanov/tasnif
cd tasnif
uv sync --extra dev
uv run pytest

Quickstart - Python API

from tasnif import TasnifClusterer

clf = TasnifClusterer(n_clusters="auto")  # or n_clusters=5
clf.fit("photos/")
result = clf.result_
print(clf.n_clusters_)          # the k that was chosen
print(clf.skipped_)             # files that could not be read

# 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")

CLI

# Cluster a directory with the default ConvNeXt backbone
tasnif cluster photos/ -k 5 -o output/

# Let tasnif pick the number of clusters
tasnif cluster photos/ -k auto -o output/

# Use CLIP and copy via symlink (fast, non-destructive)
tasnif cluster photos/ -k 8 --embedder clip --model ViT-B-32 --mode symlink

# Group portraits by person
tasnif cluster portraits/ -k 10 --embedder face

# 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

The default is timm:convnext_small.fb_in22k, ConvNeXt-S pretrained on ImageNet-22k. Pass any timm model name:

clf = TasnifClusterer(
    n_clusters=8,
    embedder="timm",
    embedder_kwargs={"model": "convnext_tiny.fb_in22k", "device": "cuda"},
)

How backbones compare: K-Means (k=10, PCA 16) on 600 images from each of five 10-class sets, NMI averaged over three seeds (1.0 means the clusters match the true classes exactly), and images per second on an Apple M4 Pro GPU:

Backbone Imagenette Imagewoof Flowers EuroSAT DTD Mean img/s
convnext_small.fb_in22k (default) 0.99 0.86 0.99 0.68 0.65 0.84 107
convnext_tiny.fb_in22k 0.99 0.83 0.97 0.71 0.60 0.82 181
DINOv2 ViT-S/14 reg4 at 224 px 0.90 0.84 1.00 0.62 0.64 0.80 163
CLIP ViT-B-16 0.99 0.70 0.95 0.68 0.61 0.79 79
CLIP ViT-B-32 (clip default) 0.96 0.65 0.93 0.70 0.61 0.77 213
resnet50 (default before 0.3.0) 0.91 0.82 0.65 0.61 0.55 0.71 193

Imagewoof is ten dog breeds, Flowers ten species from Oxford Flowers-102, EuroSAT satellite tiles and DTD textures. ResNet-50 learned the 1,000 ImageNet-1k classes and falls behind on anything else; the ImageNet-22k ConvNeXt holds up across all five. With n_clusters="auto" it found exactly 10 clusters on three of the five sets and 11 on a fourth. convnext_tiny.fb_in22k is as fast as ResNet-50 if speed matters more.

CLIP:

clf = TasnifClusterer(
    n_clusters=8,
    embedder="clip",
    embedder_kwargs={"model": "ViT-L-14", "pretrained": "laion2b_s32b_b82k"},
)

Faces (requires tasnif[face]):

clf = TasnifClusterer(
    n_clusters=10,
    embedder="face",
    embedder_kwargs={"model": "Facenet512", "detector": "retinaface"},
)

The face backend represents each image by its largest face, so photos of the same person cluster together whatever the background or framing. An image without a detectable face is embedded whole by the same model. model takes any DeepFace model (Facenet512, ArcFace, SFace, ...) and detector any of its detector backends (retinaface, yunet, centerface, ...). DeepFace runs on PyTorch when TensorFlow is not installed and picks its own device, so device is ignored. The models it wraps carry their own licenses; check them before production use.

Large folders

Images given as a folder or a list of paths are never all in memory at once. TasnifClusterer decodes them a few ahead on workers threads (default: up to 8), scales each so its shorter side is at most max_image_size (512 px by default; JPEGs are decoded at reduced scale directly, so a 12 MP photo takes about 1 MB instead of 36 MB) and drops it once embedded. Grid previews load their thumbnails from disk at export time. Pass max_image_size=None (CLI: --max-size 0) to keep full resolution.

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, ImageStream,
    reduce_pca, fit_kmeans, export_clusters, ClusterResult,
)

paths = discover_images("photos/")
embedder = create_embedder("timm")
stream = ImageStream(paths, max_size=512)  # decodes lazily, skips unreadable files
embeddings = embedder.embed(stream)
paths = stream.loaded
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.

Metadata

Release files for tasnif 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tasnif 0.3.0
File Size Uploaded
tasnif-0.3.0.tar.gz 26.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tasnif 0.3.0
File Interpreter ABI Platform
tasnif-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 57.6 kB

Release files / tasnif-0.3.0.tar.gz

Download URL tasnif-0.3.0.tar.gz
Size 26.3 kB
Tags Source
SHA-256 checksum
How to use checksums
52c09b5161ace21264076857b82fa033287d824e34b0d822f6dd460c02bb8619
BLAKE2b-256 checksum
How to use checksums
8974615ff0a744983f39c3133312d1bda1db85be48dd062c47cbc80d704f3cb8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.

Transparency log

Release files / tasnif-0.3.0-py3-none-any.whl

Download URL tasnif-0.3.0-py3-none-any.whl
Size 31.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
85671118071974402a154798b560ad0147f3a129e3ce3a16d251d2459ddd13dc
BLAKE2b-256 checksum
How to use checksums
a9d4d6ac2193b765bffc809efd142742163b1366d53803bc81ace96d35971b86
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.11

1 release file

0.1.10

1 release file

0.1.9

1 release file

0.1.8

1 release file

0.1.7

1 release file

0.1.6

1 release file

0.1.5

1 release file

0.1.4

1 release file

0.1.3

1 release file

0.1.2

1 release file

0.1.1

1 release file

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page