AutoModel + trust_remote_code support (remote-code loading; existing loaders unchanged)
Browse files- config.json +22 -0
- configuration_fusion_embedding.py +68 -0
- modeling_fusion_embedding.py +521 -0
config.json
CHANGED
|
@@ -50,5 +50,27 @@
|
|
| 50 |
"a2t_R@10": 0.8849999904632568,
|
| 51 |
"a2t_mAP@10": 0.587212324142456
|
| 52 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
}
|
|
|
|
| 50 |
"a2t_R@10": 0.8849999904632568,
|
| 51 |
"a2t_mAP@10": 0.587212324142456
|
| 52 |
}
|
| 53 |
+
},
|
| 54 |
+
"d_audio": 3584,
|
| 55 |
+
"resampler_depth": 6,
|
| 56 |
+
"resampler_heads": 8,
|
| 57 |
+
"resampler_ffn_mult": 4,
|
| 58 |
+
"resampler_dropout": 0.0,
|
| 59 |
+
"adapter_act": "silu",
|
| 60 |
+
"mrl_default": 1024,
|
| 61 |
+
"audio_pad_id": 151654,
|
| 62 |
+
"eos_id": 151645,
|
| 63 |
+
"pad_id": 151643,
|
| 64 |
+
"audio_pad_token": "<|audio_pad|>",
|
| 65 |
+
"base_model": "Qwen/Qwen3-VL-Embedding-2B",
|
| 66 |
+
"audio_model": "Qwen/Qwen2.5-Omni-7B",
|
| 67 |
+
"max_text_tokens": 512,
|
| 68 |
+
"n_decoder_layers": 28,
|
| 69 |
+
"architectures": [
|
| 70 |
+
"FusionEmbeddingModel"
|
| 71 |
+
],
|
| 72 |
+
"auto_map": {
|
| 73 |
+
"AutoConfig": "configuration_fusion_embedding.FusionEmbeddingConfig",
|
| 74 |
+
"AutoModel": "modeling_fusion_embedding.FusionEmbeddingModel"
|
| 75 |
}
|
| 76 |
}
|
configuration_fusion_embedding.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HF remote-code configuration for the fusion-embedding family.
|
| 2 |
+
|
| 3 |
+
Lives on the model repos (EximiusLabs/fusion-embedding-1-2b-preview and
|
| 4 |
+
EximiusLabs/fusion-embedding-2-2b-preview) next to modeling_fusion_embedding.py so the
|
| 5 |
+
models load with plain transformers:
|
| 6 |
+
|
| 7 |
+
from transformers import AutoModel
|
| 8 |
+
model = AutoModel.from_pretrained(
|
| 9 |
+
"EximiusLabs/fusion-embedding-1-2b-preview", trust_remote_code=True)
|
| 10 |
+
|
| 11 |
+
The config carries the trained-connector dimensions plus the frozen-component repo
|
| 12 |
+
names; the frozen Qwen3-VL-Embedding base and Qwen2.5-Omni audio tower are NOT part of
|
| 13 |
+
this checkpoint — they are fetched from their own repositories at first use.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from transformers import PretrainedConfig
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FusionEmbeddingConfig(PretrainedConfig):
|
| 22 |
+
model_type = "fusion-embedding-connector"
|
| 23 |
+
|
| 24 |
+
def __init__(
|
| 25 |
+
self,
|
| 26 |
+
d_audio: int = 3584,
|
| 27 |
+
d_llm: int = 2048,
|
| 28 |
+
n_query: int = 64,
|
| 29 |
+
d_resampler: int = 384,
|
| 30 |
+
resampler_depth: int = 6,
|
| 31 |
+
resampler_heads: int = 8,
|
| 32 |
+
resampler_ffn_mult: int = 4,
|
| 33 |
+
resampler_dropout: float = 0.0,
|
| 34 |
+
adapter_rank: int = 0,
|
| 35 |
+
adapter_act: str = "silu",
|
| 36 |
+
mrl_dims=(2048, 1536, 1024, 512, 256, 128, 64),
|
| 37 |
+
mrl_default: int = 1024,
|
| 38 |
+
audio_pad_id: int = 151654,
|
| 39 |
+
eos_id: int = 151645,
|
| 40 |
+
pad_id: int = 151643,
|
| 41 |
+
audio_pad_token: str = "<|audio_pad|>",
|
| 42 |
+
base_model: str = "Qwen/Qwen3-VL-Embedding-2B",
|
| 43 |
+
audio_model: str = "Qwen/Qwen2.5-Omni-7B",
|
| 44 |
+
max_text_tokens: int = 512,
|
| 45 |
+
n_decoder_layers: int = 28,
|
| 46 |
+
**kwargs,
|
| 47 |
+
):
|
| 48 |
+
self.d_audio = d_audio
|
| 49 |
+
self.d_llm = d_llm
|
| 50 |
+
self.n_query = n_query
|
| 51 |
+
self.d_resampler = d_resampler
|
| 52 |
+
self.resampler_depth = resampler_depth
|
| 53 |
+
self.resampler_heads = resampler_heads
|
| 54 |
+
self.resampler_ffn_mult = resampler_ffn_mult
|
| 55 |
+
self.resampler_dropout = resampler_dropout
|
| 56 |
+
self.adapter_rank = adapter_rank or 0
|
| 57 |
+
self.adapter_act = adapter_act or "silu"
|
| 58 |
+
self.mrl_dims = list(mrl_dims)
|
| 59 |
+
self.mrl_default = mrl_default
|
| 60 |
+
self.audio_pad_id = audio_pad_id
|
| 61 |
+
self.eos_id = eos_id
|
| 62 |
+
self.pad_id = pad_id
|
| 63 |
+
self.audio_pad_token = audio_pad_token
|
| 64 |
+
self.base_model = base_model
|
| 65 |
+
self.audio_model = audio_model
|
| 66 |
+
self.max_text_tokens = max_text_tokens
|
| 67 |
+
self.n_decoder_layers = n_decoder_layers
|
| 68 |
+
super().__init__(**kwargs)
|
modeling_fusion_embedding.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HF remote-code modeling for the fusion-embedding family (AutoModel + trust_remote_code).
|
| 2 |
+
|
| 3 |
+
One embedding space for text, images, and audio. The checkpoint on this repository holds
|
| 4 |
+
ONLY the trained components (perceiver-resampler connector, diagonal text whitening,
|
| 5 |
+
logit scale and — generation 2 — the modality-gated deep adapters); the frozen
|
| 6 |
+
Qwen3-VL-Embedding-2B base and the frozen Qwen2.5-Omni audio tower are downloaded from
|
| 7 |
+
their own repositories on first use and are byte-identical to their releases.
|
| 8 |
+
|
| 9 |
+
from transformers import AutoModel
|
| 10 |
+
model = AutoModel.from_pretrained(
|
| 11 |
+
"EximiusLabs/fusion-embedding-1-2b-preview", trust_remote_code=True)
|
| 12 |
+
t = model.embed_text("a dog barks in the distance")
|
| 13 |
+
a = model.embed_audio("dog.wav")
|
| 14 |
+
i = model.embed_image("dog.jpg")
|
| 15 |
+
|
| 16 |
+
The embed_* methods reproduce the repository's reference ``inference.py`` exactly (same
|
| 17 |
+
chat templates, truncation, pooling, whitening, Matryoshka truncation and normalization);
|
| 18 |
+
outputs are bitwise-identical to that loader on the same hardware. Non-audio inputs never
|
| 19 |
+
execute the generation-2 adapter branch (the gate returns the frozen layers' output
|
| 20 |
+
untouched), so text/image outputs are bit-for-bit those of generation 1 and of the base's
|
| 21 |
+
computation path.
|
| 22 |
+
|
| 23 |
+
Requires: transformers>=4.46 (with the Qwen2.5-Omni model classes), torchvision, pillow,
|
| 24 |
+
soundfile, librosa. A CUDA GPU is recommended (~14 GB at bf16).
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import math
|
| 30 |
+
import os
|
| 31 |
+
from typing import Optional, Union
|
| 32 |
+
|
| 33 |
+
import torch
|
| 34 |
+
import torch.nn as nn
|
| 35 |
+
import torch.nn.functional as F
|
| 36 |
+
from transformers import PreTrainedModel
|
| 37 |
+
|
| 38 |
+
from .configuration_fusion_embedding import FusionEmbeddingConfig
|
| 39 |
+
|
| 40 |
+
DEFAULT_QUERY_INSTRUCTION = "Retrieve images or text relevant to the user's query."
|
| 41 |
+
DOC_INSTRUCTION = "Represent the user's input."
|
| 42 |
+
|
| 43 |
+
_ACTS = {"silu": nn.SiLU, "gelu": nn.GELU, "relu": nn.ReLU}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# --------------------------------------------------------------------------- #
|
| 47 |
+
# helpers (mirrors of the training package, kept self-contained on purpose)
|
| 48 |
+
# --------------------------------------------------------------------------- #
|
| 49 |
+
def _chat(instruction: str, user_content: str) -> str:
|
| 50 |
+
"""The base's official embedding format: system-turn instruction, assistant opener."""
|
| 51 |
+
return (f"<|im_start|>system\n{instruction}<|im_end|>\n"
|
| 52 |
+
f"<|im_start|>user\n{user_content}<|im_end|>\n"
|
| 53 |
+
f"<|im_start|>assistant\n")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def sinusoidal_positions(length: int, dim: int, device, dtype) -> torch.Tensor:
|
| 57 |
+
if dim % 2 != 0:
|
| 58 |
+
pe = sinusoidal_positions(length, dim + 1, device, dtype)
|
| 59 |
+
return pe[:, :dim]
|
| 60 |
+
pos = torch.arange(length, device=device, dtype=torch.float32).unsqueeze(1)
|
| 61 |
+
div = torch.exp(torch.arange(0, dim, 2, device=device, dtype=torch.float32)
|
| 62 |
+
* (-math.log(10000.0) / dim))
|
| 63 |
+
pe = torch.zeros(length, dim, device=device, dtype=torch.float32)
|
| 64 |
+
pe[:, 0::2] = torch.sin(pos * div)
|
| 65 |
+
pe[:, 1::2] = torch.cos(pos * div)
|
| 66 |
+
return pe.to(dtype)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def last_token_pool(hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 70 |
+
lengths = attention_mask.long().sum(dim=1) - 1
|
| 71 |
+
lengths = lengths.clamp(min=0)
|
| 72 |
+
idx = lengths.view(-1, 1, 1).expand(-1, 1, hidden.size(-1))
|
| 73 |
+
return hidden.gather(1, idx).squeeze(1)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def mrl_truncate_normalize(x: torch.Tensor, dim: int) -> torch.Tensor:
|
| 77 |
+
return F.normalize(x[..., :dim], p=2, dim=-1)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TextWhitening(nn.Module):
|
| 81 |
+
"""Diagonal (per-dim, MRL-safe) standardization of frozen text embeddings."""
|
| 82 |
+
|
| 83 |
+
def __init__(self, dim: int):
|
| 84 |
+
super().__init__()
|
| 85 |
+
self.register_buffer("mean", torch.zeros(dim))
|
| 86 |
+
self.register_buffer("std", torch.ones(dim))
|
| 87 |
+
self.register_buffer("fitted", torch.zeros((), dtype=torch.uint8))
|
| 88 |
+
|
| 89 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 90 |
+
if int(self.fitted) == 0:
|
| 91 |
+
return x
|
| 92 |
+
mean = self.mean.to(device=x.device, dtype=x.dtype)
|
| 93 |
+
std = self.std.to(device=x.device, dtype=x.dtype)
|
| 94 |
+
return (x - mean) / std
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class _ResamplerBlock(nn.Module):
|
| 98 |
+
"""Pre-norm: latent self-attention -> cross-attention -> FFN."""
|
| 99 |
+
|
| 100 |
+
def __init__(self, dim: int, heads: int, ffn_mult: int, dropout: float):
|
| 101 |
+
super().__init__()
|
| 102 |
+
self.norm_sa = nn.LayerNorm(dim)
|
| 103 |
+
self.self_attn = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True)
|
| 104 |
+
self.norm_q = nn.LayerNorm(dim)
|
| 105 |
+
self.norm_kv = nn.LayerNorm(dim)
|
| 106 |
+
self.cross_attn = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True)
|
| 107 |
+
self.norm_ff = nn.LayerNorm(dim)
|
| 108 |
+
self.ffn = nn.Sequential(
|
| 109 |
+
nn.Linear(dim, dim * ffn_mult),
|
| 110 |
+
nn.GELU(),
|
| 111 |
+
nn.Dropout(dropout),
|
| 112 |
+
nn.Linear(dim * ffn_mult, dim),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
def forward(self, q, kv, key_padding_mask):
|
| 116 |
+
h = self.norm_sa(q)
|
| 117 |
+
q = q + self.self_attn(h, h, h, need_weights=False)[0]
|
| 118 |
+
h = self.norm_q(q)
|
| 119 |
+
kv_n = self.norm_kv(kv)
|
| 120 |
+
q = q + self.cross_attn(h, kv_n, kv_n, key_padding_mask=key_padding_mask,
|
| 121 |
+
need_weights=False)[0]
|
| 122 |
+
q = q + self.ffn(self.norm_ff(q))
|
| 123 |
+
return q
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class FusionResampler(nn.Module):
|
| 127 |
+
"""Perceiver-resampler: variable-length audio frames -> N fixed latent tokens."""
|
| 128 |
+
|
| 129 |
+
def __init__(self, cfg: FusionEmbeddingConfig):
|
| 130 |
+
super().__init__()
|
| 131 |
+
dr = cfg.d_resampler
|
| 132 |
+
self.in_proj = nn.Linear(cfg.d_audio, dr)
|
| 133 |
+
self.queries = nn.Parameter(torch.empty(cfg.n_query, dr))
|
| 134 |
+
nn.init.normal_(self.queries, std=0.02)
|
| 135 |
+
self.blocks = nn.ModuleList(
|
| 136 |
+
_ResamplerBlock(dr, cfg.resampler_heads, cfg.resampler_ffn_mult,
|
| 137 |
+
cfg.resampler_dropout)
|
| 138 |
+
for _ in range(cfg.resampler_depth)
|
| 139 |
+
)
|
| 140 |
+
self.out_proj = nn.Linear(dr, cfg.d_llm)
|
| 141 |
+
self.out_norm = nn.LayerNorm(cfg.d_llm)
|
| 142 |
+
|
| 143 |
+
def forward(self, frames: torch.Tensor, frame_mask: Optional[torch.Tensor] = None):
|
| 144 |
+
B, T, _ = frames.shape
|
| 145 |
+
if frame_mask is None:
|
| 146 |
+
frame_mask = torch.ones(B, T, dtype=torch.bool, device=frames.device)
|
| 147 |
+
kv = self.in_proj(frames)
|
| 148 |
+
kv = kv + sinusoidal_positions(T, kv.size(-1), kv.device, kv.dtype).unsqueeze(0)
|
| 149 |
+
key_padding = ~frame_mask
|
| 150 |
+
fully_masked = key_padding.all(dim=1)
|
| 151 |
+
if fully_masked.any():
|
| 152 |
+
key_padding = key_padding.clone()
|
| 153 |
+
key_padding[fully_masked, 0] = False
|
| 154 |
+
q = self.queries.unsqueeze(0).expand(B, -1, -1)
|
| 155 |
+
for block in self.blocks:
|
| 156 |
+
q = block(q, kv, key_padding)
|
| 157 |
+
return self.out_norm(self.out_proj(q))
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class AdapterGate:
|
| 161 |
+
"""Depth-counted on/off switch shared by every adapter hook (generation 2)."""
|
| 162 |
+
|
| 163 |
+
__slots__ = ("_depth",)
|
| 164 |
+
|
| 165 |
+
def __init__(self) -> None:
|
| 166 |
+
self._depth = 0
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def active(self) -> bool:
|
| 170 |
+
return self._depth > 0
|
| 171 |
+
|
| 172 |
+
def __enter__(self) -> "AdapterGate":
|
| 173 |
+
self._depth += 1
|
| 174 |
+
return self
|
| 175 |
+
|
| 176 |
+
def __exit__(self, *exc) -> None:
|
| 177 |
+
self._depth -= 1
|
| 178 |
+
if self._depth < 0:
|
| 179 |
+
raise RuntimeError("AdapterGate depth underflow — unbalanced enter/exit")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class GatedAdapter(nn.Module):
|
| 183 |
+
"""Parallel bottleneck adapter: ``h + up(act(down(LN(h))))``, computed in fp32."""
|
| 184 |
+
|
| 185 |
+
def __init__(self, d_model: int, rank: int, act: str = "silu"):
|
| 186 |
+
super().__init__()
|
| 187 |
+
self.norm = nn.LayerNorm(d_model)
|
| 188 |
+
self.down = nn.Linear(d_model, rank, bias=False)
|
| 189 |
+
self.act = _ACTS[act]()
|
| 190 |
+
self.up = nn.Linear(rank, d_model, bias=False)
|
| 191 |
+
nn.init.zeros_(self.up.weight)
|
| 192 |
+
|
| 193 |
+
def forward(self, h: torch.Tensor) -> torch.Tensor:
|
| 194 |
+
return self.up(self.act(self.down(self.norm(h.float())))).to(h.dtype)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _make_hook(adapter: GatedAdapter, gate: AdapterGate):
|
| 198 |
+
def hook(_module, _inputs, output):
|
| 199 |
+
if not gate.active:
|
| 200 |
+
return None # keep original output — bitwise no-op
|
| 201 |
+
if isinstance(output, tuple): # HF decoder layers -> (hidden, ...)
|
| 202 |
+
h = output[0]
|
| 203 |
+
return (h + adapter(h),) + tuple(output[1:])
|
| 204 |
+
return output + adapter(output)
|
| 205 |
+
return hook
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class OmniAudioAdapter(nn.Module):
|
| 209 |
+
"""Frozen Qwen2.5-Omni audio encoder -> (frames [B,T,d_audio], frame_mask [B,T])."""
|
| 210 |
+
|
| 211 |
+
def __init__(self, encoder: nn.Module, d_audio: int):
|
| 212 |
+
super().__init__()
|
| 213 |
+
self.encoder = encoder
|
| 214 |
+
self.d_audio = d_audio
|
| 215 |
+
|
| 216 |
+
@torch.no_grad()
|
| 217 |
+
def forward(self, mel: torch.Tensor, mel_mask: Optional[torch.Tensor] = None):
|
| 218 |
+
B, n_mels, Fdim = mel.shape
|
| 219 |
+
if mel_mask is None:
|
| 220 |
+
feat_lens = torch.full((B,), Fdim, dtype=torch.long, device=mel.device)
|
| 221 |
+
else:
|
| 222 |
+
feat_lens = mel_mask.long().sum(dim=1)
|
| 223 |
+
dtype = next(self.encoder.parameters()).dtype
|
| 224 |
+
per_item = []
|
| 225 |
+
for i in range(B):
|
| 226 |
+
Li = max(int(feat_lens[i].item()), 1)
|
| 227 |
+
feats = mel[i, :, :Li].to(dtype)
|
| 228 |
+
out = self.encoder(input_features=feats,
|
| 229 |
+
feature_lens=torch.tensor([Li], device=mel.device))
|
| 230 |
+
frames = out.last_hidden_state
|
| 231 |
+
if frames.dim() == 3:
|
| 232 |
+
frames = frames[0]
|
| 233 |
+
per_item.append(frames.float())
|
| 234 |
+
T_max = max(f.shape[0] for f in per_item)
|
| 235 |
+
frames_out = mel.new_zeros(B, T_max, self.d_audio)
|
| 236 |
+
frame_mask = torch.zeros(B, T_max, dtype=torch.bool, device=mel.device)
|
| 237 |
+
for i, f in enumerate(per_item):
|
| 238 |
+
frames_out[i, : f.shape[0]] = f
|
| 239 |
+
frame_mask[i, : f.shape[0]] = True
|
| 240 |
+
return frames_out, frame_mask
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# --------------------------------------------------------------------------- #
|
| 244 |
+
# the AutoModel entry point
|
| 245 |
+
# --------------------------------------------------------------------------- #
|
| 246 |
+
class FusionEmbeddingModel(PreTrainedModel):
|
| 247 |
+
"""fusion-embedding for transformers AutoModel (trust_remote_code).
|
| 248 |
+
|
| 249 |
+
The registered submodules are exactly the trained components shipped in this
|
| 250 |
+
repository's ``model.safetensors`` (resampler + text whitening + logit scale
|
| 251 |
+
+ generation-2 adapters). The frozen base and audio tower load lazily from
|
| 252 |
+
their own repositories on the first ``embed_*`` call, onto the device the
|
| 253 |
+
trained components are on at that moment — call ``.to("cuda")`` (or pass
|
| 254 |
+
``device_map``) before embedding.
|
| 255 |
+
"""
|
| 256 |
+
|
| 257 |
+
config_class = FusionEmbeddingConfig
|
| 258 |
+
base_model_prefix = "fusion_embedding"
|
| 259 |
+
main_input_name = "input_ids"
|
| 260 |
+
_supports_flash_attn_2 = False
|
| 261 |
+
|
| 262 |
+
def __init__(self, config: FusionEmbeddingConfig):
|
| 263 |
+
super().__init__(config)
|
| 264 |
+
self.resampler = FusionResampler(config)
|
| 265 |
+
self.text_whitening = TextWhitening(config.d_llm)
|
| 266 |
+
self.logit_scale = nn.Parameter(torch.zeros(1))
|
| 267 |
+
self.audio_adapters: Optional[nn.ModuleList] = None
|
| 268 |
+
if config.adapter_rank and config.adapter_rank > 0:
|
| 269 |
+
self.audio_adapters = nn.ModuleList(
|
| 270 |
+
GatedAdapter(config.d_llm, config.adapter_rank, config.adapter_act)
|
| 271 |
+
for _ in range(config.n_decoder_layers)
|
| 272 |
+
)
|
| 273 |
+
# runtime-only state (plain dict: never in state_dict / parameters / save)
|
| 274 |
+
self._rt: dict = {}
|
| 275 |
+
self.post_init()
|
| 276 |
+
|
| 277 |
+
def _init_weights(self, module): # trained weights always come from the checkpoint
|
| 278 |
+
pass
|
| 279 |
+
|
| 280 |
+
# ------------------------------------------------------------- backbones
|
| 281 |
+
@property
|
| 282 |
+
def _device(self) -> torch.device:
|
| 283 |
+
return self.resampler.out_proj.weight.device
|
| 284 |
+
|
| 285 |
+
def _ensure_backbones(self) -> None:
|
| 286 |
+
if "full" in self._rt:
|
| 287 |
+
return
|
| 288 |
+
from transformers import (AutoConfig, AutoFeatureExtractor, AutoModel,
|
| 289 |
+
AutoProcessor)
|
| 290 |
+
|
| 291 |
+
device, dtype = self._device, torch.bfloat16
|
| 292 |
+
full = AutoModel.from_pretrained(self.config.base_model, trust_remote_code=True,
|
| 293 |
+
dtype=dtype)
|
| 294 |
+
full = full.to(device).eval()
|
| 295 |
+
for p in full.parameters():
|
| 296 |
+
p.requires_grad_(False)
|
| 297 |
+
proc = AutoProcessor.from_pretrained(self.config.base_model, trust_remote_code=True)
|
| 298 |
+
|
| 299 |
+
acfg = AutoConfig.from_pretrained(self.config.audio_model, trust_remote_code=True)
|
| 300 |
+
audio_cfg = acfg.thinker_config.audio_config
|
| 301 |
+
tower = self._load_audio_encoder(audio_cfg, dtype).to(device)
|
| 302 |
+
fe_audio = AutoFeatureExtractor.from_pretrained(self.config.audio_model,
|
| 303 |
+
trust_remote_code=True)
|
| 304 |
+
|
| 305 |
+
self._rt.update(full=full, proc=proc, tok=proc.tokenizer,
|
| 306 |
+
tower=OmniAudioAdapter(tower, self.config.d_audio),
|
| 307 |
+
fe_audio=fe_audio, gate=AdapterGate(), adapter_handles=[])
|
| 308 |
+
|
| 309 |
+
if self.audio_adapters is not None:
|
| 310 |
+
layers = self._find_decoder_layers(full.language_model)
|
| 311 |
+
if len(layers) != len(self.audio_adapters):
|
| 312 |
+
raise RuntimeError(
|
| 313 |
+
f"decoder has {len(layers)} layers but the checkpoint carries "
|
| 314 |
+
f"{len(self.audio_adapters)} adapters")
|
| 315 |
+
gate = self._rt["gate"]
|
| 316 |
+
self._rt["adapter_handles"] = [
|
| 317 |
+
layer.register_forward_hook(_make_hook(ad, gate))
|
| 318 |
+
for layer, ad in zip(layers, self.audio_adapters)
|
| 319 |
+
]
|
| 320 |
+
|
| 321 |
+
def _load_audio_encoder(self, audio_cfg, dtype):
|
| 322 |
+
"""Instantiate the Omni audio encoder and load only ``thinker.audio_tower.*``."""
|
| 323 |
+
import glob
|
| 324 |
+
|
| 325 |
+
from huggingface_hub import snapshot_download
|
| 326 |
+
from safetensors.torch import load_file
|
| 327 |
+
from transformers.models.qwen2_5_omni import modeling_qwen2_5_omni as mod
|
| 328 |
+
|
| 329 |
+
snap = snapshot_download(self.config.audio_model,
|
| 330 |
+
allow_patterns=["*.safetensors", "*.json"])
|
| 331 |
+
encoder = mod.Qwen2_5OmniAudioEncoder(audio_cfg)
|
| 332 |
+
prefix = "thinker.audio_tower."
|
| 333 |
+
collected = {}
|
| 334 |
+
for shard in sorted(glob.glob(os.path.join(snap, "*.safetensors"))):
|
| 335 |
+
for k, v in load_file(shard).items():
|
| 336 |
+
if k.startswith(prefix):
|
| 337 |
+
collected[k[len(prefix):]] = v
|
| 338 |
+
encoder.load_state_dict(collected, strict=False)
|
| 339 |
+
return encoder.to(dtype).eval()
|
| 340 |
+
|
| 341 |
+
@staticmethod
|
| 342 |
+
def _find_decoder_layers(base_lm: nn.Module) -> nn.ModuleList:
|
| 343 |
+
best = None
|
| 344 |
+
for name, mod_ in base_lm.named_modules():
|
| 345 |
+
if isinstance(mod_, nn.ModuleList) and name.rsplit(".", 1)[-1] == "layers":
|
| 346 |
+
if best is None or len(mod_) > len(best):
|
| 347 |
+
best = mod_
|
| 348 |
+
if best is None or len(best) == 0:
|
| 349 |
+
raise ValueError("no decoder ModuleList named 'layers' found in the base")
|
| 350 |
+
return best
|
| 351 |
+
|
| 352 |
+
# ------------------------------------------------------------- internals
|
| 353 |
+
def _finish(self, pooled: torch.Tensor, dim: Optional[int]) -> torch.Tensor:
|
| 354 |
+
dim = dim or self.config.mrl_default
|
| 355 |
+
return mrl_truncate_normalize(pooled.float(), dim).squeeze(0).cpu()
|
| 356 |
+
|
| 357 |
+
def _encode_text_ids(self, ids_t: torch.Tensor) -> torch.Tensor:
|
| 358 |
+
full = self._rt["full"]
|
| 359 |
+
embeds = full.get_input_embeddings()(ids_t)
|
| 360 |
+
out = full.language_model(inputs_embeds=embeds,
|
| 361 |
+
attention_mask=torch.ones_like(ids_t))
|
| 362 |
+
hidden = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
|
| 363 |
+
return last_token_pool(hidden, torch.ones_like(ids_t))
|
| 364 |
+
|
| 365 |
+
# ------------------------------------------------------------- embedding
|
| 366 |
+
@torch.no_grad()
|
| 367 |
+
def embed_text(self, text: str, instruction: str = DEFAULT_QUERY_INSTRUCTION,
|
| 368 |
+
dim: Optional[int] = None) -> torch.Tensor:
|
| 369 |
+
self._ensure_backbones()
|
| 370 |
+
if self._rt["gate"].active:
|
| 371 |
+
raise RuntimeError("adapter gate is open during a text encode — "
|
| 372 |
+
"non-audio inputs must run with the gate closed")
|
| 373 |
+
ids = self._rt["tok"].encode(_chat(instruction, text),
|
| 374 |
+
add_special_tokens=False)[: self.config.max_text_tokens]
|
| 375 |
+
ids_t = torch.tensor([ids], device=self._device)
|
| 376 |
+
pooled = self._encode_text_ids(ids_t)
|
| 377 |
+
return self._finish(self.text_whitening(pooled), dim)
|
| 378 |
+
|
| 379 |
+
@torch.no_grad()
|
| 380 |
+
def embed_audio(self, audio: Union[str, "object"], sr: Optional[int] = None,
|
| 381 |
+
dim: Optional[int] = None) -> torch.Tensor:
|
| 382 |
+
import librosa
|
| 383 |
+
import soundfile as sf
|
| 384 |
+
|
| 385 |
+
self._ensure_backbones()
|
| 386 |
+
if isinstance(audio, (str, os.PathLike)):
|
| 387 |
+
wav, sr = sf.read(str(audio), dtype="float32")
|
| 388 |
+
else:
|
| 389 |
+
wav = audio
|
| 390 |
+
assert sr is not None, "pass sr= when embedding a raw array"
|
| 391 |
+
if getattr(wav, "ndim", 1) > 1:
|
| 392 |
+
wav = wav.mean(axis=1)
|
| 393 |
+
fe_audio = self._rt["fe_audio"]
|
| 394 |
+
target_sr = fe_audio.sampling_rate
|
| 395 |
+
if sr != target_sr:
|
| 396 |
+
wav = librosa.resample(wav, orig_sr=sr, target_sr=target_sr)
|
| 397 |
+
feats = fe_audio(wav, sampling_rate=target_sr, return_tensors="pt",
|
| 398 |
+
return_attention_mask=True, padding="max_length", truncation=True)
|
| 399 |
+
mel = feats["input_features"][0]
|
| 400 |
+
am = feats.get("attention_mask")
|
| 401 |
+
if am is not None:
|
| 402 |
+
mel = mel[:, : int(am[0].sum().item())]
|
| 403 |
+
frames, frame_mask = self._rt["tower"](
|
| 404 |
+
mel.unsqueeze(0).to(self._device),
|
| 405 |
+
torch.ones(1, mel.shape[1], dtype=torch.bool, device=self._device))
|
| 406 |
+
audio_tok = self.resampler(frames, frame_mask)
|
| 407 |
+
cfg = self.config
|
| 408 |
+
ids = torch.tensor([[cfg.audio_pad_id] * cfg.n_query + [cfg.eos_id]],
|
| 409 |
+
device=self._device)
|
| 410 |
+
attention_mask = torch.ones_like(ids)
|
| 411 |
+
full = self._rt["full"]
|
| 412 |
+
embeds = full.get_input_embeddings()(ids).clone()
|
| 413 |
+
embeds[ids == cfg.audio_pad_id] = (
|
| 414 |
+
audio_tok.reshape(-1, audio_tok.size(-1)).to(embeds.dtype))
|
| 415 |
+
with self._rt["gate"]: # adapters ON for audio (gen 2)
|
| 416 |
+
out = full.language_model(inputs_embeds=embeds, attention_mask=attention_mask)
|
| 417 |
+
hidden = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
|
| 418 |
+
pooled = last_token_pool(hidden, attention_mask)
|
| 419 |
+
return self._finish(pooled, dim)
|
| 420 |
+
|
| 421 |
+
@torch.no_grad()
|
| 422 |
+
def embed_image(self, image, dim: Optional[int] = None) -> torch.Tensor:
|
| 423 |
+
from PIL import Image
|
| 424 |
+
|
| 425 |
+
self._ensure_backbones()
|
| 426 |
+
if self._rt["gate"].active:
|
| 427 |
+
# The vision path runs through the same (hook-carrying) decoder layers;
|
| 428 |
+
# non-audio inputs must execute with the gate closed so the adapter
|
| 429 |
+
# branch never runs.
|
| 430 |
+
raise RuntimeError("adapter gate is open during an image embed — "
|
| 431 |
+
"non-audio inputs must run with the gate closed")
|
| 432 |
+
if isinstance(image, (str, os.PathLike)):
|
| 433 |
+
image = Image.open(str(image))
|
| 434 |
+
image = image.convert("RGB")
|
| 435 |
+
text = _chat(DOC_INSTRUCTION, "<|vision_start|><|image_pad|><|vision_end|>")
|
| 436 |
+
inputs = self._rt["proc"](text=[text], images=[image],
|
| 437 |
+
return_tensors="pt").to(self._device)
|
| 438 |
+
h = self._rt["full"](**inputs).last_hidden_state
|
| 439 |
+
pooled = last_token_pool(h, inputs["attention_mask"])
|
| 440 |
+
return self._finish(pooled, dim)
|
| 441 |
+
|
| 442 |
+
# ------------------------------------------------------------- batched
|
| 443 |
+
@torch.no_grad()
|
| 444 |
+
def embed_text_batch(self, texts, instruction: str = DEFAULT_QUERY_INSTRUCTION,
|
| 445 |
+
dim: Optional[int] = None,
|
| 446 |
+
max_tokens: Optional[int] = None) -> torch.Tensor:
|
| 447 |
+
"""Batch text embedding [B, dim] (right-padded, mask-aware last-token pooling)."""
|
| 448 |
+
self._ensure_backbones()
|
| 449 |
+
if self._rt["gate"].active:
|
| 450 |
+
raise RuntimeError("adapter gate is open during a text encode — "
|
| 451 |
+
"non-audio inputs must run with the gate closed")
|
| 452 |
+
cfg, tok = self.config, self._rt["tok"]
|
| 453 |
+
max_tokens = max_tokens or cfg.max_text_tokens
|
| 454 |
+
seqs = [tok.encode(_chat(instruction, t), add_special_tokens=False)[:max_tokens]
|
| 455 |
+
for t in texts]
|
| 456 |
+
L = max(len(s) for s in seqs)
|
| 457 |
+
ids = torch.full((len(seqs), L), cfg.pad_id, dtype=torch.long, device=self._device)
|
| 458 |
+
mask = torch.zeros(len(seqs), L, dtype=torch.long, device=self._device)
|
| 459 |
+
for b, s in enumerate(seqs):
|
| 460 |
+
ids[b, : len(s)] = torch.tensor(s, device=self._device)
|
| 461 |
+
mask[b, : len(s)] = 1
|
| 462 |
+
full = self._rt["full"]
|
| 463 |
+
out = full.language_model(inputs_embeds=full.get_input_embeddings()(ids),
|
| 464 |
+
attention_mask=mask)
|
| 465 |
+
hidden = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
|
| 466 |
+
pooled = self.text_whitening(last_token_pool(hidden, mask))
|
| 467 |
+
return mrl_truncate_normalize(pooled.float(), dim or cfg.mrl_default).cpu()
|
| 468 |
+
|
| 469 |
+
@torch.no_grad()
|
| 470 |
+
def embed_audio_batch(self, wavs, sr: int, dim: Optional[int] = None) -> torch.Tensor:
|
| 471 |
+
"""Batch audio embedding [B, dim] from raw waveform arrays at a common rate."""
|
| 472 |
+
import librosa
|
| 473 |
+
import numpy as np
|
| 474 |
+
|
| 475 |
+
self._ensure_backbones()
|
| 476 |
+
cfg, fe_audio = self.config, self._rt["fe_audio"]
|
| 477 |
+
target_sr = fe_audio.sampling_rate
|
| 478 |
+
prepped = []
|
| 479 |
+
for wav in wavs:
|
| 480 |
+
wav = np.asarray(wav, dtype=np.float32)
|
| 481 |
+
if wav.ndim > 1:
|
| 482 |
+
wav = wav.mean(axis=-1)
|
| 483 |
+
if sr != target_sr:
|
| 484 |
+
wav = librosa.resample(wav, orig_sr=sr, target_sr=target_sr)
|
| 485 |
+
prepped.append(wav)
|
| 486 |
+
feats = fe_audio(prepped, sampling_rate=target_sr, return_tensors="pt",
|
| 487 |
+
return_attention_mask=True, padding="max_length", truncation=True)
|
| 488 |
+
mel, am = feats["input_features"], feats.get("attention_mask")
|
| 489 |
+
if am is not None:
|
| 490 |
+
tmax = int(am.sum(dim=1).max().item())
|
| 491 |
+
mel, am = mel[:, :, :tmax], am[:, :tmax]
|
| 492 |
+
fmask = (am.bool() if am is not None
|
| 493 |
+
else torch.ones(mel.shape[0], mel.shape[2], dtype=torch.bool))
|
| 494 |
+
frames, frame_mask = self._rt["tower"](mel.to(self._device),
|
| 495 |
+
fmask.to(self._device))
|
| 496 |
+
audio_tok = self.resampler(frames, frame_mask)
|
| 497 |
+
ids = torch.tensor([[cfg.audio_pad_id] * cfg.n_query + [cfg.eos_id]] * mel.shape[0],
|
| 498 |
+
device=self._device)
|
| 499 |
+
attention_mask = torch.ones_like(ids)
|
| 500 |
+
full = self._rt["full"]
|
| 501 |
+
embeds = full.get_input_embeddings()(ids).clone()
|
| 502 |
+
embeds[ids == cfg.audio_pad_id] = (
|
| 503 |
+
audio_tok.reshape(-1, audio_tok.size(-1)).to(embeds.dtype))
|
| 504 |
+
with self._rt["gate"]:
|
| 505 |
+
out = full.language_model(inputs_embeds=embeds, attention_mask=attention_mask)
|
| 506 |
+
hidden = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
|
| 507 |
+
pooled = last_token_pool(hidden, attention_mask)
|
| 508 |
+
return mrl_truncate_normalize(pooled.float(), dim or cfg.mrl_default).cpu()
|
| 509 |
+
|
| 510 |
+
# ------------------------------------------------------------- read-out
|
| 511 |
+
@staticmethod
|
| 512 |
+
def center(embs: torch.Tensor) -> torch.Tensor:
|
| 513 |
+
"""Per-modality mean-centering + renormalization (cross-modal ranking readout)."""
|
| 514 |
+
c = embs - embs.mean(dim=0, keepdim=True)
|
| 515 |
+
return F.normalize(c, dim=-1)
|
| 516 |
+
|
| 517 |
+
def forward(self, *args, **kwargs):
|
| 518 |
+
raise NotImplementedError(
|
| 519 |
+
"fusion-embedding is an embedding model: use embed_text(str), "
|
| 520 |
+
"embed_audio(path_or_array, sr=...), embed_image(path_or_PIL), and "
|
| 521 |
+
"center(embs) for cross-modal ranking.")
|