abtonmoy commited on
Commit
b551ea8
·
verified ·
1 Parent(s): d632d6b

inference.py: native video preprocessing (no qwen-vl-utils)

Browse files
Files changed (1) hide show
  1. inference.py +359 -224
inference.py CHANGED
@@ -1,224 +1,359 @@
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 math
20
+ import os
21
+ from typing import TYPE_CHECKING, Optional, Union
22
+
23
+ if TYPE_CHECKING:
24
+ import numpy as np
25
+
26
+ import torch
27
+
28
+ BASE_MODEL = "Qwen/Qwen3-VL-Embedding-2B"
29
+ AUDIO_MODEL = "Qwen/Qwen2.5-Omni-7B"
30
+ DEFAULT_QUERY_INSTRUCTION = "Retrieve images or text relevant to the user's query."
31
+ DOC_INSTRUCTION = "Represent the user's input."
32
+ CKPT_FILE = "fusion-embedding-1-2b-preview.pt"
33
+
34
+
35
+ def _chat(instruction: str, user_content: str) -> str:
36
+ """The base's official embedding format: system-turn instruction, assistant opener."""
37
+ return (f"<|im_start|>system\n{instruction}<|im_end|>\n"
38
+ f"<|im_start|>user\n{user_content}<|im_end|>\n"
39
+ f"<|im_start|>assistant\n")
40
+
41
+
42
+ # --------------------------------------------------------------------------- #
43
+ # video preprocessing (native)
44
+ #
45
+ # Faithful reimplementation of the base model's reference video preprocessing
46
+ # (the Qwen3-VL-Embedding scripts' vision pipeline at image_patch_size=16), so
47
+ # no extra vision package is needed: frame selection, the per-frame image
48
+ # resize applied to frame-sequence inputs, the per-video smart resize under the
49
+ # total-pixel budget, and the processor kwargs (do_resize=False,
50
+ # do_sample_frames=False, video_metadata) match the reference exactly; outputs
51
+ # are verified bitwise-equal against the reference implementation on identical
52
+ # inputs. Decoded-video inputs (frame tensors, file paths via torchcodec) take
53
+ # the reference path-input treatment: a single per-video resize, no per-frame
54
+ # image resize.
55
+ # --------------------------------------------------------------------------- #
56
+ _V_PATCH_FACTOR = 32 # image_patch_size 16 x spatial merge 2
57
+ _V_FRAME_FACTOR = 2
58
+ _V_DEFAULT_FPS = 1.0
59
+ _V_DEFAULT_MAX_FRAMES = 64
60
+ _V_MIN_PIXELS = 128 * _V_PATCH_FACTOR ** 2 # per-frame floor
61
+ _V_MAX_PIXELS = 768 * _V_PATCH_FACTOR ** 2 # per-frame ceiling
62
+ _V_TOTAL_PIXELS = 10 * _V_MAX_PIXELS # per-video budget
63
+ _V_IMG_MIN_PIXELS = 4 * _V_PATCH_FACTOR ** 2 # per-frame image defaults
64
+ _V_IMG_MAX_PIXELS = 16384 * _V_PATCH_FACTOR ** 2
65
+ _V_FPS_MIN_FRAMES = 4
66
+ _V_MAX_RATIO = 200
67
+
68
+
69
+ def _v_round(n: float, f: int) -> int:
70
+ return round(n / f) * f
71
+
72
+
73
+ def _v_ceil(n: float, f: int) -> int:
74
+ return math.ceil(n / f) * f
75
+
76
+
77
+ def _v_floor(n: float, f: int) -> int:
78
+ return math.floor(n / f) * f
79
+
80
+
81
+ def _v_smart_resize(height: int, width: int, factor: int,
82
+ min_pixels: int, max_pixels: int):
83
+ if max(height, width) / min(height, width) > _V_MAX_RATIO:
84
+ raise ValueError(
85
+ f"absolute aspect ratio must be smaller than {_V_MAX_RATIO}, "
86
+ f"got {max(height, width) / min(height, width)}")
87
+ h_bar = max(factor, _v_round(height, factor))
88
+ w_bar = max(factor, _v_round(width, factor))
89
+ if h_bar * w_bar > max_pixels:
90
+ beta = math.sqrt((height * width) / max_pixels)
91
+ h_bar = _v_floor(height / beta, factor)
92
+ w_bar = _v_floor(width / beta, factor)
93
+ elif h_bar * w_bar < min_pixels:
94
+ beta = math.sqrt(min_pixels / (height * width))
95
+ h_bar = _v_ceil(height * beta, factor)
96
+ w_bar = _v_ceil(width * beta, factor)
97
+ return h_bar, w_bar
98
+
99
+
100
+ def _v_frame_to_image(frame):
101
+ """Frame-sequence element -> resized RGB PIL image (reference fetch_image)."""
102
+ from PIL import Image
103
+
104
+ if isinstance(frame, (str, os.PathLike)):
105
+ image = Image.open(str(frame))
106
+ else:
107
+ image = frame
108
+ if image.mode == "RGBA":
109
+ white = Image.new("RGB", image.size, (255, 255, 255))
110
+ white.paste(image, mask=image.split()[3])
111
+ image = white
112
+ else:
113
+ image = image.convert("RGB")
114
+ width, height = image.size
115
+ rh, rw = _v_smart_resize(height, width, _V_PATCH_FACTOR,
116
+ _V_IMG_MIN_PIXELS, _V_IMG_MAX_PIXELS)
117
+ return image.resize((rw, rh))
118
+
119
+
120
+ def _v_prepare(video, fps, max_frames):
121
+ """Normalize any supported video input to (uint8 tensor [T,C,H,W], metadata).
122
+
123
+ Frame sequences follow the reference list-input treatment (per-frame image
124
+ resize, pad to an even count by repeating the last frame, synthetic
125
+ metadata at 2 fps). Frame tensors and file paths follow the reference
126
+ decoded-video treatment (frame selection only; single per-video resize).
127
+ """
128
+ import numpy as np
129
+
130
+ if isinstance(video, torch.Tensor):
131
+ if video.ndim != 4 or video.shape[1] not in (1, 3):
132
+ raise ValueError(
133
+ f"expected a [T, C, H, W] frame tensor, got {list(video.shape)}")
134
+ frames = video
135
+ if frames.shape[1] == 1:
136
+ frames = frames.expand(-1, 3, -1, -1)
137
+ if frames.dtype != torch.uint8:
138
+ frames = frames.clamp(0, 255).to(torch.uint8)
139
+ t = frames.shape[0]
140
+ mf = max_frames or _V_DEFAULT_MAX_FRAMES
141
+ if t > mf:
142
+ idx = np.linspace(0, t - 1, mf, dtype=int)
143
+ frames = frames[torch.as_tensor(idx.copy())]
144
+ t = mf
145
+ n = _v_ceil(t, _V_FRAME_FACTOR)
146
+ if t < n:
147
+ frames = torch.cat([frames, frames[-1:].expand(n - t, -1, -1, -1)])
148
+ metadata = dict(fps=2.0, frames_indices=list(range(n)),
149
+ total_num_frames=float(n))
150
+ return frames, metadata
151
+
152
+ if isinstance(video, (str, os.PathLike)):
153
+ v = str(video)
154
+ if v.startswith("file://"):
155
+ v = v[7:]
156
+ try:
157
+ from torchcodec.decoders import VideoDecoder
158
+ except ImportError as e:
159
+ raise ImportError(
160
+ "embedding a video by file path requires torchcodec "
161
+ "(pip install torchcodec); alternatively pass decoded frames "
162
+ "(a [T, C, H, W] tensor or a list of PIL images)") from e
163
+ decoder = VideoDecoder(v)
164
+ video_fps = decoder.metadata.average_fps
165
+ total = decoder.metadata.num_frames
166
+ want_fps = fps or _V_DEFAULT_FPS
167
+ min_frames = _v_ceil(_V_FPS_MIN_FRAMES, _V_FRAME_FACTOR)
168
+ max_f = _v_floor(max_frames or _V_DEFAULT_MAX_FRAMES, _V_FRAME_FACTOR)
169
+ n = total / video_fps * want_fps
170
+ n = min(min(max(n, min_frames), max_f), total)
171
+ n = _v_floor(n, _V_FRAME_FACTOR)
172
+ if not (_V_FRAME_FACTOR <= n <= total):
173
+ raise ValueError(
174
+ f"video too short: {total} frames; need >= {_V_FRAME_FACTOR}")
175
+ idx = torch.linspace(0, total - 1, n).round().long().tolist()
176
+ frames = decoder.get_frames_at(indices=idx).data
177
+ metadata = dict(fps=video_fps, frames_indices=idx,
178
+ total_num_frames=total, video_backend="torchcodec")
179
+ return frames, metadata
180
+
181
+ # frame sequence (PIL images and/or paths)
182
+ frames = list(video)
183
+ if not frames:
184
+ raise ValueError("empty frame sequence")
185
+ mf = max_frames or _V_DEFAULT_MAX_FRAMES
186
+ if len(frames) > mf:
187
+ idx = np.linspace(0, len(frames) - 1, mf, dtype=int)
188
+ frames = [frames[i] for i in idx]
189
+ images = [_v_frame_to_image(f) for f in frames]
190
+ n = _v_ceil(len(images), _V_FRAME_FACTOR)
191
+ if len(images) < n:
192
+ images.extend([images[-1]] * (n - len(images)))
193
+ tensor = torch.stack([
194
+ torch.from_numpy(np.array(image).transpose(2, 0, 1)) for image in images
195
+ ])
196
+ metadata = dict(fps=2.0, frames_indices=list(range(n)),
197
+ total_num_frames=float(n))
198
+ return tensor, metadata
199
+
200
+
201
+ def _v_resize_video(frames: torch.Tensor) -> torch.Tensor:
202
+ """Per-video smart resize under the total-pixel budget (reference exact)."""
203
+ from torchvision.transforms import InterpolationMode
204
+ from torchvision.transforms import functional as TF
205
+
206
+ n, _, height, width = frames.shape
207
+ max_pixels = max(min(_V_MAX_PIXELS, _V_TOTAL_PIXELS / n * _V_FRAME_FACTOR),
208
+ int(_V_MIN_PIXELS * 1.05))
209
+ rh, rw = _v_smart_resize(height, width, _V_PATCH_FACTOR,
210
+ _V_MIN_PIXELS, max_pixels)
211
+ return TF.resize(frames, [rh, rw],
212
+ interpolation=InterpolationMode.BICUBIC,
213
+ antialias=True).float()
214
+
215
+
216
+ class FusionEmbedder:
217
+ def __init__(self, ckpt_path: str, device: str = "cuda", dtype=torch.bfloat16):
218
+ from transformers import AutoFeatureExtractor, AutoModel, AutoProcessor
219
+
220
+ from fusion_embedding.config import FusionConfig
221
+ from fusion_embedding.hf_components import BaseLMAdapter, load_audio_tower
222
+ from fusion_embedding.model import FusionEmbeddingModel, last_token_pool
223
+
224
+ self.device = device
225
+ self._pool = last_token_pool
226
+ ck = torch.load(ckpt_path, map_location="cpu", weights_only=False)
227
+ flds = {f.name for f in dataclasses.fields(FusionConfig)}
228
+ self.cfg = FusionConfig(**{k: v for k, v in ck["config"].items() if k in flds})
229
+
230
+ self.full = AutoModel.from_pretrained(BASE_MODEL, trust_remote_code=True, dtype=dtype)
231
+ self.full = self.full.to(device).eval()
232
+ for p in self.full.parameters():
233
+ p.requires_grad_(False)
234
+ self.proc = AutoProcessor.from_pretrained(BASE_MODEL, trust_remote_code=True)
235
+ self.tok = self.proc.tokenizer
236
+
237
+ tower, _, _ = load_audio_tower(AUDIO_MODEL, device=device, dtype=dtype)
238
+ self.fe_audio = AutoFeatureExtractor.from_pretrained(AUDIO_MODEL, trust_remote_code=True)
239
+
240
+ self.model = FusionEmbeddingModel(self.cfg, self.full.get_input_embeddings(),
241
+ BaseLMAdapter(self.full.language_model),
242
+ audio_encoder=tower)
243
+ self.model.resampler.to(device).float()
244
+ self.model.resampler.load_state_dict(ck["resampler"])
245
+ self.model.text_whitening.load_state_dict(ck["text_whitening"]) # identity if unfitted
246
+ self.model.eval()
247
+
248
+ # ------------------------------------------------------------------ loading
249
+ @classmethod
250
+ def from_pretrained(cls, repo_or_path: str, device: str = "cuda",
251
+ revision: Optional[str] = None, **kw) -> "FusionEmbedder":
252
+ """Load from a local checkpoint path or an HF repo. ``revision`` pins a repo
253
+ tag/commit (e.g. ``"v0.1-preview"``, ``"v0.2-preview"``); default is latest."""
254
+ if os.path.exists(repo_or_path):
255
+ path = repo_or_path
256
+ else:
257
+ from huggingface_hub import hf_hub_download
258
+ path = hf_hub_download(repo_or_path, CKPT_FILE, revision=revision)
259
+ return cls(path, device=device, **kw)
260
+
261
+ # ------------------------------------------------------------------ helpers
262
+ def _finish(self, pooled: torch.Tensor, dim: Optional[int]) -> torch.Tensor:
263
+ from fusion_embedding.model import mrl_truncate_normalize
264
+ return mrl_truncate_normalize(pooled.float(), dim or self.cfg.mrl_default).squeeze(0).cpu()
265
+
266
+ # ------------------------------------------------------------------ audio
267
+ @torch.no_grad()
268
+ def embed_audio(self, audio: Union[str, "np.ndarray"], sr: Optional[int] = None,
269
+ dim: Optional[int] = None) -> torch.Tensor:
270
+ import librosa
271
+ import soundfile as sf
272
+ if isinstance(audio, (str, os.PathLike)):
273
+ wav, sr = sf.read(str(audio), dtype="float32")
274
+ else:
275
+ wav = audio
276
+ assert sr is not None, "pass sr= when embedding a raw array"
277
+ if getattr(wav, "ndim", 1) > 1:
278
+ wav = wav.mean(axis=1)
279
+ target_sr = self.fe_audio.sampling_rate
280
+ if sr != target_sr:
281
+ wav = librosa.resample(wav, orig_sr=sr, target_sr=target_sr)
282
+ feats = self.fe_audio(wav, sampling_rate=target_sr, return_tensors="pt",
283
+ return_attention_mask=True, padding="max_length", truncation=True)
284
+ mel = feats["input_features"][0]
285
+ am = feats.get("attention_mask")
286
+ if am is not None:
287
+ mel = mel[:, : int(am[0].sum().item())]
288
+ audio_tok = self.model.audio_tokens(
289
+ mel.unsqueeze(0).to(self.device),
290
+ torch.ones(1, mel.shape[1], dtype=torch.bool, device=self.device))
291
+ ids = torch.tensor([[self.cfg.audio_pad_id] * self.cfg.n_query + [self.cfg.eos_id]],
292
+ device=self.device)
293
+ pooled = self.model.encode_audio(ids, torch.ones_like(ids), audio_tok)
294
+ return self._finish(pooled, dim)
295
+
296
+ # ------------------------------------------------------------------ text
297
+ @torch.no_grad()
298
+ def embed_text(self, text: str, instruction: str = DEFAULT_QUERY_INSTRUCTION,
299
+ dim: Optional[int] = None) -> torch.Tensor:
300
+ ids = self.tok.encode(_chat(instruction, text), add_special_tokens=False)[:512]
301
+ ids_t = torch.tensor([ids], device=self.device)
302
+ pooled = self.model.encode_text(ids_t, torch.ones_like(ids_t))
303
+ return self._finish(self.model.text_whitening(pooled), dim)
304
+
305
+ # ------------------------------------------------------------------ image
306
+ @torch.no_grad()
307
+ def embed_image(self, image, dim: Optional[int] = None) -> torch.Tensor:
308
+ from PIL import Image
309
+ if isinstance(image, (str, os.PathLike)):
310
+ image = Image.open(str(image))
311
+ image = image.convert("RGB")
312
+ text = _chat(DOC_INSTRUCTION, "<|vision_start|><|image_pad|><|vision_end|>")
313
+ inputs = self.proc(text=[text], images=[image], return_tensors="pt").to(self.device)
314
+ h = self.full(**inputs).last_hidden_state
315
+ pooled = self._pool(h, inputs["attention_mask"])
316
+ return self._finish(pooled, dim)
317
+
318
+ # ------------------------------------------------------------------ video
319
+ @torch.no_grad()
320
+ def embed_video(self, video, fps: Optional[float] = None,
321
+ max_frames: Optional[int] = None,
322
+ dim: Optional[int] = None) -> torch.Tensor:
323
+ """Embed a video through the frozen base model's own video path.
324
+
325
+ ``video`` is a decoded frame tensor ([T, C, H, W], e.g. straight from
326
+ a torchcodec ``VideoDecoder``), a file path/URL (decoded with
327
+ torchcodec, 1 fps up to 64 frames), or a pre-extracted frame sequence
328
+ (PIL images and/or frame paths, sampled uniformly to 64).
329
+ Preprocessing natively reimplements the base model's reference
330
+ scripts (see the module-level helpers above); no extra vision package
331
+ is required. Like images, video is a non-audio input: it takes the
332
+ frozen path (no whitening, no adapters).
333
+ """
334
+ gate = getattr(self.model, "_adapter_gate", None)
335
+ if gate is not None and gate.active:
336
+ # The video path runs through the same (hook-carrying) decoder layers;
337
+ # non-audio inputs must run with the gate closed so the adapter branch
338
+ # never runs. Mirrors the encode_text/embed_image guards.
339
+ raise RuntimeError("adapter gate is open during a video embed — "
340
+ "non-audio inputs must run with the gate closed")
341
+ frames, metadata = _v_prepare(video, fps, max_frames)
342
+ frames = _v_resize_video(frames)
343
+ text = _chat(DOC_INSTRUCTION, "<|vision_start|><|video_pad|><|vision_end|>")
344
+ inputs = self.proc(text=[text], videos=[frames],
345
+ video_metadata=[metadata],
346
+ do_resize=False, do_sample_frames=False,
347
+ return_tensors="pt").to(self.device)
348
+ h = self.full(**inputs).last_hidden_state
349
+ pooled = self._pool(h, inputs["attention_mask"])
350
+ return self._finish(pooled, dim)
351
+
352
+ # ------------------------------------------------------------------ cross-modal readout
353
+ @staticmethod
354
+ def center(embs: torch.Tensor) -> torch.Tensor:
355
+ """Per-modality mean-centering followed by renormalization. Recommended when ranking
356
+ a gallery of one modality against queries of another; improves cross-modal R@1 by
357
+ roughly two points across modality pairs in our evaluation."""
358
+ c = embs - embs.mean(dim=0, keepdim=True)
359
+ return torch.nn.functional.normalize(c, dim=-1)