quadembed 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quadembed-0.1.0/.gitignore +24 -0
- quadembed-0.1.0/LICENSE +45 -0
- quadembed-0.1.0/PKG-INFO +157 -0
- quadembed-0.1.0/README.md +118 -0
- quadembed-0.1.0/pyproject.toml +59 -0
- quadembed-0.1.0/quadembed/__init__.py +168 -0
- quadembed-0.1.0/quadembed/encoders.py +103 -0
- quadembed-0.1.0/quadembed/losses.py +30 -0
- quadembed-0.1.0/quadembed/projectors.py +111 -0
- quadembed-0.1.0/quadembed/video.py +47 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.claude/
|
|
5
|
+
scratch/
|
|
6
|
+
|
|
7
|
+
# large binary checkpoints and cached datasets -- not meant for git,
|
|
8
|
+
# the trained model is published separately on Hugging Face:
|
|
9
|
+
# https://huggingface.co/Mithil-21/quadembed-nano
|
|
10
|
+
checkpoints/
|
|
11
|
+
checkpoints_round*_backup/
|
|
12
|
+
data_cache/
|
|
13
|
+
*.pt
|
|
14
|
+
|
|
15
|
+
# staging folder for the Hugging Face upload, mirrors published content
|
|
16
|
+
hf_repo/
|
|
17
|
+
|
|
18
|
+
# python build artifacts
|
|
19
|
+
package/dist/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
|
|
22
|
+
# personal blog draft and its working assets -- not meant for the public repo
|
|
23
|
+
medium-blog-draft.md
|
|
24
|
+
blog_assets/
|
quadembed-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mithil Maske
|
|
4
|
+
|
|
5
|
+
This package and its associated model weights are licensed under the Creative
|
|
6
|
+
Commons Attribution-NonCommercial 4.0 International License.
|
|
7
|
+
|
|
8
|
+
You are free to:
|
|
9
|
+
|
|
10
|
+
Share - copy and redistribute the material in any medium or format
|
|
11
|
+
Adapt - remix, transform, and build upon the material
|
|
12
|
+
|
|
13
|
+
Under the following terms:
|
|
14
|
+
|
|
15
|
+
Attribution - You must give appropriate credit, provide a link to the
|
|
16
|
+
license, and indicate if changes were made.
|
|
17
|
+
|
|
18
|
+
NonCommercial - You may not use the material for commercial purposes.
|
|
19
|
+
|
|
20
|
+
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
WHY NON-COMMERCIAL
|
|
24
|
+
------------------
|
|
25
|
+
|
|
26
|
+
QuadEmbed is built on top of three frozen pretrained encoders. One of them,
|
|
27
|
+
jinaai/jina-embeddings-v5-text-nano, is itself licensed CC BY-NC 4.0, and it
|
|
28
|
+
defines the embedding space that the trained projectors map into. That
|
|
29
|
+
non-commercial restriction therefore carries through to this work.
|
|
30
|
+
|
|
31
|
+
The other two frozen encoders are permissively licensed:
|
|
32
|
+
- google/siglip2-base-patch16-naflex (Apache-2.0)
|
|
33
|
+
- openai/whisper-large-v3 (Apache-2.0)
|
|
34
|
+
|
|
35
|
+
If you need commercial use, you would need a separate license for the text
|
|
36
|
+
encoder from Jina AI, or to retrain the projectors against a permissively
|
|
37
|
+
licensed text encoder instead.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ATTRIBUTION
|
|
41
|
+
-----------
|
|
42
|
+
|
|
43
|
+
The architecture reproduced here (GELATO) is described in Jina AI's paper on
|
|
44
|
+
jina-embeddings-v5-omni, arXiv:2605.08384. This project is an independent
|
|
45
|
+
reproduction and is not affiliated with or endorsed by Jina AI.
|
quadembed-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: quadembed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multimodal embeddings for text, image, audio, and video in one shared vector space, trained on a single consumer GPU
|
|
5
|
+
Project-URL: Homepage, https://github.com/mithilai/QuadEmbed
|
|
6
|
+
Project-URL: Repository, https://github.com/mithilai/QuadEmbed
|
|
7
|
+
Project-URL: Model Weights, https://huggingface.co/Mithil-21/quadembed-nano
|
|
8
|
+
Project-URL: Write-up, https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd
|
|
9
|
+
Author: Mithil Maske
|
|
10
|
+
License: CC-BY-NC-4.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: audio,clip,contrastive-learning,embeddings,multimodal,retrieval,semantic-search,text-to-image,video
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
21
|
+
Classifier: Topic :: Multimedia :: Video
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: huggingface-hub>=0.24
|
|
25
|
+
Requires-Dist: numpy>=1.24
|
|
26
|
+
Requires-Dist: pillow>=9.0
|
|
27
|
+
Requires-Dist: torch>=2.1
|
|
28
|
+
Requires-Dist: transformers>=4.45
|
|
29
|
+
Provides-Extra: all
|
|
30
|
+
Requires-Dist: librosa>=0.10; extra == 'all'
|
|
31
|
+
Requires-Dist: opencv-python>=4.8; extra == 'all'
|
|
32
|
+
Requires-Dist: soundfile>=0.12; extra == 'all'
|
|
33
|
+
Provides-Extra: audio
|
|
34
|
+
Requires-Dist: librosa>=0.10; extra == 'audio'
|
|
35
|
+
Requires-Dist: soundfile>=0.12; extra == 'audio'
|
|
36
|
+
Provides-Extra: video
|
|
37
|
+
Requires-Dist: opencv-python>=4.8; extra == 'video'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# QuadEmbed
|
|
41
|
+
|
|
42
|
+
Multimodal embeddings for **text, image, audio, and video** in one shared
|
|
43
|
+
768-dimensional vector space. Compare any modality against any other with a
|
|
44
|
+
dot product.
|
|
45
|
+
|
|
46
|
+
Trained end-to-end on a single RTX 4060 laptop GPU (8GB VRAM) by freezing
|
|
47
|
+
three pretrained encoders and training only two small projection heads,
|
|
48
|
+
reproducing the GELATO architecture behind Jina AI's jina-embeddings-v5-omni.
|
|
49
|
+
|
|
50
|
+
> ⚠️ **Non-commercial license.** QuadEmbed builds on
|
|
51
|
+
> `jina-embeddings-v5-text-nano`, which is CC-BY-NC-4.0. That restriction
|
|
52
|
+
> carries through to this package and its weights. Research and educational
|
|
53
|
+
> use is fine; commercial use is not, without a separate license from Jina AI.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install quadembed # text + image + audio
|
|
59
|
+
pip install quadembed[video] # adds video support (OpenCV)
|
|
60
|
+
pip install quadembed[all] # everything, plus audio file loading helpers
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quickstart
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from quadembed import QuadEmbed
|
|
67
|
+
from PIL import Image
|
|
68
|
+
|
|
69
|
+
model = QuadEmbed.from_pretrained() # downloads weights from the Hub on first run
|
|
70
|
+
|
|
71
|
+
texts = ["a dog running on the beach", "a bowl of ramen noodles"]
|
|
72
|
+
text_embeds = model.embed_text(texts)
|
|
73
|
+
image_embeds = model.embed_image([Image.open("photo.jpg").convert("RGB")])
|
|
74
|
+
|
|
75
|
+
# embeddings are L2-normalized, so this is cosine similarity
|
|
76
|
+
similarity = text_embeds @ image_embeds.T
|
|
77
|
+
for text, score in zip(texts, similarity[:, 0].tolist()):
|
|
78
|
+
print(f"{score:.3f} {text}")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Audio
|
|
82
|
+
|
|
83
|
+
Pass mono float32 arrays at 16 kHz (what the frozen Whisper encoder expects):
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import soundfile as sf
|
|
87
|
+
|
|
88
|
+
audio, sr = sf.read("clip.wav")
|
|
89
|
+
audio_embeds = model.embed_audio([audio.astype("float32")], sampling_rate=sr)
|
|
90
|
+
similarity = model.embed_text(["a dog barking"]) @ audio_embeds.T
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Video
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
model = QuadEmbed.from_pretrained(modalities=("text", "video"))
|
|
97
|
+
video_embeds = model.embed_video_file("clip.mp4", num_frames=4)
|
|
98
|
+
similarity = model.embed_text(["a person skateboarding"]) @ video_embeds.T
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Loading only what you need
|
|
102
|
+
|
|
103
|
+
Each encoder costs memory and download time. Load a subset:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
model = QuadEmbed.from_pretrained(modalities=("text", "vision")) # skip audio
|
|
107
|
+
model = QuadEmbed.from_pretrained(device="cpu") # force CPU
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## How it works
|
|
111
|
+
|
|
112
|
+
Three frozen encoders, two trained projectors:
|
|
113
|
+
|
|
114
|
+
| Role | Model (frozen) | Trainable on top |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| Text (anchor) | `jinaai/jina-embeddings-v5-text-nano` (239M) | nothing, it defines the space |
|
|
117
|
+
| Vision | `google/siglip2-base-patch16-naflex` | vision projector (2.36M params) |
|
|
118
|
+
| Audio | `openai/whisper-large-v3` encoder | audio projector (0.98M params) |
|
|
119
|
+
|
|
120
|
+
Only ~3.3M parameters were ever trained, against nearly a billion frozen ones.
|
|
121
|
+
Video needs no encoder or projector of its own: frames are sampled, run
|
|
122
|
+
through the vision path, and mean-pooled over time.
|
|
123
|
+
|
|
124
|
+
Training used bidirectional in-batch InfoNCE (temperature 0.02) plus
|
|
125
|
+
Matryoshka representation learning over prefix dims {32, 64, 128, 256, 768},
|
|
126
|
+
so truncated embeddings remain usable if you need a smaller index.
|
|
127
|
+
|
|
128
|
+
## Measured performance
|
|
129
|
+
|
|
130
|
+
Cross-modal retrieval recall@k on held-out splits, text-query direction:
|
|
131
|
+
|
|
132
|
+
| Modality | R@1 | R@5 | R@10 | n |
|
|
133
|
+
|---|---|---|---|---|
|
|
134
|
+
| Image | 13.7% | 68.6% | 81.1% | 1024 |
|
|
135
|
+
| Audio | 67% | 97% | 100% | 33 |
|
|
136
|
+
| Video | 40% | 86% | 94% | 50 |
|
|
137
|
+
|
|
138
|
+
Random-chance R@1 on the 1024-candidate image eval is ~0.1%.
|
|
139
|
+
|
|
140
|
+
These are honest small-scale numbers. Vision was trained on ~172k
|
|
141
|
+
image-caption pairs, orders of magnitude less than production embedding
|
|
142
|
+
models, and image R@1 is the weakest metric as a result. R@5 and R@10 are
|
|
143
|
+
considerably stronger, so this is more useful for candidate retrieval and
|
|
144
|
+
reranking than for exact top-1 matching.
|
|
145
|
+
|
|
146
|
+
## Links
|
|
147
|
+
|
|
148
|
+
- **Source and training code:** [github.com/mithilai/QuadEmbed](https://github.com/mithilai/QuadEmbed)
|
|
149
|
+
- **Model weights:** [huggingface.co/Mithil-21/quadembed-nano](https://huggingface.co/Mithil-21/quadembed-nano)
|
|
150
|
+
- **Full write-up:** [I Built a Multimodal Embedding Model From Scratch on an RTX 4060](https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd)
|
|
151
|
+
|
|
152
|
+
## Credit
|
|
153
|
+
|
|
154
|
+
QuadEmbed reproduces the architecture described in Jina AI's GELATO paper
|
|
155
|
+
([arXiv:2605.08384](https://arxiv.org/abs/2605.08384)). All credit for the
|
|
156
|
+
original architecture and training recipe belongs there. This is an
|
|
157
|
+
independent reproduction, not affiliated with or endorsed by Jina AI.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# QuadEmbed
|
|
2
|
+
|
|
3
|
+
Multimodal embeddings for **text, image, audio, and video** in one shared
|
|
4
|
+
768-dimensional vector space. Compare any modality against any other with a
|
|
5
|
+
dot product.
|
|
6
|
+
|
|
7
|
+
Trained end-to-end on a single RTX 4060 laptop GPU (8GB VRAM) by freezing
|
|
8
|
+
three pretrained encoders and training only two small projection heads,
|
|
9
|
+
reproducing the GELATO architecture behind Jina AI's jina-embeddings-v5-omni.
|
|
10
|
+
|
|
11
|
+
> ⚠️ **Non-commercial license.** QuadEmbed builds on
|
|
12
|
+
> `jina-embeddings-v5-text-nano`, which is CC-BY-NC-4.0. That restriction
|
|
13
|
+
> carries through to this package and its weights. Research and educational
|
|
14
|
+
> use is fine; commercial use is not, without a separate license from Jina AI.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install quadembed # text + image + audio
|
|
20
|
+
pip install quadembed[video] # adds video support (OpenCV)
|
|
21
|
+
pip install quadembed[all] # everything, plus audio file loading helpers
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from quadembed import QuadEmbed
|
|
28
|
+
from PIL import Image
|
|
29
|
+
|
|
30
|
+
model = QuadEmbed.from_pretrained() # downloads weights from the Hub on first run
|
|
31
|
+
|
|
32
|
+
texts = ["a dog running on the beach", "a bowl of ramen noodles"]
|
|
33
|
+
text_embeds = model.embed_text(texts)
|
|
34
|
+
image_embeds = model.embed_image([Image.open("photo.jpg").convert("RGB")])
|
|
35
|
+
|
|
36
|
+
# embeddings are L2-normalized, so this is cosine similarity
|
|
37
|
+
similarity = text_embeds @ image_embeds.T
|
|
38
|
+
for text, score in zip(texts, similarity[:, 0].tolist()):
|
|
39
|
+
print(f"{score:.3f} {text}")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Audio
|
|
43
|
+
|
|
44
|
+
Pass mono float32 arrays at 16 kHz (what the frozen Whisper encoder expects):
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import soundfile as sf
|
|
48
|
+
|
|
49
|
+
audio, sr = sf.read("clip.wav")
|
|
50
|
+
audio_embeds = model.embed_audio([audio.astype("float32")], sampling_rate=sr)
|
|
51
|
+
similarity = model.embed_text(["a dog barking"]) @ audio_embeds.T
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Video
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
model = QuadEmbed.from_pretrained(modalities=("text", "video"))
|
|
58
|
+
video_embeds = model.embed_video_file("clip.mp4", num_frames=4)
|
|
59
|
+
similarity = model.embed_text(["a person skateboarding"]) @ video_embeds.T
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Loading only what you need
|
|
63
|
+
|
|
64
|
+
Each encoder costs memory and download time. Load a subset:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
model = QuadEmbed.from_pretrained(modalities=("text", "vision")) # skip audio
|
|
68
|
+
model = QuadEmbed.from_pretrained(device="cpu") # force CPU
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How it works
|
|
72
|
+
|
|
73
|
+
Three frozen encoders, two trained projectors:
|
|
74
|
+
|
|
75
|
+
| Role | Model (frozen) | Trainable on top |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| Text (anchor) | `jinaai/jina-embeddings-v5-text-nano` (239M) | nothing, it defines the space |
|
|
78
|
+
| Vision | `google/siglip2-base-patch16-naflex` | vision projector (2.36M params) |
|
|
79
|
+
| Audio | `openai/whisper-large-v3` encoder | audio projector (0.98M params) |
|
|
80
|
+
|
|
81
|
+
Only ~3.3M parameters were ever trained, against nearly a billion frozen ones.
|
|
82
|
+
Video needs no encoder or projector of its own: frames are sampled, run
|
|
83
|
+
through the vision path, and mean-pooled over time.
|
|
84
|
+
|
|
85
|
+
Training used bidirectional in-batch InfoNCE (temperature 0.02) plus
|
|
86
|
+
Matryoshka representation learning over prefix dims {32, 64, 128, 256, 768},
|
|
87
|
+
so truncated embeddings remain usable if you need a smaller index.
|
|
88
|
+
|
|
89
|
+
## Measured performance
|
|
90
|
+
|
|
91
|
+
Cross-modal retrieval recall@k on held-out splits, text-query direction:
|
|
92
|
+
|
|
93
|
+
| Modality | R@1 | R@5 | R@10 | n |
|
|
94
|
+
|---|---|---|---|---|
|
|
95
|
+
| Image | 13.7% | 68.6% | 81.1% | 1024 |
|
|
96
|
+
| Audio | 67% | 97% | 100% | 33 |
|
|
97
|
+
| Video | 40% | 86% | 94% | 50 |
|
|
98
|
+
|
|
99
|
+
Random-chance R@1 on the 1024-candidate image eval is ~0.1%.
|
|
100
|
+
|
|
101
|
+
These are honest small-scale numbers. Vision was trained on ~172k
|
|
102
|
+
image-caption pairs, orders of magnitude less than production embedding
|
|
103
|
+
models, and image R@1 is the weakest metric as a result. R@5 and R@10 are
|
|
104
|
+
considerably stronger, so this is more useful for candidate retrieval and
|
|
105
|
+
reranking than for exact top-1 matching.
|
|
106
|
+
|
|
107
|
+
## Links
|
|
108
|
+
|
|
109
|
+
- **Source and training code:** [github.com/mithilai/QuadEmbed](https://github.com/mithilai/QuadEmbed)
|
|
110
|
+
- **Model weights:** [huggingface.co/Mithil-21/quadembed-nano](https://huggingface.co/Mithil-21/quadembed-nano)
|
|
111
|
+
- **Full write-up:** [I Built a Multimodal Embedding Model From Scratch on an RTX 4060](https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd)
|
|
112
|
+
|
|
113
|
+
## Credit
|
|
114
|
+
|
|
115
|
+
QuadEmbed reproduces the architecture described in Jina AI's GELATO paper
|
|
116
|
+
([arXiv:2605.08384](https://arxiv.org/abs/2605.08384)). All credit for the
|
|
117
|
+
original architecture and training recipe belongs there. This is an
|
|
118
|
+
independent reproduction, not affiliated with or endorsed by Jina AI.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quadembed"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Multimodal embeddings for text, image, audio, and video in one shared vector space, trained on a single consumer GPU"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "CC-BY-NC-4.0" }
|
|
12
|
+
authors = [{ name = "Mithil Maske" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"embeddings",
|
|
15
|
+
"multimodal",
|
|
16
|
+
"retrieval",
|
|
17
|
+
"contrastive-learning",
|
|
18
|
+
"clip",
|
|
19
|
+
"text-to-image",
|
|
20
|
+
"audio",
|
|
21
|
+
"video",
|
|
22
|
+
"semantic-search",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Science/Research",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
33
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
34
|
+
"Topic :: Multimedia :: Video",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"torch>=2.1",
|
|
38
|
+
"transformers>=4.45",
|
|
39
|
+
"huggingface-hub>=0.24",
|
|
40
|
+
"pillow>=9.0",
|
|
41
|
+
"numpy>=1.24",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
video = ["opencv-python>=4.8"]
|
|
46
|
+
audio = ["soundfile>=0.12", "librosa>=0.10"]
|
|
47
|
+
all = ["opencv-python>=4.8", "soundfile>=0.12", "librosa>=0.10"]
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://github.com/mithilai/QuadEmbed"
|
|
51
|
+
Repository = "https://github.com/mithilai/QuadEmbed"
|
|
52
|
+
"Model Weights" = "https://huggingface.co/Mithil-21/quadembed-nano"
|
|
53
|
+
"Write-up" = "https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd"
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["quadembed"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = ["quadembed", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""QuadEmbed: a local multimodal embedding model covering text, image, audio,
|
|
2
|
+
and video in one shared 768-dim space.
|
|
3
|
+
|
|
4
|
+
Reproduces the GELATO architecture (jina-embeddings-v5-omni): three frozen
|
|
5
|
+
pretrained encoders plus two small trained projectors. Only the projectors
|
|
6
|
+
were trained; every encoder is used as published.
|
|
7
|
+
|
|
8
|
+
from quadembed import QuadEmbed
|
|
9
|
+
|
|
10
|
+
model = QuadEmbed.from_pretrained() # pulls weights from the Hub
|
|
11
|
+
text = model.embed_text(["a dog on the beach"])
|
|
12
|
+
image = model.embed_image([Image.open("photo.jpg")])
|
|
13
|
+
print(text @ image.T) # cosine similarity
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
import torch.nn.functional as F
|
|
19
|
+
|
|
20
|
+
from .encoders import AudioEncoder, TextEncoder, VisionEncoder
|
|
21
|
+
from .losses import bidirectional_infonce, matryoshka_infonce
|
|
22
|
+
from .projectors import AudioProjector, VisionProjector, embed_video_from_frames
|
|
23
|
+
from .video import embed_video, sample_frames
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
DEFAULT_REPO_ID = "Mithil-21/quadembed-nano"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"QuadEmbed",
|
|
31
|
+
"TextEncoder",
|
|
32
|
+
"VisionEncoder",
|
|
33
|
+
"AudioEncoder",
|
|
34
|
+
"VisionProjector",
|
|
35
|
+
"AudioProjector",
|
|
36
|
+
"matryoshka_infonce",
|
|
37
|
+
"bidirectional_infonce",
|
|
38
|
+
"embed_video",
|
|
39
|
+
"embed_video_from_frames",
|
|
40
|
+
"sample_frames",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_CHECKPOINTS = {
|
|
45
|
+
"vision": "checkpoints/vision_projector.pt",
|
|
46
|
+
"audio": "checkpoints/audio_projector.pt",
|
|
47
|
+
"video": "checkpoints/video_projector.pt",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_device() -> str:
|
|
52
|
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class QuadEmbed:
|
|
56
|
+
"""Three frozen encoders plus the trained projectors, with one `embed_*`
|
|
57
|
+
method per modality.
|
|
58
|
+
|
|
59
|
+
Every method returns an L2-normalized ``[batch, 768]`` tensor in the same
|
|
60
|
+
shared space, so any two outputs from any two modalities can be compared
|
|
61
|
+
with a plain matrix product (which is then cosine similarity).
|
|
62
|
+
|
|
63
|
+
Loading all three encoders takes a few GB of VRAM and a minute or two on
|
|
64
|
+
first run while the frozen backbones download from the Hub. Pass
|
|
65
|
+
``modalities=`` to load only what you need.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, device: str | None = None, modalities=("text", "vision", "audio")):
|
|
69
|
+
self.device = device or _default_device()
|
|
70
|
+
self.modalities = tuple(modalities)
|
|
71
|
+
|
|
72
|
+
self.text_encoder = TextEncoder(self.device) if "text" in self.modalities else None
|
|
73
|
+
|
|
74
|
+
needs_vision = "vision" in self.modalities or "video" in self.modalities
|
|
75
|
+
self.vision_encoder = VisionEncoder(self.device) if needs_vision else None
|
|
76
|
+
self.audio_encoder = AudioEncoder(self.device) if "audio" in self.modalities else None
|
|
77
|
+
|
|
78
|
+
self.vision_projector = (
|
|
79
|
+
VisionProjector(patch_dim=768, out_dim=768).to(self.device).eval()
|
|
80
|
+
if "vision" in self.modalities else None
|
|
81
|
+
)
|
|
82
|
+
self.audio_projector = (
|
|
83
|
+
AudioProjector(in_dim=1280, out_dim=768).to(self.device).eval()
|
|
84
|
+
if "audio" in self.modalities else None
|
|
85
|
+
)
|
|
86
|
+
self.video_projector = (
|
|
87
|
+
VisionProjector(patch_dim=768, out_dim=768).to(self.device).eval()
|
|
88
|
+
if "video" in self.modalities else None
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_pretrained(
|
|
93
|
+
cls,
|
|
94
|
+
repo_id_or_path: str = DEFAULT_REPO_ID,
|
|
95
|
+
device: str | None = None,
|
|
96
|
+
modalities=("text", "vision", "audio"),
|
|
97
|
+
revision: str | None = None,
|
|
98
|
+
) -> "QuadEmbed":
|
|
99
|
+
"""Load trained projector weights.
|
|
100
|
+
|
|
101
|
+
``repo_id_or_path`` is either a Hugging Face repo id (the default
|
|
102
|
+
pulls the published checkpoints) or a local directory containing a
|
|
103
|
+
``checkpoints/`` folder in the same layout.
|
|
104
|
+
"""
|
|
105
|
+
model = cls(device=device, modalities=modalities)
|
|
106
|
+
|
|
107
|
+
targets = [
|
|
108
|
+
("vision", model.vision_projector),
|
|
109
|
+
("audio", model.audio_projector),
|
|
110
|
+
("video", model.video_projector),
|
|
111
|
+
]
|
|
112
|
+
for name, projector in targets:
|
|
113
|
+
if projector is None:
|
|
114
|
+
continue
|
|
115
|
+
path = model._resolve(repo_id_or_path, _CHECKPOINTS[name], revision)
|
|
116
|
+
projector.load_state_dict(torch.load(path, map_location=model.device))
|
|
117
|
+
return model
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _resolve(repo_id_or_path: str, filename: str, revision: str | None) -> str:
|
|
121
|
+
local = os.path.join(repo_id_or_path, filename)
|
|
122
|
+
if os.path.isfile(local):
|
|
123
|
+
return local
|
|
124
|
+
if os.path.isdir(repo_id_or_path):
|
|
125
|
+
raise FileNotFoundError(f"{local} not found in local directory {repo_id_or_path}")
|
|
126
|
+
|
|
127
|
+
from huggingface_hub import hf_hub_download
|
|
128
|
+
|
|
129
|
+
return hf_hub_download(repo_id=repo_id_or_path, filename=filename, revision=revision)
|
|
130
|
+
|
|
131
|
+
def _check(self, attr: str, modality: str):
|
|
132
|
+
value = getattr(self, attr)
|
|
133
|
+
if value is None:
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
f"'{modality}' was not loaded. Construct with "
|
|
136
|
+
f"modalities=(..., '{modality}') to enable it."
|
|
137
|
+
)
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
@torch.no_grad()
|
|
141
|
+
def embed_text(self, texts: list[str]) -> torch.Tensor:
|
|
142
|
+
encoder = self._check("text_encoder", "text")
|
|
143
|
+
return F.normalize(encoder.embed(texts).float(), dim=-1)
|
|
144
|
+
|
|
145
|
+
@torch.no_grad()
|
|
146
|
+
def embed_image(self, images: list) -> torch.Tensor:
|
|
147
|
+
encoder = self._check("vision_encoder", "vision")
|
|
148
|
+
projector = self._check("vision_projector", "vision")
|
|
149
|
+
patch_tokens, mask, spatial_shapes = encoder.patch_tokens(images)
|
|
150
|
+
return F.normalize(projector(patch_tokens.float(), mask, spatial_shapes), dim=-1)
|
|
151
|
+
|
|
152
|
+
@torch.no_grad()
|
|
153
|
+
def embed_audio(self, arrays: list, sampling_rate: int = 16000) -> torch.Tensor:
|
|
154
|
+
"""``arrays``: list of mono float32 numpy arrays at ``sampling_rate``
|
|
155
|
+
(16 kHz is what the frozen Whisper encoder expects)."""
|
|
156
|
+
encoder = self._check("audio_encoder", "audio")
|
|
157
|
+
projector = self._check("audio_projector", "audio")
|
|
158
|
+
frame_tokens = encoder.frame_tokens(arrays, sampling_rate=sampling_rate)
|
|
159
|
+
return F.normalize(projector(frame_tokens.float()), dim=-1)
|
|
160
|
+
|
|
161
|
+
@torch.no_grad()
|
|
162
|
+
def embed_video_file(self, video_path: str, num_frames: int = 4) -> torch.Tensor:
|
|
163
|
+
"""Samples ``num_frames`` frames and mean-pools their embeddings.
|
|
164
|
+
Needs the video extra: ``pip install quadembed[video]``."""
|
|
165
|
+
encoder = self._check("vision_encoder", "video")
|
|
166
|
+
projector = self._check("video_projector", "video")
|
|
167
|
+
out = embed_video(encoder, projector, video_path, self.device, num_frames=num_frames)
|
|
168
|
+
return F.normalize(out.unsqueeze(0), dim=-1)
|
|
@@ -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
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Bidirectional in-batch InfoNCE + Matryoshka loss, matching GELATO's Stage
|
|
2
|
+
1/2 objective (temperature 0.02, prefix dims for the nano joint space)."""
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn.functional as F
|
|
5
|
+
|
|
6
|
+
NANO_MATRYOSHKA_DIMS = (32, 64, 128, 256, 768)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def bidirectional_infonce(a: torch.Tensor, b: torch.Tensor, temperature: float = 0.02) -> torch.Tensor:
|
|
10
|
+
"""a, b: [B, D] paired embeddings (e.g. text, image), same batch order = positives."""
|
|
11
|
+
a = F.normalize(a, dim=-1)
|
|
12
|
+
b = F.normalize(b, dim=-1)
|
|
13
|
+
logits = a @ b.T / temperature # [B, B]
|
|
14
|
+
labels = torch.arange(a.shape[0], device=a.device)
|
|
15
|
+
loss_a2b = F.cross_entropy(logits, labels)
|
|
16
|
+
loss_b2a = F.cross_entropy(logits.T, labels)
|
|
17
|
+
return (loss_a2b + loss_b2a) / 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def matryoshka_infonce(a: torch.Tensor, b: torch.Tensor, dims=NANO_MATRYOSHKA_DIMS, temperature: float = 0.02) -> torch.Tensor:
|
|
21
|
+
"""Sum of bidirectional InfoNCE over truncated embedding prefixes, so
|
|
22
|
+
every prefix length is independently a usable (Matryoshka-truncatable)
|
|
23
|
+
embedding, not just the full 768-dim vector."""
|
|
24
|
+
losses = []
|
|
25
|
+
full_dim = a.shape[-1]
|
|
26
|
+
for d in dims:
|
|
27
|
+
if d > full_dim:
|
|
28
|
+
continue
|
|
29
|
+
losses.append(bidirectional_infonce(a[..., :d], b[..., :d], temperature))
|
|
30
|
+
return torch.stack(losses).mean()
|
|
@@ -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)
|
|
@@ -0,0 +1,47 @@
|
|
|
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 numpy as np
|
|
5
|
+
import torch
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _require_cv2():
|
|
10
|
+
try:
|
|
11
|
+
import cv2
|
|
12
|
+
except ImportError as e: # pragma: no cover
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"Video support needs OpenCV. Install it with:\n"
|
|
15
|
+
" pip install quadembed[video]"
|
|
16
|
+
) from e
|
|
17
|
+
return cv2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sample_frames(video_path: str, num_frames: int = 4) -> list:
|
|
21
|
+
cv2 = _require_cv2()
|
|
22
|
+
cap = cv2.VideoCapture(video_path)
|
|
23
|
+
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
24
|
+
if total <= 0:
|
|
25
|
+
cap.release()
|
|
26
|
+
raise ValueError(f"no frames read from {video_path}")
|
|
27
|
+
indices = np.linspace(0, total - 1, num=min(num_frames, total), dtype=int)
|
|
28
|
+
frames = []
|
|
29
|
+
for idx in indices:
|
|
30
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
|
|
31
|
+
ok, frame_bgr = cap.read()
|
|
32
|
+
if not ok:
|
|
33
|
+
continue
|
|
34
|
+
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
|
35
|
+
frames.append(Image.fromarray(frame_rgb))
|
|
36
|
+
cap.release()
|
|
37
|
+
if not frames:
|
|
38
|
+
raise ValueError(f"no frames decoded from {video_path}")
|
|
39
|
+
return frames
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@torch.no_grad()
|
|
43
|
+
def embed_video(vision_encoder, vision_projector, video_path: str, device: str, num_frames: int = 4) -> torch.Tensor:
|
|
44
|
+
frames = sample_frames(video_path, num_frames=num_frames)
|
|
45
|
+
patch_tokens, mask, spatial_shapes = vision_encoder.patch_tokens(frames) # [num_frames, N, 768]
|
|
46
|
+
frame_embeds = vision_projector(patch_tokens.float(), mask, spatial_shapes) # [num_frames, 768]
|
|
47
|
+
return frame_embeds.mean(dim=0)
|