Files
playground/cluster_images.py
T
2026-07-24 15:58:31 +02:00

164 lines
5.2 KiB
Python
Executable File

#!/usr/bin/env -S uv run
# /// script
# dependencies = [
# "torch",
# "torchvision",
# "timm",
# "scikit-learn",
# "pillow",
# "imagehash",
# ]
# ///
"""Group similar large images using ResNet embeddings or perceptual hashing."""
import torch
import timm
import numpy as np
from PIL import Image
from pathlib import Path
from sklearn.cluster import KMeans, DBSCAN
from sklearn.preprocessing import StandardScaler
import imagehash
import argparse
# Load pretrained ResNet50 on M1
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
model = timm.create_model("resnet50", pretrained=True, num_classes=0)
model = model.to(device)
model.eval()
def get_embedding(img_path, size=224):
"""Extract embedding from image."""
try:
img = Image.open(img_path).convert("RGB")
img.thumbnail((size, size), Image.Resampling.LANCZOS)
canvas = Image.new("RGB", (size, size), (128, 128, 128))
offset = ((size - img.width) // 2, (size - img.height) // 2)
canvas.paste(img, offset)
x = torch.tensor(np.array(canvas), dtype=torch.float32)
x = x.permute(2, 0, 1) / 255.0
x = (x - torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)) / torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
with torch.no_grad():
embedding = model(x.unsqueeze(0).to(device)).squeeze().cpu().numpy()
return embedding
except Exception as e:
print(f"Error processing {img_path}: {e}")
return None
def get_phash(img_path):
"""Extract perceptual hash from image."""
try:
img = Image.open(img_path).convert("RGB")
return imagehash.phash(img)
except Exception as e:
print(f"Error processing {img_path}: {e}")
return None
def hash_distance(h1, h2):
"""Hamming distance between two hashes."""
return h1 - h2
def parse_args():
parser = argparse.ArgumentParser(description="Group similar images using embeddings or perceptual hashing.")
parser.add_argument("paths", nargs="+", type=Path, help="Image file(s) or directory")
parser.add_argument("--cluster", action="store_true", help="Use ResNet embedding clustering")
parser.add_argument("--perceptual-hash", action="store_true", help="Use perceptual hashing")
parser.add_argument("--clusters", type=int, help="Number of clusters for embedding mode (auto if not specified)")
parser.add_argument("--hash-threshold", type=int, default=5, help="Hamming distance threshold for perceptual hash")
args = parser.parse_args()
if not args.cluster and not args.perceptual_hash:
parser.error("Either --cluster or --perceptual-hash must be specified")
if args.cluster and args.perceptual_hash:
parser.error("Cannot specify both --cluster and --perceptual-hash")
return args
def group_by_hash(valid_files, hashes, threshold):
"""Group images by perceptual hash similarity."""
labels = [-1] * len(valid_files)
cluster_id = 0
for i in range(len(valid_files)):
if labels[i] != -1:
continue
labels[i] = cluster_id
for j in range(i + 1, len(valid_files)):
if labels[j] == -1 and hash_distance(hashes[i], hashes[j]) <= threshold:
labels[j] = cluster_id
cluster_id += 1
return np.array(labels)
def main():
args = parse_args()
# Collect image files
img_files = []
for path in args.paths:
if path.is_dir():
img_files.extend(path.glob("*.[jJ][pP][gG]"))
img_files.extend(path.glob("*.[pP][nN][gG]"))
else:
img_files.append(path)
if not img_files:
print("No images found.")
return
if args.perceptual_hash:
# Perceptual hash mode
hashes = []
valid_files = []
for i, img_path in enumerate(img_files):
print(f"Processing {i + 1}/{len(img_files)}: {img_path.name}")
h = get_phash(img_path)
if h is not None:
hashes.append(h)
valid_files.append(img_path)
labels = group_by_hash(valid_files, hashes, args.hash_threshold)
n_clusters = len(np.unique(labels))
else:
# Embedding clustering mode
embeddings = []
valid_files = []
for i, img_path in enumerate(img_files):
print(f"Processing {i + 1}/{len(img_files)}: {img_path.name}")
emb = get_embedding(img_path)
if emb is not None:
embeddings.append(emb)
valid_files.append(img_path)
embeddings = np.array(embeddings)
scaler = StandardScaler()
embeddings = scaler.fit_transform(embeddings)
n_clusters = args.clusters or max(2, int(np.sqrt(len(embeddings) / 2)))
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
labels = kmeans.fit_predict(embeddings)
# Save clustered images
for img_path, cluster_label in zip(valid_files, labels):
new_name = f"group{cluster_label}_{img_path.name}"
dest = Path.cwd() / new_name
img_path.rename(dest) if img_path.parent == Path.cwd() else dest.write_bytes(img_path.read_bytes())
print(f"Clustered {len(valid_files)} images into {n_clusters} clusters in {Path.cwd()}")
if __name__ == "__main__":
main()