Mithil Maske commited on
Initial upload: gelato-local-nano (frozen encoders + trained projectors)
Browse files- README.md +150 -0
- checkpoints/audio_projector.pt +3 -0
- checkpoints/video_projector.pt +3 -0
- checkpoints/vision_projector.pt +3 -0
- examples/inference_example.py +26 -0
- gelato_local/__init__.py +83 -0
- gelato_local/encoders.py +103 -0
- gelato_local/projectors.py +111 -0
- gelato_local/video.py +36 -0
- requirements.txt +7 -0
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
|