| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
|
|
|
|
|
|
|
|
|
|
| import json |
| import hashlib |
| from pathlib import Path |
| import pandas as pd |
| import torch |
| import torch.nn as nn |
| from torchvision import models, transforms |
| from PIL import Image |
| from sklearn.cluster import KMeans |
| from sklearn.metrics import silhouette_score |
| import numpy as np |
| from typing import List, Dict, Any |
| from collections import defaultdict |
|
|
| def setup_feature_extractor(): |
| """Initialize the ResNet18 feature extractor""" |
| |
| transform = transforms.Compose([ |
| transforms.Resize((224, 224)), |
| transforms.ToTensor(), |
| transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), |
| ]) |
| |
| |
| resnet = models.resnet18(pretrained=True) |
| encoder = nn.Sequential(*list(resnet.children())[:-1]) |
| encoder.eval() |
| |
| return transform, encoder |
|
|
| def get_image_path_from_url(url: str, image_cache_dir: Path) -> Path: |
| """Generate image path from URL using the same hashing method""" |
| filename = hashlib.md5(url.encode()).hexdigest() + '.jpg' |
| return image_cache_dir / filename |
|
|
| def extract_features_batch(image_paths: List[Path], transform, encoder, batch_size: int = 32) -> List[np.ndarray]: |
| """Extract features in batches for better GPU utilization""" |
| features_list = [] |
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') |
| encoder = encoder.to(device) |
| |
| for i in range(0, len(image_paths), batch_size): |
| batch_paths = image_paths[i:i + batch_size] |
| batch_tensors = [] |
| valid_indices = [] |
| |
| |
| for j, path in enumerate(batch_paths): |
| try: |
| image = Image.open(path).convert('RGB') |
| tensor = transform(image) |
| batch_tensors.append(tensor) |
| valid_indices.append(i + j) |
| except Exception as e: |
| print(f"Error loading {path}: {e}") |
| features_list.append(None) |
| continue |
| |
| if not batch_tensors: |
| continue |
| |
| |
| batch_tensor = torch.stack(batch_tensors).to(device) |
| with torch.no_grad(): |
| batch_features = encoder(batch_tensor).squeeze().cpu().numpy() |
| |
| |
| if len(batch_tensors) == 1: |
| batch_features = batch_features.reshape(1, -1) |
| |
| |
| batch_idx = 0 |
| for j in range(len(batch_paths)): |
| if i + j in valid_indices: |
| features_list.append(batch_features[batch_idx]) |
| batch_idx += 1 |
| else: |
| features_list.append(None) |
| |
| return features_list |
|
|
| def find_optimal_clusters(vectors: np.ndarray, min_k: int = 2, max_k: int = 6) -> int: |
| """Find optimal number of clusters using silhouette score""" |
| if len(vectors) < min_k: |
| return min(len(vectors), 2) |
| |
| best_k, best_score = min_k, -1 |
| |
| for k in range(min_k, min(max_k + 1, len(vectors) + 1)): |
| kmeans = KMeans(n_clusters=k, random_state=42, n_init=10) |
| labels = kmeans.fit_predict(vectors) |
| |
| |
| if len(set(labels)) > 1: |
| score = silhouette_score(vectors, labels) |
| if score > best_score: |
| best_k, best_score = k, score |
| |
| return best_k |
|
|
| def cluster_images_by_decade(metadata: List[Dict[Any, Any]], image_cache_dir: Path, |
| batch_size: int = 32, use_fixed_k: bool = False, fixed_k: int = 5) -> List[Dict[Any, Any]]: |
| """ |
| Cluster images by decade and add cluster information to metadata |
| |
| Args: |
| metadata: List of metadata dictionaries |
| image_cache_dir: Path to cached images directory |
| batch_size: Batch size for feature extraction |
| use_fixed_k: Whether to use fixed number of clusters (faster) |
| fixed_k: Fixed number of clusters if use_fixed_k=True |
| |
| Returns: |
| Updated metadata with cluster information |
| """ |
| print("Setting up feature extractor...") |
| transform, encoder = setup_feature_extractor() |
| |
| |
| decade_groups = defaultdict(list) |
| for item in metadata: |
| decade_groups[item['decade']].append(item) |
| |
| updated_metadata = [] |
| |
| for decade, items in decade_groups.items(): |
| print(f"\nProcessing decade: {decade} ({len(items)} images)") |
| |
| |
| valid_items = [] |
| image_paths = [] |
| |
| for item in items: |
| image_path = get_image_path_from_url(item['url'], image_cache_dir) |
| |
| if not image_path.exists(): |
| print(f"Warning: Image not found: {image_path}") |
| item['cluster'] = -1 |
| updated_metadata.append(item) |
| continue |
| |
| valid_items.append(item) |
| image_paths.append(image_path) |
| |
| if len(image_paths) == 0: |
| print(f"No valid images found for decade {decade}") |
| continue |
| |
| if len(image_paths) == 1: |
| valid_items[0]['cluster'] = 0 |
| updated_metadata.extend(valid_items) |
| continue |
| |
| |
| print(f"Extracting features for {len(image_paths)} images...") |
| features_list = extract_features_batch(image_paths, transform, encoder, batch_size) |
| |
| |
| final_items = [] |
| final_vectors = [] |
| |
| for item, features in zip(valid_items, features_list): |
| if features is not None: |
| final_items.append(item) |
| final_vectors.append(features) |
| else: |
| item['cluster'] = -1 |
| updated_metadata.append(item) |
| |
| if len(final_vectors) == 0: |
| print(f"No valid features extracted for decade {decade}") |
| continue |
| |
| if len(final_vectors) == 1: |
| final_items[0]['cluster'] = 0 |
| updated_metadata.extend(final_items) |
| continue |
| |
| |
| vectors = np.array(final_vectors) |
| print(f"Feature extraction complete. Shape: {vectors.shape}") |
| |
| |
| if use_fixed_k: |
| k = min(fixed_k, len(vectors)) |
| print(f"Using fixed k={k}") |
| else: |
| k = find_optimal_clusters(vectors) |
| print(f"Optimal number of clusters: {k}") |
| |
| |
| if k > 1: |
| kmeans = KMeans(n_clusters=k, random_state=42, n_init=5) |
| cluster_labels = kmeans.fit_predict(vectors) |
| else: |
| cluster_labels = np.zeros(len(vectors), dtype=int) |
| |
| |
| for item, cluster_label in zip(final_items, cluster_labels): |
| item['cluster'] = int(cluster_label) |
| |
| updated_metadata.extend(final_items) |
| |
| print(f"Clustering complete. Cluster distribution: {dict(zip(*np.unique(cluster_labels, return_counts=True)))}") |
| |
| return updated_metadata |
|
|
| def add_clustering_to_pipeline(data_dir: Path, batch_size: int = 32, use_fixed_k: bool = False, fixed_k: int = 5): |
| """ |
| Add clustering step to the existing data processing pipeline |
| |
| Args: |
| data_dir: Data directory path |
| batch_size: Batch size for feature extraction (larger = faster but more memory) |
| use_fixed_k: Use fixed number of clusters instead of optimization (much faster) |
| fixed_k: Number of clusters to use if use_fixed_k=True |
| """ |
| processed_json_path = data_dir / 'metadata' / 'processed_metadata.json' |
| image_cache_dir = data_dir / 'cache' / 'images' |
| |
| |
| print("Loading processed metadata...") |
| with open(processed_json_path, 'r', encoding='utf-8') as f: |
| metadata = json.load(f) |
| |
| print(f"Loaded {len(metadata)} items from metadata") |
| |
| |
| device = "GPU" if torch.cuda.is_available() else "CPU" |
| print(f"Using device: {device}") |
| |
| |
| print("Starting clustering process...") |
| clustered_metadata = cluster_images_by_decade( |
| metadata, image_cache_dir, batch_size, use_fixed_k, fixed_k |
| ) |
| |
| |
| print(f"Saving clustered metadata back to {processed_json_path}") |
| |
| with open(processed_json_path, 'w', encoding='utf-8') as f: |
| json.dump(clustered_metadata, f, indent=2, ensure_ascii=False) |
| |
| print("Clustering complete!") |
| |
| |
| cluster_stats = {} |
| missing_images = 0 |
| failed_extractions = 0 |
| |
| for item in clustered_metadata: |
| decade = item['decade'] |
| cluster = item.get('cluster', -1) |
| |
| if cluster == -1: |
| if 'cluster' in item: |
| failed_extractions += 1 |
| else: |
| missing_images += 1 |
| continue |
| |
| if decade not in cluster_stats: |
| cluster_stats[decade] = {} |
| |
| if cluster not in cluster_stats[decade]: |
| cluster_stats[decade][cluster] = 0 |
| cluster_stats[decade][cluster] += 1 |
| |
| print("\n=== Clustering Summary ===") |
| for decade, clusters in cluster_stats.items(): |
| print(f"{decade}: {len(clusters)} clusters, {sum(clusters.values())} images") |
| for cluster_id, count in sorted(clusters.items()): |
| print(f" Cluster {cluster_id}: {count} images") |
| |
| if missing_images > 0: |
| print(f"\nWarning: {missing_images} images were not found in cache") |
| if failed_extractions > 0: |
| print(f"Warning: {failed_extractions} images failed feature extraction") |
| |
| return processed_json_path |