Remove duplicated library code; the canonical copy is on PyPI
Browse files- README.md +6 -2
- examples/inference_example.py +0 -26
- quadembed/__init__.py +0 -84
- quadembed/encoders.py +0 -103
- quadembed/projectors.py +0 -111
- quadembed/video.py +0 -36
- requirements.txt +0 -7
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")`).
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|