abtonmoy commited on
Commit
e63b6c0
·
verified ·
1 Parent(s): f1a36c5

inference.py: embed_video (base's own video path; purely additive)

Browse files
Files changed (1) hide show
  1. inference.py +224 -150
inference.py CHANGED
@@ -1,150 +1,224 @@
1
- """fusion-embedding-1 inference — one embedding space for text, images, and audio.
2
-
3
- Loads the frozen Qwen3-VL-Embedding base (native paths for text and images), the frozen
4
- Qwen2.5-Omni audio tower, and this repository's trained connector checkpoint. All inputs
5
- use the base model's official chat-template format; embedding quality is sensitive to
6
- this formatting, so use the templates provided here rather than constructing your own.
7
-
8
- from inference import FusionEmbedder
9
- fe = FusionEmbedder.from_pretrained("EximiusLabs/fusion-embedding-1-2b-preview")
10
- a, t, i = fe.embed_audio("dog.wav"), fe.embed_text("a dog barks"), fe.embed_image("dog.jpg")
11
-
12
- Requires: fusion_embedding (pip install git+https://github.com/Eximius-Labs/fusion-embedding-1),
13
- transformers>=4.46, torchvision, pillow, soundfile, librosa.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import dataclasses
19
- import os
20
- from typing import TYPE_CHECKING, Optional, Union
21
-
22
- if TYPE_CHECKING:
23
- import numpy as np
24
-
25
- import torch
26
-
27
- BASE_MODEL = "Qwen/Qwen3-VL-Embedding-2B"
28
- AUDIO_MODEL = "Qwen/Qwen2.5-Omni-7B"
29
- DEFAULT_QUERY_INSTRUCTION = "Retrieve images or text relevant to the user's query."
30
- DOC_INSTRUCTION = "Represent the user's input."
31
- CKPT_FILE = "fusion-embedding-1-2b-preview.pt"
32
-
33
-
34
- def _chat(instruction: str, user_content: str) -> str:
35
- """The base's official embedding format: system-turn instruction, assistant opener."""
36
- return (f"<|im_start|>system\n{instruction}<|im_end|>\n"
37
- f"<|im_start|>user\n{user_content}<|im_end|>\n"
38
- f"<|im_start|>assistant\n")
39
-
40
-
41
- class FusionEmbedder:
42
- def __init__(self, ckpt_path: str, device: str = "cuda", dtype=torch.bfloat16):
43
- from transformers import AutoFeatureExtractor, AutoModel, AutoProcessor
44
-
45
- from fusion_embedding.config import FusionConfig
46
- from fusion_embedding.hf_components import BaseLMAdapter, load_audio_tower
47
- from fusion_embedding.model import FusionEmbeddingModel, last_token_pool
48
-
49
- self.device = device
50
- self._pool = last_token_pool
51
- ck = torch.load(ckpt_path, map_location="cpu", weights_only=False)
52
- flds = {f.name for f in dataclasses.fields(FusionConfig)}
53
- self.cfg = FusionConfig(**{k: v for k, v in ck["config"].items() if k in flds})
54
-
55
- self.full = AutoModel.from_pretrained(BASE_MODEL, trust_remote_code=True, dtype=dtype)
56
- self.full = self.full.to(device).eval()
57
- for p in self.full.parameters():
58
- p.requires_grad_(False)
59
- self.proc = AutoProcessor.from_pretrained(BASE_MODEL, trust_remote_code=True)
60
- self.tok = self.proc.tokenizer
61
-
62
- tower, _, _ = load_audio_tower(AUDIO_MODEL, device=device, dtype=dtype)
63
- self.fe_audio = AutoFeatureExtractor.from_pretrained(AUDIO_MODEL, trust_remote_code=True)
64
-
65
- self.model = FusionEmbeddingModel(self.cfg, self.full.get_input_embeddings(),
66
- BaseLMAdapter(self.full.language_model),
67
- audio_encoder=tower)
68
- self.model.resampler.to(device).float()
69
- self.model.resampler.load_state_dict(ck["resampler"])
70
- self.model.text_whitening.load_state_dict(ck["text_whitening"]) # identity if unfitted
71
- self.model.eval()
72
-
73
- # ------------------------------------------------------------------ loading
74
- @classmethod
75
- def from_pretrained(cls, repo_or_path: str, device: str = "cuda",
76
- revision: Optional[str] = None, **kw) -> "FusionEmbedder":
77
- """Load from a local checkpoint path or an HF repo. ``revision`` pins a repo
78
- tag/commit (e.g. ``"v0.1-preview"``, ``"v0.2-preview"``); default is latest."""
79
- if os.path.exists(repo_or_path):
80
- path = repo_or_path
81
- else:
82
- from huggingface_hub import hf_hub_download
83
- path = hf_hub_download(repo_or_path, CKPT_FILE, revision=revision)
84
- return cls(path, device=device, **kw)
85
-
86
- # ------------------------------------------------------------------ helpers
87
- def _finish(self, pooled: torch.Tensor, dim: Optional[int]) -> torch.Tensor:
88
- from fusion_embedding.model import mrl_truncate_normalize
89
- return mrl_truncate_normalize(pooled.float(), dim or self.cfg.mrl_default).squeeze(0).cpu()
90
-
91
- # ------------------------------------------------------------------ audio
92
- @torch.no_grad()
93
- def embed_audio(self, audio: Union[str, "np.ndarray"], sr: Optional[int] = None,
94
- dim: Optional[int] = None) -> torch.Tensor:
95
- import librosa
96
- import soundfile as sf
97
- if isinstance(audio, (str, os.PathLike)):
98
- wav, sr = sf.read(str(audio), dtype="float32")
99
- else:
100
- wav = audio
101
- assert sr is not None, "pass sr= when embedding a raw array"
102
- if getattr(wav, "ndim", 1) > 1:
103
- wav = wav.mean(axis=1)
104
- target_sr = self.fe_audio.sampling_rate
105
- if sr != target_sr:
106
- wav = librosa.resample(wav, orig_sr=sr, target_sr=target_sr)
107
- feats = self.fe_audio(wav, sampling_rate=target_sr, return_tensors="pt",
108
- return_attention_mask=True, padding="max_length", truncation=True)
109
- mel = feats["input_features"][0]
110
- am = feats.get("attention_mask")
111
- if am is not None:
112
- mel = mel[:, : int(am[0].sum().item())]
113
- audio_tok = self.model.audio_tokens(
114
- mel.unsqueeze(0).to(self.device),
115
- torch.ones(1, mel.shape[1], dtype=torch.bool, device=self.device))
116
- ids = torch.tensor([[self.cfg.audio_pad_id] * self.cfg.n_query + [self.cfg.eos_id]],
117
- device=self.device)
118
- pooled = self.model.encode_audio(ids, torch.ones_like(ids), audio_tok)
119
- return self._finish(pooled, dim)
120
-
121
- # ------------------------------------------------------------------ text
122
- @torch.no_grad()
123
- def embed_text(self, text: str, instruction: str = DEFAULT_QUERY_INSTRUCTION,
124
- dim: Optional[int] = None) -> torch.Tensor:
125
- ids = self.tok.encode(_chat(instruction, text), add_special_tokens=False)[:512]
126
- ids_t = torch.tensor([ids], device=self.device)
127
- pooled = self.model.encode_text(ids_t, torch.ones_like(ids_t))
128
- return self._finish(self.model.text_whitening(pooled), dim)
129
-
130
- # ------------------------------------------------------------------ image
131
- @torch.no_grad()
132
- def embed_image(self, image, dim: Optional[int] = None) -> torch.Tensor:
133
- from PIL import Image
134
- if isinstance(image, (str, os.PathLike)):
135
- image = Image.open(str(image))
136
- image = image.convert("RGB")
137
- text = _chat(DOC_INSTRUCTION, "<|vision_start|><|image_pad|><|vision_end|>")
138
- inputs = self.proc(text=[text], images=[image], return_tensors="pt").to(self.device)
139
- h = self.full(**inputs).last_hidden_state
140
- pooled = self._pool(h, inputs["attention_mask"])
141
- return self._finish(pooled, dim)
142
-
143
- # ------------------------------------------------------------------ cross-modal readout
144
- @staticmethod
145
- def center(embs: torch.Tensor) -> torch.Tensor:
146
- """Per-modality mean-centering followed by renormalization. Recommended when ranking
147
- a gallery of one modality against queries of another; improves cross-modal R@1 by
148
- roughly two points across modality pairs in our evaluation."""
149
- c = embs - embs.mean(dim=0, keepdim=True)
150
- return torch.nn.functional.normalize(c, dim=-1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fusion-embedding-1 inference — one embedding space for text, images, and audio.
2
+
3
+ Loads the frozen Qwen3-VL-Embedding base (native paths for text and images), the frozen
4
+ Qwen2.5-Omni audio tower, and this repository's trained connector checkpoint. All inputs
5
+ use the base model's official chat-template format; embedding quality is sensitive to
6
+ this formatting, so use the templates provided here rather than constructing your own.
7
+
8
+ from inference import FusionEmbedder
9
+ fe = FusionEmbedder.from_pretrained("EximiusLabs/fusion-embedding-1-2b-preview")
10
+ a, t, i = fe.embed_audio("dog.wav"), fe.embed_text("a dog barks"), fe.embed_image("dog.jpg")
11
+
12
+ Requires: fusion_embedding (pip install git+https://github.com/Eximius-Labs/fusion-embedding-1),
13
+ transformers>=4.46, torchvision, pillow, soundfile, librosa.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import os
20
+ from typing import TYPE_CHECKING, Optional, Union
21
+
22
+ if TYPE_CHECKING:
23
+ import numpy as np
24
+
25
+ import torch
26
+
27
+ BASE_MODEL = "Qwen/Qwen3-VL-Embedding-2B"
28
+ AUDIO_MODEL = "Qwen/Qwen2.5-Omni-7B"
29
+ DEFAULT_QUERY_INSTRUCTION = "Retrieve images or text relevant to the user's query."
30
+ DOC_INSTRUCTION = "Represent the user's input."
31
+ CKPT_FILE = "fusion-embedding-1-2b-preview.pt"
32
+
33
+
34
+ def _chat(instruction: str, user_content: str) -> str:
35
+ """The base's official embedding format: system-turn instruction, assistant opener."""
36
+ return (f"<|im_start|>system\n{instruction}<|im_end|>\n"
37
+ f"<|im_start|>user\n{user_content}<|im_end|>\n"
38
+ f"<|im_start|>assistant\n")
39
+
40
+
41
+ class FusionEmbedder:
42
+ def __init__(self, ckpt_path: str, device: str = "cuda", dtype=torch.bfloat16):
43
+ from transformers import AutoFeatureExtractor, AutoModel, AutoProcessor
44
+
45
+ from fusion_embedding.config import FusionConfig
46
+ from fusion_embedding.hf_components import BaseLMAdapter, load_audio_tower
47
+ from fusion_embedding.model import FusionEmbeddingModel, last_token_pool
48
+
49
+ self.device = device
50
+ self._pool = last_token_pool
51
+ ck = torch.load(ckpt_path, map_location="cpu", weights_only=False)
52
+ flds = {f.name for f in dataclasses.fields(FusionConfig)}
53
+ self.cfg = FusionConfig(**{k: v for k, v in ck["config"].items() if k in flds})
54
+
55
+ self.full = AutoModel.from_pretrained(BASE_MODEL, trust_remote_code=True, dtype=dtype)
56
+ self.full = self.full.to(device).eval()
57
+ for p in self.full.parameters():
58
+ p.requires_grad_(False)
59
+ self.proc = AutoProcessor.from_pretrained(BASE_MODEL, trust_remote_code=True)
60
+ self.tok = self.proc.tokenizer
61
+
62
+ tower, _, _ = load_audio_tower(AUDIO_MODEL, device=device, dtype=dtype)
63
+ self.fe_audio = AutoFeatureExtractor.from_pretrained(AUDIO_MODEL, trust_remote_code=True)
64
+
65
+ self.model = FusionEmbeddingModel(self.cfg, self.full.get_input_embeddings(),
66
+ BaseLMAdapter(self.full.language_model),
67
+ audio_encoder=tower)
68
+ self.model.resampler.to(device).float()
69
+ self.model.resampler.load_state_dict(ck["resampler"])
70
+ self.model.text_whitening.load_state_dict(ck["text_whitening"]) # identity if unfitted
71
+ self.model.eval()
72
+
73
+ # ------------------------------------------------------------------ loading
74
+ @classmethod
75
+ def from_pretrained(cls, repo_or_path: str, device: str = "cuda",
76
+ revision: Optional[str] = None, **kw) -> "FusionEmbedder":
77
+ """Load from a local checkpoint path or an HF repo. ``revision`` pins a repo
78
+ tag/commit (e.g. ``"v0.1-preview"``, ``"v0.2-preview"``); default is latest."""
79
+ if os.path.exists(repo_or_path):
80
+ path = repo_or_path
81
+ else:
82
+ from huggingface_hub import hf_hub_download
83
+ path = hf_hub_download(repo_or_path, CKPT_FILE, revision=revision)
84
+ return cls(path, device=device, **kw)
85
+
86
+ # ------------------------------------------------------------------ helpers
87
+ def _finish(self, pooled: torch.Tensor, dim: Optional[int]) -> torch.Tensor:
88
+ from fusion_embedding.model import mrl_truncate_normalize
89
+ return mrl_truncate_normalize(pooled.float(), dim or self.cfg.mrl_default).squeeze(0).cpu()
90
+
91
+ # ------------------------------------------------------------------ audio
92
+ @torch.no_grad()
93
+ def embed_audio(self, audio: Union[str, "np.ndarray"], sr: Optional[int] = None,
94
+ dim: Optional[int] = None) -> torch.Tensor:
95
+ import librosa
96
+ import soundfile as sf
97
+ if isinstance(audio, (str, os.PathLike)):
98
+ wav, sr = sf.read(str(audio), dtype="float32")
99
+ else:
100
+ wav = audio
101
+ assert sr is not None, "pass sr= when embedding a raw array"
102
+ if getattr(wav, "ndim", 1) > 1:
103
+ wav = wav.mean(axis=1)
104
+ target_sr = self.fe_audio.sampling_rate
105
+ if sr != target_sr:
106
+ wav = librosa.resample(wav, orig_sr=sr, target_sr=target_sr)
107
+ feats = self.fe_audio(wav, sampling_rate=target_sr, return_tensors="pt",
108
+ return_attention_mask=True, padding="max_length", truncation=True)
109
+ mel = feats["input_features"][0]
110
+ am = feats.get("attention_mask")
111
+ if am is not None:
112
+ mel = mel[:, : int(am[0].sum().item())]
113
+ audio_tok = self.model.audio_tokens(
114
+ mel.unsqueeze(0).to(self.device),
115
+ torch.ones(1, mel.shape[1], dtype=torch.bool, device=self.device))
116
+ ids = torch.tensor([[self.cfg.audio_pad_id] * self.cfg.n_query + [self.cfg.eos_id]],
117
+ device=self.device)
118
+ pooled = self.model.encode_audio(ids, torch.ones_like(ids), audio_tok)
119
+ return self._finish(pooled, dim)
120
+
121
+ # ------------------------------------------------------------------ text
122
+ @torch.no_grad()
123
+ def embed_text(self, text: str, instruction: str = DEFAULT_QUERY_INSTRUCTION,
124
+ dim: Optional[int] = None) -> torch.Tensor:
125
+ ids = self.tok.encode(_chat(instruction, text), add_special_tokens=False)[:512]
126
+ ids_t = torch.tensor([ids], device=self.device)
127
+ pooled = self.model.encode_text(ids_t, torch.ones_like(ids_t))
128
+ return self._finish(self.model.text_whitening(pooled), dim)
129
+
130
+ # ------------------------------------------------------------------ image
131
+ @torch.no_grad()
132
+ def embed_image(self, image, dim: Optional[int] = None) -> torch.Tensor:
133
+ from PIL import Image
134
+ if isinstance(image, (str, os.PathLike)):
135
+ image = Image.open(str(image))
136
+ image = image.convert("RGB")
137
+ text = _chat(DOC_INSTRUCTION, "<|vision_start|><|image_pad|><|vision_end|>")
138
+ inputs = self.proc(text=[text], images=[image], return_tensors="pt").to(self.device)
139
+ h = self.full(**inputs).last_hidden_state
140
+ pooled = self._pool(h, inputs["attention_mask"])
141
+ return self._finish(pooled, dim)
142
+
143
+ # ------------------------------------------------------------------ video
144
+ @torch.no_grad()
145
+ def embed_video(self, video, fps: Optional[float] = None,
146
+ max_frames: Optional[int] = None,
147
+ dim: Optional[int] = None) -> torch.Tensor:
148
+ """Embed a video through the frozen base model's own video path.
149
+
150
+ ``video`` is a file path/URL, or a pre-extracted frame sequence (a list of
151
+ PIL images and/or frame-image paths). Preprocessing follows the base model's
152
+ official usage (the Qwen3-VL-Embedding reference scripts):
153
+ ``qwen_vl_utils.process_vision_info`` with ``image_patch_size=16``, 1 fps
154
+ sampling up to 64 frames for path inputs, uniform temporal sampling of frame
155
+ sequences, a 7,864,320 total-pixel budget, and ``do_resize=False`` at the
156
+ processor because the vision utility already smart-resizes. Like images,
157
+ video is a non-audio input and takes the frozen path (no whitening).
158
+ Requires qwen-vl-utils>=0.0.14; path inputs additionally need a video
159
+ decoder backend supported by it.
160
+ """
161
+ import numpy as np
162
+
163
+ gate = getattr(self.model, "_adapter_gate", None)
164
+ if gate is not None and gate.active:
165
+ # The video path runs through the same (hook-carrying) decoder layers;
166
+ # non-audio inputs must run with the gate closed so the adapter branch
167
+ # never runs. Mirrors the encode_text/embed_image guards.
168
+ raise RuntimeError("adapter gate is open during a video embed — "
169
+ "non-audio inputs must run with the gate closed")
170
+ try:
171
+ from qwen_vl_utils import process_vision_info
172
+ except ImportError as e:
173
+ raise ImportError(
174
+ "video embedding uses the base model's own preprocessing "
175
+ "package: pip install 'qwen-vl-utils>=0.0.14'") from e
176
+
177
+ # Constants from the base's reference implementation.
178
+ video_total_pixels = 10 * 768 * 32 * 32
179
+ default_fps, default_max_frames = 1.0, 64
180
+
181
+ if isinstance(video, (str, os.PathLike)):
182
+ v = str(video)
183
+ content = v if v.startswith(("http://", "https://")) \
184
+ else "file://" + os.path.abspath(v)
185
+ vkw = {"fps": fps or default_fps,
186
+ "max_frames": max_frames or default_max_frames,
187
+ "total_pixels": video_total_pixels}
188
+ else:
189
+ frames = list(video)
190
+ if not frames:
191
+ raise ValueError("empty frame sequence")
192
+ mf = max_frames or default_max_frames
193
+ if len(frames) > mf:
194
+ # Uniform temporal sampling, as in the base's sample_frames.
195
+ idx = np.linspace(0, len(frames) - 1, mf, dtype=int)
196
+ frames = [frames[i] for i in idx]
197
+ content = ["file://" + os.path.abspath(str(f))
198
+ if isinstance(f, (str, os.PathLike)) else f
199
+ for f in frames]
200
+ vkw = {"total_pixels": video_total_pixels}
201
+
202
+ conversation = [{"role": "user",
203
+ "content": [{"type": "video", "video": content, **vkw}]}]
204
+ _, video_inputs, video_kwargs = process_vision_info(
205
+ conversation, image_patch_size=16,
206
+ return_video_metadata=True, return_video_kwargs=True)
207
+ videos, video_metadata = zip(*video_inputs)
208
+ text = _chat(DOC_INSTRUCTION, "<|vision_start|><|video_pad|><|vision_end|>")
209
+ inputs = self.proc(text=[text], videos=list(videos),
210
+ video_metadata=list(video_metadata),
211
+ do_resize=False, return_tensors="pt",
212
+ **video_kwargs).to(self.device)
213
+ h = self.full(**inputs).last_hidden_state
214
+ pooled = self._pool(h, inputs["attention_mask"])
215
+ return self._finish(pooled, dim)
216
+
217
+ # ------------------------------------------------------------------ cross-modal readout
218
+ @staticmethod
219
+ def center(embs: torch.Tensor) -> torch.Tensor:
220
+ """Per-modality mean-centering followed by renormalization. Recommended when ranking
221
+ a gallery of one modality against queries of another; improves cross-modal R@1 by
222
+ roughly two points across modality pairs in our evaluation."""
223
+ c = embs - embs.mean(dim=0, keepdim=True)
224
+ return torch.nn.functional.normalize(c, dim=-1)