Mithil-AI commited on
Commit
653566e
·
verified ·
1 Parent(s): 12b0c79

Remove duplicated library code; the canonical copy is on PyPI

Browse files
README.md CHANGED
@@ -117,8 +117,12 @@ model = QuadEmbed.from_pretrained(device="cpu") # force CPU
117
  ```
118
 
119
  Audio takes mono float32 arrays at 16 kHz; video takes a file path
120
- (`model.embed_video_file("clip.mp4")`). See
121
- `examples/inference_example.py` for a complete runnable script.
 
 
 
 
122
 
123
  ## Important caveat: two different vision checkpoints
124
 
 
117
  ```
118
 
119
  Audio takes mono float32 arrays at 16 kHz; video takes a file path
120
+ (`model.embed_video_file("clip.mp4")`). A complete runnable example lives in
121
+ [the source repo](https://github.com/mithilai/QuadEmbed/blob/main/package/examples/inference_example.py).
122
+
123
+ This repo hosts the trained weights only; the library itself is published on
124
+ [PyPI](https://pypi.org/project/quadembed/) so there is one canonical copy of
125
+ the code rather than a duplicate here that can drift out of sync.
126
 
127
  ## Important caveat: two different vision checkpoints
128
 
examples/inference_example.py DELETED
@@ -1,26 +0,0 @@
1
- """Embed text, an image, and a video into the same 768-dim space and compare
2
- with cosine similarity (embeddings are already L2-normalized, so a plain dot
3
- product is cosine similarity).
4
-
5
- Run from the repo root:
6
- pip install -r requirements.txt
7
- python examples/inference_example.py
8
- """
9
- from PIL import Image
10
-
11
- from quadembed import QuadEmbed
12
-
13
- model = QuadEmbed.from_pretrained("checkpoints") # device auto-detects cuda/cpu
14
-
15
- texts = ["a photo of a dog running on the beach", "a bowl of ramen noodles"]
16
- text_embeds = model.embed_text(texts)
17
-
18
- image = Image.open("your_image.jpg").convert("RGB") # replace with a real path
19
- image_embeds = model.embed_image([image])
20
-
21
- similarity = text_embeds @ image_embeds.T
22
- for text, score in zip(texts, similarity[:, 0].tolist()):
23
- print(f"{score:.3f} {text}")
24
-
25
- # video_embeds = model.embed_video_file("your_clip.mp4") # uses the video-tuned projector, see README
26
- # audio_embeds = model.embed_audio([your_16khz_float32_array])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
quadembed/__init__.py DELETED
@@ -1,84 +0,0 @@
1
- """QuadEmbed: a local, from-scratch-trained multimodal embedding model
2
- covering all four modalities, text, image, audio, and video, in one shared
3
- 768-dim space. Reproduces the architecture behind Jina AI's GELATO
4
- (jina-embeddings-v5-omni): frozen text/vision/audio encoders plus two small
5
- trained projectors.
6
-
7
- from quadembed import QuadEmbed
8
-
9
- model = QuadEmbed.from_pretrained("checkpoints", device="cuda")
10
- text_embeds = model.embed_text(["a dog running on the beach"])
11
- image_embeds = model.embed_image([Image.open("photo.jpg")])
12
- similarity = text_embeds @ image_embeds.T
13
- """
14
- import os
15
-
16
- import torch
17
- import torch.nn.functional as F
18
-
19
- from .encoders import AudioEncoder, TextEncoder, VisionEncoder
20
- from .projectors import AudioProjector, VisionProjector
21
- from .video import embed_video, sample_frames
22
-
23
- __all__ = [
24
- "QuadEmbed",
25
- "TextEncoder",
26
- "VisionEncoder",
27
- "AudioEncoder",
28
- "VisionProjector",
29
- "AudioProjector",
30
- "embed_video",
31
- "sample_frames",
32
- ]
33
-
34
-
35
- class QuadEmbed:
36
- """Loads all three frozen encoders plus the trained projectors, and
37
- exposes one `embed_*` method per modality, each returning an L2-normalized
38
- [batch, 768] tensor in the shared space -- so any two outputs from any
39
- two modalities can be compared with a plain dot product / cosine
40
- similarity."""
41
-
42
- def __init__(self, device: str = "cuda" if torch.cuda.is_available() else "cpu"):
43
- self.device = device
44
- self.text_encoder = TextEncoder(device)
45
- self.vision_encoder = VisionEncoder(device)
46
- self.audio_encoder = AudioEncoder(device)
47
- self.vision_projector = VisionProjector(patch_dim=768, out_dim=768).to(device).eval()
48
- self.audio_projector = AudioProjector(in_dim=1280, out_dim=768).to(device).eval()
49
- self.video_projector = VisionProjector(patch_dim=768, out_dim=768).to(device).eval()
50
-
51
- @classmethod
52
- def from_pretrained(cls, checkpoint_dir: str, device: str = "cuda" if torch.cuda.is_available() else "cpu"):
53
- model = cls(device=device)
54
- model.vision_projector.load_state_dict(
55
- torch.load(os.path.join(checkpoint_dir, "vision_projector.pt"), map_location=device)
56
- )
57
- model.audio_projector.load_state_dict(
58
- torch.load(os.path.join(checkpoint_dir, "audio_projector.pt"), map_location=device)
59
- )
60
- model.video_projector.load_state_dict(
61
- torch.load(os.path.join(checkpoint_dir, "video_projector.pt"), map_location=device)
62
- )
63
- return model
64
-
65
- @torch.no_grad()
66
- def embed_text(self, texts: list[str]) -> torch.Tensor:
67
- return F.normalize(self.text_encoder.embed(texts).float(), dim=-1)
68
-
69
- @torch.no_grad()
70
- def embed_image(self, images: list) -> torch.Tensor:
71
- patch_tokens, mask, spatial_shapes = self.vision_encoder.patch_tokens(images)
72
- out = self.vision_projector(patch_tokens.float(), mask, spatial_shapes)
73
- return F.normalize(out, dim=-1)
74
-
75
- @torch.no_grad()
76
- def embed_audio(self, arrays: list, sampling_rate: int = 16000) -> torch.Tensor:
77
- frame_tokens = self.audio_encoder.frame_tokens(arrays, sampling_rate=sampling_rate)
78
- out = self.audio_projector(frame_tokens.float())
79
- return F.normalize(out, dim=-1)
80
-
81
- @torch.no_grad()
82
- def embed_video_file(self, video_path: str, num_frames: int = 4) -> torch.Tensor:
83
- out = embed_video(self.vision_encoder, self.video_projector, video_path, self.device, num_frames=num_frames)
84
- return F.normalize(out.unsqueeze(0), dim=-1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
quadembed/encoders.py DELETED
@@ -1,103 +0,0 @@
1
- """Frozen encoder loaders.
2
-
3
- Substitutions vs. the GELATO paper's exact checkpoints, since the paper's
4
- adapted encoders (Qwen3.5's vision tower, Qwen2.5-Omni's audio tower) aren't
5
- distributed as standalone checkpoints, and the paper itself says they were
6
- *adapted from* these two source models -- so we use the source models
7
- directly, which the paper's own dimension numbers (1280 audio, patch-merge
8
- producing 3072 for nano) line up with:
9
- text : jinaai/jina-embeddings-v5-text-nano (exact match, publicly released)
10
- vision: google/siglip2-base-patch16-naflex (source SigLIP2 GELATO adapted)
11
- audio : openai/whisper-large-v3 encoder (source Whisper GELATO adapted)
12
- """
13
- import torch
14
- import torch.nn as nn
15
- from transformers import (
16
- AutoModel,
17
- AutoTokenizer,
18
- AutoImageProcessor,
19
- WhisperModel,
20
- WhisperFeatureExtractor,
21
- )
22
-
23
- try:
24
- from transformers import Siglip2VisionModel as _VisionModelClass
25
- except ImportError:
26
- from transformers import AutoModel as _VisionModelClass
27
-
28
- TEXT_MODEL_ID = "jinaai/jina-embeddings-v5-text-nano"
29
- VISION_MODEL_ID = "google/siglip2-base-patch16-naflex"
30
- AUDIO_MODEL_ID = "openai/whisper-large-v3"
31
-
32
-
33
- def _freeze(module: nn.Module) -> nn.Module:
34
- module.eval()
35
- for p in module.parameters():
36
- p.requires_grad_(False)
37
- return module
38
-
39
-
40
- def mean_pool(last_hidden_state: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
41
- mask = attention_mask.unsqueeze(-1).to(last_hidden_state.dtype)
42
- summed = (last_hidden_state * mask).sum(dim=1)
43
- counts = mask.sum(dim=1).clamp(min=1e-9)
44
- return summed / counts
45
-
46
-
47
- class TextEncoder:
48
- def __init__(self, device: str, dtype=torch.bfloat16, max_length: int = 512):
49
- self.tokenizer = AutoTokenizer.from_pretrained(TEXT_MODEL_ID, trust_remote_code=True)
50
- self.model = _freeze(
51
- AutoModel.from_pretrained(TEXT_MODEL_ID, trust_remote_code=True, dtype=dtype).to(device)
52
- )
53
- self.device = device
54
- self.max_length = max_length
55
-
56
- @torch.no_grad()
57
- def embed(self, texts: list[str]) -> torch.Tensor:
58
- inputs = self.tokenizer(
59
- texts, padding=True, truncation=True, max_length=self.max_length, return_tensors="pt"
60
- ).to(self.device)
61
- out = self.model(**inputs)
62
- return mean_pool(out.last_hidden_state, inputs["attention_mask"])
63
-
64
-
65
- class VisionEncoder:
66
- def __init__(self, device: str, dtype=torch.bfloat16):
67
- self.processor = AutoImageProcessor.from_pretrained(VISION_MODEL_ID)
68
- self.model = _freeze(_VisionModelClass.from_pretrained(VISION_MODEL_ID, dtype=dtype).to(device))
69
- self.device = device
70
-
71
- @torch.no_grad()
72
- def patch_tokens(self, images: list) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None]:
73
- """Returns (patch_tokens [B,N,D], attention_mask [B,N] or None,
74
- spatial_shapes [B,2] or None -- per-image (H_patches, W_patches)
75
- before NaFlex pads every image in the batch to the same N, needed
76
- for a real spatial 2x2 merge instead of grouping arbitrary tokens."""
77
- inputs = self.processor(images=images, return_tensors="pt").to(self.device)
78
- vision_module = getattr(self.model, "vision_model", self.model)
79
- out = vision_module(**inputs)
80
- mask = inputs.get("pixel_attention_mask")
81
- spatial_shapes = inputs.get("spatial_shapes")
82
- return out.last_hidden_state, mask, spatial_shapes
83
-
84
-
85
- class AudioEncoder:
86
- def __init__(self, device: str, dtype=torch.bfloat16):
87
- self.feature_extractor = WhisperFeatureExtractor.from_pretrained(AUDIO_MODEL_ID)
88
- full = WhisperModel.from_pretrained(AUDIO_MODEL_ID, dtype=dtype)
89
- self.model = full.encoder
90
- del full.decoder
91
- del full
92
- self.model = _freeze(self.model.to(device))
93
- self.device = device
94
- self.dtype = dtype
95
-
96
- @torch.no_grad()
97
- def frame_tokens(self, audio_arrays: list, sampling_rate: int = 16000) -> torch.Tensor:
98
- inputs = self.feature_extractor(
99
- audio_arrays, sampling_rate=sampling_rate, return_tensors="pt"
100
- ).to(self.device)
101
- features = inputs["input_features"].to(self.dtype)
102
- out = self.model(features)
103
- return out.last_hidden_state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
quadembed/projectors.py DELETED
@@ -1,111 +0,0 @@
1
- """Trainable projectors that map frozen vision/audio encoder outputs into the
2
- frozen text encoder's embedding space -- the only parameters GELATO trains.
3
-
4
- Everything upstream (SigLIP2, Whisper encoder, jina-v5-text-nano) stays frozen;
5
- only VisionProjector.fc_vision_2 and AudioProjector.fc_audio get gradients.
6
- """
7
- import torch
8
- import torch.nn as nn
9
-
10
-
11
- class VisionProjector(nn.Module):
12
- """LayerNorm -> real 2x2 spatial merge -> trainable linear, matching
13
- GELATO's "LayerNorm, 2x2 spatial merge, fc_vision_2" description
14
- (nano: 3072->768).
15
-
16
- Uses NaFlex's per-image `spatial_shapes` (H_patches, W_patches) to group
17
- each image's own patch grid into genuine 2x2 spatial blocks before
18
- concatenating channel-wise -- earlier versions approximated this with
19
- sequential 4-token grouping (mixing patches from unrelated rows), which
20
- is architecturally wrong and is kept here only as a fallback for
21
- callers that don't have spatial_shapes available.
22
- """
23
-
24
- def __init__(self, patch_dim: int, out_dim: int, merge: int = 4, hidden_dim: int | None = None):
25
- super().__init__()
26
- self.merge = merge
27
- self.norm = nn.LayerNorm(patch_dim, elementwise_affine=False)
28
- if hidden_dim:
29
- # not part of GELATO's own recipe (which uses a single linear
30
- # fc_vision_2) -- an experiment to test whether a linear
31
- # projector's capacity, not data, is capping R@1. See README's
32
- # "round 6" for the result.
33
- self.fc_vision_2 = nn.Sequential(
34
- nn.Linear(patch_dim * merge, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, out_dim)
35
- )
36
- else:
37
- self.fc_vision_2 = nn.Linear(patch_dim * merge, out_dim)
38
- self.modality_delim = nn.Parameter(torch.zeros(out_dim))
39
-
40
- def _spatial_merge_one(self, tokens: torch.Tensor, h: int, w: int) -> torch.Tensor:
41
- # tokens: [N, D] for one image, first h*w entries are the valid grid
42
- d = tokens.shape[-1]
43
- h2, w2 = h - (h % 2), w - (w % 2) # drop a trailing odd row/col rather than fabricate one
44
- grid = tokens[: h * w].view(h, w, d)[:h2, :w2]
45
- blocks = grid.reshape(h2 // 2, 2, w2 // 2, 2, d).permute(0, 2, 1, 3, 4).reshape(-1, 4 * d)
46
- return blocks
47
-
48
- def forward(
49
- self,
50
- patch_tokens: torch.Tensor,
51
- attention_mask: torch.Tensor | None = None,
52
- spatial_shapes: torch.Tensor | None = None,
53
- ) -> torch.Tensor:
54
- # patch_tokens: [B, N, patch_dim]
55
- b, n, d = patch_tokens.shape
56
- patch_tokens = self.norm(patch_tokens)
57
-
58
- if spatial_shapes is not None:
59
- pooled = []
60
- for i in range(b):
61
- h, w = int(spatial_shapes[i, 0]), int(spatial_shapes[i, 1])
62
- blocks = self._spatial_merge_one(patch_tokens[i], h, w)
63
- projected = self.fc_vision_2(blocks) # [num_blocks, out_dim]
64
- pooled.append(projected.mean(dim=0))
65
- pooled = torch.stack(pooled, dim=0)
66
- else:
67
- pad = (-n) % self.merge
68
- pt = patch_tokens
69
- if pad:
70
- pt = nn.functional.pad(pt, (0, 0, 0, pad))
71
- if attention_mask is not None:
72
- attention_mask = nn.functional.pad(attention_mask, (0, pad))
73
- merged = pt.reshape(b, pt.shape[1] // self.merge, d * self.merge)
74
- projected = self.fc_vision_2(merged) # [B, N/merge, out_dim]
75
- if attention_mask is not None:
76
- merged_mask = attention_mask.reshape(b, -1, self.merge).amax(dim=-1).to(projected.dtype)
77
- weights = merged_mask.unsqueeze(-1)
78
- pooled = (projected * weights).sum(dim=1) / weights.sum(dim=1).clamp(min=1e-6)
79
- else:
80
- pooled = projected.mean(dim=1)
81
-
82
- return pooled + self.modality_delim
83
-
84
-
85
- class AudioProjector(nn.Module):
86
- """Trainable linear (1280->768 for nano), matching GELATO's fc_audio."""
87
-
88
- def __init__(self, in_dim: int, out_dim: int):
89
- super().__init__()
90
- self.fc_audio = nn.Linear(in_dim, out_dim)
91
- self.modality_delim = nn.Parameter(torch.zeros(out_dim))
92
-
93
- def forward(self, frame_tokens: torch.Tensor, attention_mask: torch.Tensor | None = None) -> torch.Tensor:
94
- # frame_tokens: [B, T, in_dim]
95
- projected = self.fc_audio(frame_tokens)
96
- if attention_mask is not None:
97
- weights = attention_mask.unsqueeze(-1).to(projected.dtype)
98
- pooled = (projected * weights).sum(dim=1) / weights.sum(dim=1).clamp(min=1e-6)
99
- else:
100
- pooled = projected.mean(dim=1)
101
- return pooled + self.modality_delim
102
-
103
-
104
- def embed_video_from_frames(vision_projector: VisionProjector, per_frame_patch_tokens: list[torch.Tensor]) -> torch.Tensor:
105
- """Video = mean of per-frame vision-projector embeddings.
106
-
107
- No dedicated video encoder, matching GELATO: sample frames, run each
108
- through the (shared, already-trained) vision projector, pool over time.
109
- """
110
- frame_embeds = torch.stack([vision_projector(tokens) for tokens in per_frame_patch_tokens], dim=1) # [B, F, out_dim]
111
- return frame_embeds.mean(dim=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
quadembed/video.py DELETED
@@ -1,36 +0,0 @@
1
- """Frame sampling + video embedding. No video encoder exists here -- a video
2
- is just N sampled frames run through the already-trained VisionProjector and
3
- mean-pooled, exactly as GELATO does."""
4
- import cv2
5
- import numpy as np
6
- import torch
7
- from PIL import Image
8
-
9
-
10
- def sample_frames(video_path: str, num_frames: int = 4) -> list:
11
- cap = cv2.VideoCapture(video_path)
12
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
13
- if total <= 0:
14
- cap.release()
15
- raise ValueError(f"no frames read from {video_path}")
16
- indices = np.linspace(0, total - 1, num=min(num_frames, total), dtype=int)
17
- frames = []
18
- for idx in indices:
19
- cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
20
- ok, frame_bgr = cap.read()
21
- if not ok:
22
- continue
23
- frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
24
- frames.append(Image.fromarray(frame_rgb))
25
- cap.release()
26
- if not frames:
27
- raise ValueError(f"no frames decoded from {video_path}")
28
- return frames
29
-
30
-
31
- @torch.no_grad()
32
- def embed_video(vision_encoder, vision_projector, video_path: str, device: str, num_frames: int = 4) -> torch.Tensor:
33
- frames = sample_frames(video_path, num_frames=num_frames)
34
- patch_tokens, mask, spatial_shapes = vision_encoder.patch_tokens(frames) # [num_frames, N, 768]
35
- frame_embeds = vision_projector(patch_tokens.float(), mask, spatial_shapes) # [num_frames, 768]
36
- return frame_embeds.mean(dim=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,7 +0,0 @@
1
- torch>=2.1
2
- transformers>=4.45
3
- pillow
4
- opencv-python
5
- soundfile
6
- librosa
7
- numpy