164 lines
5.2 KiB
Python
Executable File
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()
|