Mithil Maske commited on
Commit
0af30f5
·
verified ·
1 Parent(s): f75d4eb

Initial upload: gelato-local-nano (frozen encoders + trained projectors)

Browse files
README.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ tags:
4
+ - multimodal
5
+ - embeddings
6
+ - text-to-image
7
+ - image-text-matching
8
+ - audio
9
+ - video
10
+ - retrieval
11
+ - gelato
12
+ - jina-embeddings
13
+ library_name: pytorch
14
+ ---
15
+
16
+ # gelato-local-nano
17
+
18
+ A local, from-scratch-trained reproduction of **GELATO** (Geometry-preserving
19
+ Embeddings via Locked Aligned TOwers), the architecture behind
20
+ [jina-embeddings-v5-omni](https://arxiv.org/abs/2605.08384). It maps text,
21
+ images, audio, and video into one shared 768-dimensional embedding space,
22
+ trained end-to-end on a single 8GB consumer GPU (RTX 4060 laptop).
23
+
24
+ This is not a copy of Jina's released checkpoint — the three encoders below
25
+ are frozen, publicly available source models, and only two small projector
26
+ heads (a few million parameters total) were trained from scratch on public
27
+ datasets to align them into a shared space.
28
+
29
+ ## Architecture
30
+
31
+ Three frozen encoders, two trained projectors — everything large stays
32
+ untouched; only the projectors and two small delimiter vectors have
33
+ gradients:
34
+
35
+ | Role | Model (frozen) | Params | Role in this repo |
36
+ |---|---|---|---|
37
+ | Text (anchor) | [`jinaai/jina-embeddings-v5-text-nano`](https://huggingface.co/jinaai/jina-embeddings-v5-text-nano) | 239M | Defines the target embedding space; never modified |
38
+ | Vision | [`google/siglip2-base-patch16-naflex`](https://huggingface.co/google/siglip2-base-patch16-naflex) | ~93M | Patch features, variable resolution (NaFlex) |
39
+ | Audio | [`openai/whisper-large-v3`](https://huggingface.co/openai/whisper-large-v3) (encoder only) | ~635M | Frame-level audio features |
40
+ | **Vision projector** (trained) | `checkpoints/vision_projector.pt` | 2.36M | LayerNorm → real 2×2 spatial patch merge → linear (3072→768) |
41
+ | **Audio projector** (trained) | `checkpoints/audio_projector.pt` | 0.98M | Linear (1280→768) |
42
+
43
+ **Video has no dedicated encoder or projector.** A video is 4 sampled frames
44
+ run through the vision encoder + vision projector, then mean-pooled over
45
+ time — exactly how GELATO's own paper handles video. `checkpoints/video_projector.pt`
46
+ is the vision projector after additional fine-tuning on real video frames
47
+ (see caveat below).
48
+
49
+ Why these particular substitutions: GELATO's paper describes its vision and
50
+ audio towers as *adapted from* SigLIP2 and Whisper-large-v3, but doesn't
51
+ release those adapted checkpoints publicly — so this reproduction uses the
52
+ public source models directly. The text encoder is an exact match; Jina
53
+ describes v5-omni's text tower as bit-identical to the standalone
54
+ `jina-embeddings-v5-text` release.
55
+
56
+ ## Results (measured on this hardware, not estimated)
57
+
58
+ Cross-modal retrieval recall@k, text-query direction, against a held-out
59
+ split (higher is better):
60
+
61
+ | Modality | R@1 | R@5 | R@10 | n |
62
+ |---|---|---|---|---|
63
+ | Image | 13.7% | 68.6% | 81.1% | 1024 |
64
+ | Audio | 67% | 97% | 100% | 33 |
65
+ | Video (held-out) | 40% | 86% | 94% | 50 |
66
+
67
+ Random-chance R@1 on the 1024-candidate image eval is ~0.1%; all three
68
+ modalities land far above chance. Peak VRAM across every training run
69
+ stayed under 2.7GB — well inside an 8GB budget.
70
+
71
+ These numbers come after seven rounds of iterating on the vision projector
72
+ specifically (architecture fixes, data scale, data diversity, projector
73
+ capacity) — full write-up of what did and didn't move the needle is in this
74
+ project's [feasibility write-up and README](https://github.com/) *(local
75
+ project — see the blog post that accompanies this model for the full
76
+ round-by-round story)*. Short version: architecture wasn't the bottleneck
77
+ after round 3; raw training data volume was — R@1 only moved once the
78
+ image-caption training set was scaled to ~172k pairs (round 7).
79
+
80
+ ## Usage
81
+
82
+ ```bash
83
+ pip install -r requirements.txt
84
+ ```
85
+
86
+ ```python
87
+ from gelato_local import GelatoLocal
88
+ from PIL import Image
89
+
90
+ model = GelatoLocal.from_pretrained("checkpoints") # auto-detects cuda/cpu
91
+
92
+ text_embeds = model.embed_text(["a dog running on the beach"])
93
+ image_embeds = model.embed_image([Image.open("photo.jpg").convert("RGB")])
94
+
95
+ similarity = text_embeds @ image_embeds.T # already L2-normalized -> cosine similarity
96
+ ```
97
+
98
+ See `examples/inference_example.py` for a complete runnable script, including
99
+ audio and video usage.
100
+
101
+ ## Important caveat: two different vision checkpoints
102
+
103
+ `checkpoints/vision_projector.pt` (best pure-image retrieval, the round-7
104
+ result above) and `checkpoints/video_projector.pt` (best video retrieval)
105
+ are **not the same weights**. The video checkpoint was produced by an
106
+ earlier training path that continued a *pre-round-3* vision projector
107
+ (before this project's spatial-merge architecture fix) on real MSR-VTT
108
+ video frames. It was never re-trained against the improved architecture or
109
+ the larger round-7 dataset. Practically: use `vision_projector.pt` for
110
+ image retrieval, `video_projector.pt` for video retrieval, and don't expect
111
+ them to be interchangeable or to represent the same underlying model
112
+ version.
113
+
114
+ ## Scope-downs vs. the GELATO paper
115
+
116
+ - Training data: ~172k image-caption pairs (Flickr8k + COCO + Conceptual
117
+ Captions), ~600 audio-caption pairs (AudioCaps), ~180 video-frame pairs
118
+ (MSR-VTT) — vs. the paper's enterprise-scale, multi-domain corpus.
119
+ - Batch size up to 128 vs. the paper's 256.
120
+ - No task-specific LoRA adapters (retrieval/classification/clustering)
121
+ layered on top — base cross-modal alignment only.
122
+ - Six rounds of experimentation ruled out architecture (merge fidelity,
123
+ projector capacity) as the remaining gap to the paper's published numbers;
124
+ data volume was the one lever that reliably helped, and this repo's
125
+ training data is still 2-3 orders of magnitude smaller than an
126
+ enterprise-scale corpus.
127
+
128
+ ## License
129
+
130
+ This repo is licensed **CC-BY-NC-4.0** (non-commercial), matching the most
131
+ restrictive license among its components:
132
+ [`jina-embeddings-v5-text-nano`](https://huggingface.co/jinaai/jina-embeddings-v5-text-nano)
133
+ is CC-BY-NC-4.0. `siglip2-base-patch16-naflex` and `whisper-large-v3` are
134
+ both Apache-2.0. If you plan to use this for anything commercial, you'll
135
+ need a compatible license for the text encoder specifically — check with
136
+ Jina AI directly.
137
+
138
+ ## Citation
139
+
140
+ This repo reproduces the architecture described in:
141
+
142
+ ```
143
+ @article{gelato2026,
144
+ title={jina-embeddings-v5-omni / GELATO},
145
+ note={arXiv:2605.08384}
146
+ }
147
+ ```
148
+
149
+ Not affiliated with or endorsed by Jina AI — an independent, from-scratch
150
+ reproduction built for a learning/portfolio project.
checkpoints/audio_projector.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1a20b68d9a30cef510238d2e954e7a950d10b65cbc2370c6601a99062d6bd420
3
+ size 3940356
checkpoints/video_projector.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d85127853776872aed34f69d7ad5d13ddac3abf6f0f95cdb069e94d9350a07b8
3
+ size 9445380
checkpoints/vision_projector.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f486aeef4fefd959a9e567d39e3b290460242c4efbf09569233d4cffa8b5bdaf
3
+ size 9445394
examples/inference_example.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 gelato_local import GelatoLocal
12
+
13
+ model = GelatoLocal.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])
gelato_local/__init__.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """gelato-local-nano: a local, from-scratch-trained reproduction of GELATO
2
+ (the architecture behind jina-embeddings-v5-omni) -- frozen text/vision/audio
3
+ encoders plus two small trained projectors, sharing one 768-dim embedding
4
+ space across text, image, audio, and video.
5
+
6
+ from gelato_local import GelatoLocal
7
+
8
+ model = GelatoLocal.from_pretrained("checkpoints", device="cuda")
9
+ text_embeds = model.embed_text(["a dog running on the beach"])
10
+ image_embeds = model.embed_image([Image.open("photo.jpg")])
11
+ similarity = text_embeds @ image_embeds.T
12
+ """
13
+ import os
14
+
15
+ import torch
16
+ import torch.nn.functional as F
17
+
18
+ from .encoders import AudioEncoder, TextEncoder, VisionEncoder
19
+ from .projectors import AudioProjector, VisionProjector
20
+ from .video import embed_video, sample_frames
21
+
22
+ __all__ = [
23
+ "GelatoLocal",
24
+ "TextEncoder",
25
+ "VisionEncoder",
26
+ "AudioEncoder",
27
+ "VisionProjector",
28
+ "AudioProjector",
29
+ "embed_video",
30
+ "sample_frames",
31
+ ]
32
+
33
+
34
+ class GelatoLocal:
35
+ """Loads all three frozen encoders plus the trained projectors, and
36
+ exposes one `embed_*` method per modality, each returning an L2-normalized
37
+ [batch, 768] tensor in the shared space -- so any two outputs from any
38
+ two modalities can be compared with a plain dot product / cosine
39
+ similarity."""
40
+
41
+ def __init__(self, device: str = "cuda" if torch.cuda.is_available() else "cpu"):
42
+ self.device = device
43
+ self.text_encoder = TextEncoder(device)
44
+ self.vision_encoder = VisionEncoder(device)
45
+ self.audio_encoder = AudioEncoder(device)
46
+ self.vision_projector = VisionProjector(patch_dim=768, out_dim=768).to(device).eval()
47
+ self.audio_projector = AudioProjector(in_dim=1280, out_dim=768).to(device).eval()
48
+ self.video_projector = VisionProjector(patch_dim=768, out_dim=768).to(device).eval()
49
+
50
+ @classmethod
51
+ def from_pretrained(cls, checkpoint_dir: str, device: str = "cuda" if torch.cuda.is_available() else "cpu"):
52
+ model = cls(device=device)
53
+ model.vision_projector.load_state_dict(
54
+ torch.load(os.path.join(checkpoint_dir, "vision_projector.pt"), map_location=device)
55
+ )
56
+ model.audio_projector.load_state_dict(
57
+ torch.load(os.path.join(checkpoint_dir, "audio_projector.pt"), map_location=device)
58
+ )
59
+ model.video_projector.load_state_dict(
60
+ torch.load(os.path.join(checkpoint_dir, "video_projector.pt"), map_location=device)
61
+ )
62
+ return model
63
+
64
+ @torch.no_grad()
65
+ def embed_text(self, texts: list[str]) -> torch.Tensor:
66
+ return F.normalize(self.text_encoder.embed(texts).float(), dim=-1)
67
+
68
+ @torch.no_grad()
69
+ def embed_image(self, images: list) -> torch.Tensor:
70
+ patch_tokens, mask, spatial_shapes = self.vision_encoder.patch_tokens(images)
71
+ out = self.vision_projector(patch_tokens.float(), mask, spatial_shapes)
72
+ return F.normalize(out, dim=-1)
73
+
74
+ @torch.no_grad()
75
+ def embed_audio(self, arrays: list, sampling_rate: int = 16000) -> torch.Tensor:
76
+ frame_tokens = self.audio_encoder.frame_tokens(arrays, sampling_rate=sampling_rate)
77
+ out = self.audio_projector(frame_tokens.float())
78
+ return F.normalize(out, dim=-1)
79
+
80
+ @torch.no_grad()
81
+ def embed_video_file(self, video_path: str, num_frames: int = 4) -> torch.Tensor:
82
+ out = embed_video(self.vision_encoder, self.video_projector, video_path, self.device, num_frames=num_frames)
83
+ return F.normalize(out.unsqueeze(0), dim=-1)
gelato_local/encoders.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
gelato_local/projectors.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
gelato_local/video.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ torch>=2.1
2
+ transformers>=4.45
3
+ pillow
4
+ opencv-python
5
+ soundfile
6
+ librosa
7
+ numpy