Diwakar Basnet commited on
Commit
052f26d
·
1 Parent(s): e0314f7

feat: integrate I-JEPA manager and HF model repository loading

Browse files
README.md CHANGED
@@ -8,6 +8,7 @@ sdk_version: 6.13.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
 
11
  short_description: Finds meme with the most similar expression or pose
12
  ---
13
 
 
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
+ python_version: 3.14
12
  short_description: Finds meme with the most similar expression or pose
13
  ---
14
 
app.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from pathlib import Path
3
+ from PIL import Image
4
+ import numpy as np
5
+
6
+ from config import settings
7
+ from utils.model_loading_util import load_model_from_hf
8
+ from src.processing.embedder import EmbeddingGenerator
9
+ from src.search.pinecone_indexer import PineconeImageSimilaritySearch
10
+
11
+
12
+ print("Initializing model and search index...")
13
+ model = load_model_from_hf(
14
+ repo_id=settings.model_repo_id,
15
+ device=settings.device,
16
+ token=settings.hf_token
17
+ )
18
+
19
+
20
+ generator = EmbeddingGenerator(
21
+ model=model,
22
+ device=settings.device,
23
+ batch_size=settings.batch_size,
24
+ num_workers=2,
25
+ layer_strategy="last_four_concat"
26
+ )
27
+
28
+ searcher = PineconeImageSimilaritySearch(
29
+ index_name=settings.pinecone_index_name,
30
+ api_key=settings.pinecone_api_key,
31
+ dimension=settings.embedding_dim,
32
+ metric="cosine",
33
+ )
34
+
35
+
36
+ def search_similar_images(input_img):
37
+ if input_img is None:
38
+ return None
39
+
40
+ # Convert Gradio input (can be numpy array) to PIL Image
41
+ if isinstance(input_img, np.ndarray):
42
+ input_img = Image.fromarray(input_img)
43
+
44
+ query_embedding = generator.generate_single_embedding(input_img)
45
+ results = searcher.search(query_embedding, k=5, return_scores=True)
46
+
47
+ gallery_items = []
48
+ for path, score in results:
49
+ img_path = Path(path)
50
+ if not img_path.is_absolute():
51
+ img_path = settings.project_root / img_path
52
+
53
+ if img_path.exists():
54
+ gallery_items.append((str(img_path), f"Similarity: {score:.4f}"))
55
+ else:
56
+ print(f"Warning: Image path not found: {img_path}")
57
+
58
+ return gallery_items
59
+
60
+
61
+ custom_css = """
62
+ .container {
63
+ max-width: 1000px;
64
+ margin: auto;
65
+ padding: 20px;
66
+ }
67
+ .header {
68
+ text-align: center;
69
+ margin-bottom: 30px;
70
+ }
71
+ .header h1 {
72
+ font-size: 2.5rem;
73
+ font-weight: 800;
74
+ background: linear-gradient(90deg, #4F46E5, #EC4899);
75
+ -webkit-background-clip: text;
76
+ -webkit-text-fill-color: transparent;
77
+ margin-bottom: 10px;
78
+ }
79
+ .header p {
80
+ color: #6B7280;
81
+ font-size: 1.1rem;
82
+ }
83
+ .gradio-container {
84
+ background-color: #F9FAFB !important;
85
+ }
86
+ .gallery-container {
87
+ border-radius: 12px;
88
+ overflow: hidden;
89
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
90
+ }
91
+ """
92
+
93
+ with gr.Blocks() as demo:
94
+ with gr.Column(elem_classes="container"):
95
+ with gr.Column(elem_classes="header"):
96
+ gr.Markdown("# Meme Similarity Search")
97
+ gr.Markdown(
98
+ "Upload an image to find the top 5 most similar memes in our database.")
99
+
100
+ with gr.Row():
101
+ with gr.Column(scale=1):
102
+ input_image = gr.Image(
103
+ label="Upload Image",
104
+ type="pil",
105
+ elem_id="input-img"
106
+ )
107
+ search_btn = gr.Button("Find Similar Memes", variant="primary")
108
+
109
+ with gr.Column(scale=2):
110
+ output_gallery = gr.Gallery(
111
+ label="Top 5 Similar Memes",
112
+ show_label=True,
113
+ elem_id="gallery",
114
+ columns=2,
115
+ rows=3,
116
+ object_fit="contain",
117
+ height="600px"
118
+ )
119
+
120
+ search_btn.click(
121
+ fn=search_similar_images,
122
+ inputs=input_image,
123
+ outputs=output_gallery
124
+ )
125
+
126
+ input_image.upload(
127
+ fn=search_similar_images,
128
+ inputs=input_image,
129
+ outputs=output_gallery
130
+ )
131
+
132
+ if __name__ == "__main__":
133
+ demo.launch(
134
+ server_name="0.0.0.0",
135
+ server_port=7860,
136
+ css=custom_css
137
+ )
config/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .settings import settings
config/settings.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from pathlib import Path
3
+ from typing import Dict, Any, Optional
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+ from pydantic import Field
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ # Pinecone Settings (Loaded from .env)
10
+ pinecone_api_key: str
11
+ pinecone_index_name: str
12
+
13
+ project_root: Path = Field(default=Path(__file__).parent.parent)
14
+
15
+ # Hugging Face Settings
16
+ model_repo_id: str = "Unspoiled-Egg/ijepa-target-encoder-huge"
17
+ hf_token: Optional[str] = None
18
+
19
+ # Model & Inference Settings
20
+ batch_size: int = 4
21
+ device: str = "cuda" if torch.cuda.is_available() else "cpu"
22
+ embedding_dim: int = 5120
23
+
24
+ model_config = SettingsConfigDict(
25
+ env_file=".env",
26
+ env_file_encoding="utf-8",
27
+ extra="ignore"
28
+ )
29
+
30
+
31
+ # Create the singleton instance
32
+ settings = Settings()
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "gradio>=6.13.0",
2
+ "numpy",
3
+ "pillow",
4
+ "pinecone",
5
+ "pydantic>=2.13.3",
6
+ "pydantic-settings>=2.14.0",
7
+ "torch",
8
+ "torchvision",
9
+ "tqdm>=4.67.3",
10
+ "uuid",
11
+ "huggingface_hub",
src/__init__.py ADDED
File without changes
src/models/__init__.py ADDED
File without changes
src/models/ijepa.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from typing import Optional
4
+ from .patch_embedding import PatchEmbed
5
+ from .transformer_block import TransformerBlock
6
+
7
+
8
+ class IJEPATargetEncoder(nn.Module):
9
+ """
10
+ Standard ViT without classification head.
11
+ Processes full image and outputs patch-level representations.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ img_size: int = 224,
17
+ patch_size: int = 14,
18
+ in_chans: int = 3,
19
+ embed_dim: int = 768,
20
+ depth: int = 12,
21
+ num_heads: int = 12,
22
+ mlp_ratio: float = 4.0,
23
+ qkv_bias: bool = True,
24
+ drop_rate: float = 0.0,
25
+ attn_drop_rate: float = 0.0,
26
+ norm_layer: Optional[nn.Module] = None,
27
+ ):
28
+ super().__init__()
29
+ self.num_features = self.embed_dim = embed_dim
30
+ norm_layer = norm_layer or nn.LayerNorm
31
+
32
+ # Patch embedding
33
+ self.patch_embed = PatchEmbed(
34
+ img_size=img_size,
35
+ patch_size=patch_size,
36
+ in_chans=in_chans,
37
+ embed_dim=embed_dim,
38
+ )
39
+ num_patches = self.patch_embed.num_patches
40
+
41
+ # Positional embedding (learnable)
42
+ self.pos_embed = nn.Parameter(
43
+ torch.zeros(1, num_patches, embed_dim)
44
+ )
45
+ self.pos_drop = nn.Dropout(p=drop_rate)
46
+
47
+ # Transformer blocks
48
+ self.blocks = nn.ModuleList([
49
+ TransformerBlock(
50
+ dim=embed_dim,
51
+ num_heads=num_heads,
52
+ mlp_ratio=mlp_ratio,
53
+ qkv_bias=qkv_bias,
54
+ drop=drop_rate,
55
+ attn_drop=attn_drop_rate,
56
+ )
57
+ for _ in range(depth)
58
+ ])
59
+
60
+ self.norm = norm_layer(embed_dim, eps=1e-6)
61
+
62
+ # Initialize weights
63
+ nn.init.trunc_normal_(self.pos_embed, std=0.02)
64
+ self.apply(self._init_weights)
65
+
66
+ def _init_weights(self, m):
67
+ if isinstance(m, nn.Linear):
68
+ nn.init.trunc_normal_(m.weight, std=0.02)
69
+ if m.bias is not None:
70
+ nn.init.constant_(m.bias, 0)
71
+ elif isinstance(m, nn.LayerNorm):
72
+ nn.init.constant_(m.bias, 0)
73
+ nn.init.constant_(m.weight, 1.0)
74
+
75
+ def forward(
76
+ self,
77
+ x: torch.Tensor,
78
+ return_all_tokens: bool = True,
79
+ patch_indices: Optional[torch.Tensor] = None,
80
+ ) -> torch.Tensor:
81
+ """
82
+ Args:
83
+ x: input images (B, C, H, W)
84
+ return_all_tokens: If true, return all patch tokens
85
+ patch_indices: If provided, return only specific patch indices
86
+
87
+ Returns:
88
+ Patch representations (B, N, D) or (B, len(indices), D)
89
+ """
90
+ # Patch embedding
91
+ x = self.patch_embed(x) # (B, N, D)
92
+
93
+ # Add positional embeddings
94
+ x = x + self.pos_embed
95
+ x = self.pos_drop(x)
96
+
97
+ # Apply transformer blocks
98
+ for block in self.blocks:
99
+ x = block(x)
100
+
101
+ x = self.norm(x)
102
+
103
+ # Return specific patches if indices provided
104
+ if patch_indices is not None:
105
+ x = x[:, patch_indices, :]
106
+
107
+ return x
108
+
109
+ def get_layer_representations(
110
+ self,
111
+ x: torch.Tensor,
112
+ strategy: str = "last",
113
+ specific_indices: Optional[list[int]] = None,
114
+ patch_indices: Optional[torch.Tensor] = None,
115
+ ) -> torch.Tensor:
116
+ """
117
+ Extract semantic representations using different layer strategies.
118
+
119
+ Args:
120
+ x: Input images (B, C, H, W)
121
+ strategy: Strategy to extract representations
122
+ - 'last': final layer only (baseline)
123
+ - 'second-last': 2nd-to-last block output
124
+ - 'last_four_concat': concat of last 4 layer (B, N, 4*D)
125
+ - 'specific': layers at specific_indices (eg: [25,27,29,31])
126
+ specific_indices: Block indices to use when strategy='specific'
127
+ patch_indices: If provided, return only these patch posistions
128
+
129
+ Returns:
130
+ (B, N, D) for "last"/"second_last", (B, N, 4*D) for concat strategies
131
+ """
132
+ x = self.patch_embed(x)
133
+ x = x + self.pos_embed
134
+ x = self.pos_drop(x)
135
+
136
+ n_blocks = len(self.blocks)
137
+
138
+ # Determnine which block indices to capture
139
+ if strategy == "second_last":
140
+ capture_at = {n_blocks - 2}
141
+ elif strategy == "last_four_concat":
142
+ capture_at = set(range(n_blocks - 4, n_blocks))
143
+ elif strategy == "specific":
144
+ assert specific_indices is not None, "Provide specific_indices when strategy='specific'"
145
+ capture_at = set(specific_indices)
146
+ else:
147
+ capture_at = {n_blocks - 1}
148
+
149
+ captured = {}
150
+ for i, block in enumerate(self.blocks):
151
+ x = block(x)
152
+ if i in capture_at:
153
+ captured[i] = x.clone()
154
+
155
+ # Apply norm and pool
156
+ if strategy in ("last_four_concat", "specific"):
157
+ # Sort by layer order, normalize each, then concat along D
158
+ layers = [self.norm(captured[i]) for i in sorted(captured)]
159
+ out = torch.cat(layers, dim=-1) # (B, N, num_layers * D)
160
+ elif strategy == "second_last":
161
+ out = self.norm(captured[n_blocks - 2])
162
+ else:
163
+ out = self.norm(x) # x is already the last block output
164
+
165
+ if patch_indices is not None:
166
+ out = out[:, patch_indices, :]
167
+
168
+ return out
src/models/multi_head_attention.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+
5
+ class MultiHeadAttention(nn.Module):
6
+ def __init__(
7
+ self,
8
+ dim: int,
9
+ num_heads: int = 8,
10
+ qkv_bias: bool = True,
11
+ attn_drop: float = 0.0,
12
+ proj_drop: float = 0.0,
13
+ ):
14
+ super().__init__()
15
+ self.num_heads = num_heads
16
+ head_dim = dim // num_heads
17
+ self.scale = head_dim ** -0.5
18
+
19
+ self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
20
+ self.attn_drop = nn.Dropout(attn_drop)
21
+ self.proj = nn.Linear(dim, dim)
22
+ self.proj_drop = nn.Dropout(proj_drop)
23
+
24
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
25
+ B, N, C = x.shape
26
+ qkv = self.qkv(x)
27
+ qkv = qkv.reshape(B, N, 3, self.num_heads, C // self.num_heads)
28
+ qkv = qkv.permute(2, 0, 3, 1, 4)
29
+ q, k, v = qkv[0], qkv[1], qkv[2]
30
+
31
+ attn = (q @ k.transpose(-2, -1))
32
+ attn = attn * self.scale
33
+ attn = attn.softmax(dim=-1)
34
+ attn = self.attn_drop(attn)
35
+
36
+ x = (attn @ v)
37
+ x = x.transpose(1, 2)
38
+ x = x.reshape(B, N, C)
39
+ x = self.proj(x)
40
+ x = self.proj_drop(x)
41
+ return x
src/models/multilayer_perceptron.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from typing import Optional
4
+
5
+
6
+ class MLP(nn.Module):
7
+ def __init__(
8
+ self,
9
+ in_features: int,
10
+ hidden_features: Optional[int] = None,
11
+ out_features: Optional[int] = None,
12
+ drop: float = 0.0,
13
+ ):
14
+ super().__init__()
15
+ out_features = out_features or in_features
16
+ hidden_features = hidden_features or in_features
17
+
18
+ self.fc1 = nn.Linear(in_features, hidden_features)
19
+ self.act = nn.GELU()
20
+ self.fc2 = nn.Linear(hidden_features, out_features)
21
+ self.drop = nn.Dropout(drop)
22
+
23
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
24
+ x = self.fc1(x)
25
+ x = self.act(x)
26
+ x = self.drop(x)
27
+ x = self.fc2(x)
28
+ x = self.drop(x)
29
+ return x
src/models/patch_embedding.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+
5
+ class PatchEmbed(nn.Module):
6
+ """Image to Patch Embedding"""
7
+ def __init__(
8
+ self,
9
+ img_size: int = 224,
10
+ patch_size: int = 14,
11
+ in_chans: int = 3,
12
+ embed_dim: int = 768,
13
+ ):
14
+ super().__init__()
15
+ self.img_size = img_size
16
+ self.patch_size = patch_size
17
+ self.grid_size = img_size // patch_size
18
+ self.num_patches = self.grid_size ** 2
19
+
20
+ self.proj = nn.Conv2d(
21
+ in_chans,
22
+ embed_dim,
23
+ kernel_size=patch_size,
24
+ stride=patch_size
25
+ )
26
+
27
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
28
+ x = self.proj(x)
29
+ x = x.flatten(2)
30
+ x = x.transpose(1, 2)
31
+ return x
src/models/transformer_block.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from .multi_head_attention import MultiHeadAttention
4
+ from .multilayer_perceptron import MLP
5
+
6
+
7
+ class TransformerBlock(nn.Module):
8
+ def __init__(
9
+ self,
10
+ dim: int,
11
+ num_heads: int,
12
+ mlp_ratio: float = 4.0,
13
+ qkv_bias: bool = True,
14
+ drop: float = 0.0,
15
+ attn_drop: float = 0.0,
16
+ ):
17
+ super().__init__()
18
+ self.norm1 = nn.LayerNorm(dim, eps=1e-6)
19
+ self.attn = MultiHeadAttention(
20
+ dim, num_heads=num_heads, qkv_bias=qkv_bias,
21
+ attn_drop=attn_drop, proj_drop=drop
22
+ )
23
+ self.norm2 = nn.LayerNorm(dim, eps=1e-6)
24
+ mlp_hidden_dim = int(dim * mlp_ratio)
25
+ self.mlp = MLP(
26
+ in_features=dim,
27
+ hidden_features=mlp_hidden_dim,
28
+ drop=drop
29
+ )
30
+
31
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
32
+ x = x + self.attn(self.norm1(x))
33
+ x = x + self.mlp(self.norm2(x))
34
+ return x
src/processing/__init__.py ADDED
File without changes
src/processing/embedder.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tqdm
2
+ import torch
3
+ import numpy as np
4
+ import torch.nn as nn
5
+ from PIL import Image
6
+ from pathlib import Path
7
+ from torchvision import transforms
8
+ from typing import List, Union, Tuple, Optional
9
+ from torch.utils.data import DataLoader, Dataset
10
+
11
+
12
+ class ImageEmbeddingDataset(Dataset):
13
+ """Dataset for batch image embedding generation"""
14
+ def __init__(
15
+ self,
16
+ image_paths: List[Union[str, Path]],
17
+ transform=None
18
+ ):
19
+ self.image_paths = [Path(p) for p in image_paths]
20
+ self.transform = transform or self.default_transform()
21
+
22
+ @staticmethod
23
+ def default_transform():
24
+ # I-JEPA uses mean=05 and std=0.5 normalization
25
+ return transforms.Compose([
26
+ transforms.Resize(
27
+ 224, interpolation=transforms.InterpolationMode.BICUBIC
28
+ ),
29
+ transforms.CenterCrop(224),
30
+ transforms.ToTensor(),
31
+ transforms.Normalize(
32
+ mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]
33
+ )
34
+ ])
35
+
36
+ def __len__(self):
37
+ return len(self.image_paths)
38
+
39
+ def __getitem__(self, idx):
40
+ img_path = self.image_paths[idx]
41
+ image = Image.open(img_path).convert('RGB')
42
+ image = self.transform(image)
43
+ return image, str(img_path)
44
+
45
+
46
+ class EmbeddingGenerator:
47
+ """Generate embeddings for image database using batch inference."""
48
+ def __init__(
49
+ self,
50
+ model: nn.Module,
51
+ device: str = "cuda" if torch.cuda.is_available() else "cpu",
52
+ batch_size: int = 4,
53
+ num_workers: int = 1,
54
+ layer_strategy: str = "second_last",
55
+ specific_indices: Optional[List[int]] = None,
56
+ ):
57
+ self.model = model.to(device).eval()
58
+ self.device = device
59
+ self.batch_size = batch_size
60
+ self.num_workers = num_workers
61
+ self.layer_strategy = layer_strategy
62
+ self.specific_indices = specific_indices
63
+
64
+ # Freeze model
65
+ for param in self.model.parameters():
66
+ param.requires_grad = False
67
+
68
+ def _get_features(self, images: torch.Tensor) -> torch.Tensor:
69
+ """Central routing method - all forward calss go through here."""
70
+ if self.layer_strategy == "last":
71
+ return self.model(images)
72
+ return self.model.get_layer_representations(
73
+ images,
74
+ strategy=self.layer_strategy,
75
+ specific_indices=self.specific_indices,
76
+ )
77
+
78
+ @torch.no_grad()
79
+ def generate_embeddings(
80
+ self,
81
+ image_paths: List[Union[str, Path]],
82
+ return_paths: bool = True,
83
+ show_progress: bool = True,
84
+ ) -> Union[np.ndarray, Tuple[np.ndarray, List[str]]]:
85
+ """
86
+ Generate embeddings for all images.
87
+
88
+ Returns:
89
+ embeddings: (N, D) array of embeddings
90
+ paths: (optional) list of image paths
91
+ """
92
+ print(" 3.1 Image Embedding Dataset...")
93
+ dataset = ImageEmbeddingDataset(image_paths)
94
+ print(" 3.2 DataLoader...")
95
+ dataloader = DataLoader(
96
+ dataset,
97
+ batch_size=self.batch_size,
98
+ shuffle=False,
99
+ num_workers=self.num_workers,
100
+ pin_memory=True,
101
+ )
102
+
103
+ all_embeddings = []
104
+ all_paths = []
105
+
106
+ print(" 3.3 tqdm iterator...\n")
107
+ iterator = tqdm.tqdm(dataloader, desc="Generating embeddings") if show_progress else dataloader
108
+
109
+ print(" 3.4 for loop...")
110
+ for batch_images, batch_paths in iterator:
111
+ batch_images = batch_images.to(self.device, non_blocking=True)
112
+
113
+ # Get embeddings: average pool patch tokens for global representation
114
+ features = self._get_features(batch_images) # (B, N, D)
115
+ embeddings = features.mean(dim=1) # (B, D)
116
+
117
+ # L2 normalization for cosine similarity
118
+ embeddings = nn.functional.normalize(embeddings, p=2, dim=1)
119
+
120
+ all_embeddings.append(embeddings.cpu().numpy())
121
+ all_paths.extend(batch_paths)
122
+
123
+ embeddings = np.vstack(all_embeddings)
124
+
125
+ if return_paths:
126
+ return embeddings, all_paths
127
+ return embeddings
128
+
129
+ def generate_single_embedding(
130
+ self, image: Union[str, Path, Image.Image, torch.Tensor]
131
+ ) -> np.ndarray:
132
+ """Generate embedding for a single image"""
133
+ transform = ImageEmbeddingDataset.default_transform()
134
+
135
+ if isinstance(image, (str, Path)):
136
+ image = Image.open(image).convert('RGB')
137
+
138
+ if isinstance(image, Image.Image):
139
+ image = transform(image)
140
+
141
+ if isinstance(image, torch.Tensor):
142
+ image = image.unsqueeze(0) if image.dim() == 3 else image
143
+
144
+ image = image.to(self.device)
145
+
146
+ with torch.no_grad():
147
+ features = self._get_features(image)
148
+ embedding = features.mean(dim=1)
149
+ embedding = nn.functional.normalize(embedding, p=2, dim=1)
150
+
151
+ return embedding.cpu().numpy()
src/search/__init__.py ADDED
File without changes
src/search/pinecone_indexer.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import numpy as np
3
+ from pathlib import Path
4
+ from typing import List, Union, Tuple, Optional
5
+
6
+
7
+ class PineconeImageSimilaritySearch:
8
+ """
9
+ Pinecone-backend similarity search for image embeddings. Image paths are stored as
10
+ Pinecone vector metadata so no local state is needed between sessions.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ index_name: str,
16
+ api_key: str,
17
+ cloud: str = "aws",
18
+ region: str = "us-east-1",
19
+ dimension: int = 768,
20
+ namespace: str = "",
21
+ metric: str = "cosine",
22
+ create_if_missing: bool = True,
23
+ ):
24
+ try:
25
+ from pinecone import Pinecone, ServerlessSpec
26
+ except ImportError:
27
+ raise ImportError("Run `pip install pinecone` first.")
28
+
29
+ self.dimension = dimension
30
+ self.index_type = metric
31
+ self.namespace = namespace
32
+ self._index_name = index_name
33
+ self.image_paths: List[str] = []
34
+ self.metadata: dict = {}
35
+
36
+ pc = Pinecone(api_key=api_key)
37
+
38
+ existing = [idx.name for idx in pc.list_indexes()]
39
+ if index_name not in existing:
40
+ if not create_if_missing:
41
+ raise ValueError(
42
+ f"Index '{index_name}' not found and create_if_missing=False."
43
+ )
44
+ pc.create_index(
45
+ name=index_name,
46
+ dimension=dimension,
47
+ metric=metric,
48
+ spec=ServerlessSpec(cloud=cloud, region=region),
49
+ )
50
+ print(f"Created Pinecone index '{index_name}' ({metric}, dim={dimension})")
51
+ else:
52
+ print(f"Connected to existing Pinecone index '{index_name}'")
53
+
54
+ self.index = pc.Index(index_name)
55
+
56
+ def add_embeddings(
57
+ self,
58
+ embeddings: np.ndarray,
59
+ image_paths: List[str],
60
+ metadata: Optional[dict] = None,
61
+ ):
62
+ """
63
+ Upsert embeddings into Pinecone.
64
+
65
+ Args:
66
+ embeddings: (N, D) float32 array for L2-normalised embeddings
67
+ image_paths: List of N image paths (stored as Pinecone metadata)
68
+ metadata: Optional {int_index: dict} of extra per-image metadata
69
+ """
70
+ assert len(embeddings) == len(image_paths), "Embeddings and paths must match"
71
+ assert embeddings.shape[1] == self.dimension, (
72
+ f"Expected dim {self.dimension}, got {embeddings.shape[1]}"
73
+ )
74
+
75
+ embeddings = embeddings.astype("float32")
76
+
77
+ vectors = []
78
+ for i, (emb, path) in enumerate(zip(embeddings, image_paths)):
79
+ vec_id = str(uuid.uuid4())
80
+ meta = {"image_path": path}
81
+ if metadata and i in metadata:
82
+ meta.update(metadata[i])
83
+ vectors.append({"id": vec_id, "values": emb.tolist(), "metadata": meta})
84
+
85
+ # Pinecone recommends batches of <= 100
86
+ batch_size = 100
87
+ for start in range(0, len(vectors), batch_size):
88
+ self.index.upsert(
89
+ vectors=vectors[start: start + batch_size],
90
+ namespace=self.namespace,
91
+ )
92
+
93
+ self.image_paths.extend(image_paths)
94
+ if metadata:
95
+ for i, path in enumerate(image_paths):
96
+ if i in metadata:
97
+ self.metadata[path] = metadata[i]
98
+
99
+ print(f"Upserted {len(embeddings)} vectors. "
100
+ f"Total (local cache): {len(self.image_paths)}")
101
+
102
+ def search(
103
+ self,
104
+ query_embedding: np.ndarray,
105
+ k: int = 5,
106
+ return_scores: bool = True,
107
+ filter: Optional[dict] = None,
108
+ ) -> Union[List[str], List[Tuple[str, float]]]:
109
+ """
110
+ Search for the k most similar images.
111
+
112
+ Args:
113
+ query_embedding: (1, D) or (D,) float32 array
114
+ k: Number of results
115
+ return_scores: If True, return (path, score) tuples
116
+ filter: Optional Pinecone metadata filter dict
117
+ """
118
+ if query_embedding.ndim == 1:
119
+ query_embedding = query_embedding.reshape(1, -1)
120
+
121
+ query_list = query_embedding[0].astype("float32").tolist()
122
+
123
+ kwargs = dict(
124
+ vector=query_list,
125
+ top_k=k,
126
+ include_metadata=True,
127
+ namespace=self.namespace,
128
+ )
129
+ if filter:
130
+ kwargs["filter"] = filter
131
+
132
+ response = self.index.query(**kwargs)
133
+
134
+ results = []
135
+ for match in response.matches:
136
+ path = match.metadata.get("image_path", match.id)
137
+ if return_scores:
138
+ results.append((path, float(match.score)))
139
+ else:
140
+ results.append(path)
141
+
142
+ return results
143
+
144
+ def batch_search(
145
+ self,
146
+ query_embeddings: np.ndarray,
147
+ k: int = 5,
148
+ filter: Optional[dict] = None,
149
+ ) -> List[List[Tuple[str, float]]]:
150
+ """Search for multiple query embeddings sequentially."""
151
+ return [
152
+ self.search(q, k=k, return_scores=True, filter=filter)
153
+ for q in query_embeddings
154
+ ]
155
+
156
+ def save(self, save_dir: Union[str, Path]):
157
+ """
158
+ Pinecone vectors are already persisted server-side.
159
+ This optionally saves the local image_path cache to disk so we don't
160
+ have to re-scane the index on startup.
161
+ """
162
+ import pickle
163
+
164
+ save_dir = Path(save_dir)
165
+ save_dir.mkdir(parents=True, exist_ok=True)
166
+ local_state = {
167
+ "image_paths": self.image_paths,
168
+ "metadata": self.metadata,
169
+ "dimension": self.dimension,
170
+ "namespace": self.namespace,
171
+ "index_name": self._index_name,
172
+ "metric": self.index_type,
173
+ }
174
+ with open(save_dir / "pinecone_local_cache.pkl", "wb") as f:
175
+ pickle.dump(local_state, f)
176
+ print(f"Saved local state to: {save_dir}")
177
+
178
+ @classmethod
179
+ def load(
180
+ cls, save_dir: Union[str, Path], api_key: str, use_gpu: bool = False
181
+ ) -> "PineconeImageSimilaritySearch":
182
+ """
183
+ Load local state and return a new PineconeImageSimilaritySearch instance.
184
+ """
185
+ import pickle
186
+
187
+ save_dir = Path(save_dir)
188
+ with open(save_dir / "pinecone_local_cache.pkl", "rb") as f:
189
+ local_state = pickle.load(f)
190
+
191
+ instance = cls(
192
+ index_name=local_state["index_name"],
193
+ api_key=api_key,
194
+ dimension=local_state["dimension"],
195
+ namespace=local_state["namespace"],
196
+ metric=local_state["index_type"],
197
+ create_if_mission=False,
198
+ )
199
+ instance.image_paths = local_state["image_paths"]
200
+ instance.metadata = local_state["metadata"]
201
+ return instance
utils/__init__.py ADDED
File without changes
utils/model_loading_util.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import torch
3
+ from dataclasses import dataclass
4
+ from huggingface_hub import hf_hub_download
5
+ from src.models.ijepa import IJEPATargetEncoder
6
+
7
+
8
+ @dataclass
9
+ class ViTConfig:
10
+ img_size: int = 224
11
+ in_chans: int = 3
12
+ patch_size: int = 14
13
+ embed_dim: int = 1280
14
+ depth: int = 32
15
+ num_heads: int = 16
16
+ mlp_ratio: float = 4.0
17
+
18
+
19
+ def load_model_from_hf(
20
+ repo_id: str,
21
+ device: str = "cuda",
22
+ token: str = None
23
+ ):
24
+ """
25
+ Downloads and loads the I-JEPA model from a Hugging Face Model Repository.
26
+ """
27
+ print(f"Fetching model files from {repo_id}...")
28
+
29
+ # 1. Download Config
30
+ config_path = hf_hub_download(
31
+ repo_id=repo_id,
32
+ filename="config.json",
33
+ token=token
34
+ )
35
+
36
+ # 2. Download Weights
37
+ weights_path = hf_hub_download(
38
+ repo_id=repo_id,
39
+ filename="model_weights.pth",
40
+ token=token
41
+ )
42
+
43
+ # 3. Initialize Architecture from downloaded config
44
+ with open(config_path, 'r') as f:
45
+ config_dict = json.load(f)
46
+ config = ViTConfig(**config_dict)
47
+
48
+ model = IJEPATargetEncoder(
49
+ img_size=config.img_size,
50
+ patch_size=config.patch_size,
51
+ embed_dim=config.embed_dim,
52
+ depth=config.depth,
53
+ num_heads=config.num_heads,
54
+ mlp_ratio=config.mlp_ratio
55
+ )
56
+
57
+ # 4. Load Weights
58
+ print("Loading state dict...")
59
+ state_dict = torch.load(weights_path, map_location='cpu')
60
+ model.load_state_dict(state_dict)
61
+
62
+ model = model.to(device).eval()
63
+ print("Model successfully loaded from Hugging Face.")
64
+
65
+ return model