File size: 4,936 Bytes
74ce024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
"""
Custom Hugging Face Inference Endpoint handler for Qwen3-VL-Embedding-8B.

WHERE THIS FILE GOES:
Hugging Face Inference Endpoints look for a `handler.py` file living in the
ROOT of the MODEL REPO you deploy (not in your own project repo). So:

  1. Duplicate Qwen/Qwen3-VL-Embedding-8B into your own namespace on the Hub
     (huggingface.co -> the model page -> "..." menu -> "Duplicate this model"),
     e.g. your-username/qwen3-vl-embedding-endpoint. This copies the weights
     without you having to re-upload ~16GB yourself.
  2. Add this file to that new repo, named exactly `handler.py`, in the repo root.
  3. Add the accompanying `requirements.txt` (see endpoint_requirements.txt)
     to that same repo root.
  4. Deploy an Inference Endpoint from that repo. Because it contains a
     handler.py, Endpoints will use it automatically instead of a default
     pipeline.

WHAT IT DOES:
Loads the model once when the endpoint starts, then on every request:
builds the same system-instruction + text/image conversation your old
local code used, runs a forward pass, takes last-token pooling, L2-normalizes,
and returns the embedding as JSON.

REQUEST FORMAT (what your client should POST):
  {"inputs": {"text": "some product text", "image_base64": "<optional b64>"}}

RESPONSE FORMAT:
  {"embedding": [0.01, -0.02, ...], "dimension": 4096}
"""
from __future__ import annotations

import base64
from io import BytesIO
from typing import Any, Dict, List, Optional

import torch
import torch.nn.functional as F
from PIL import Image
from transformers import AutoModel, AutoProcessor

INSTRUCTION = (
    "Represent this retail product for multimodal fashion, beauty, and home catalog retrieval. "
    "Preserve product identity, category, materials, visible design details, structure, color nuance, and style-relevant attributes."
)

MAX_IMAGE_SIDE = 768


def _format_as_conversation(text: str, has_image: bool) -> List[Dict[str, Any]]:
    content: List[Dict[str, Any]] = []
    if has_image:
        content.append({"type": "image"})
    if text:
        content.append({"type": "text", "text": text})
    if not content:
        content.append({"type": "text", "text": ""})
    return [
        {"role": "system", "content": [{"type": "text", "text": INSTRUCTION}]},
        {"role": "user", "content": content},
    ]


class EndpointHandler:
    def __init__(self, path: str = ""):
        # `path` is filled in by the Endpoints runtime with the local
        # directory the repo (including model weights) was downloaded into.
        self.device = "cuda" if torch.cuda.is_available() else "cpu"
        self.dtype = torch.bfloat16 if self.device == "cuda" else torch.float32

        self.processor = AutoProcessor.from_pretrained(
            path, trust_remote_code=True, local_files_only=True
        )
        self.model = AutoModel.from_pretrained(
            path, trust_remote_code=True, local_files_only=True, torch_dtype=self.dtype
        )
        self.model = self.model.to(self.device).eval()

    def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
        payload = data.get("inputs", data) or {}
        text = payload.get("text") or ""
        image_b64 = payload.get("image_base64")

        image = self._decode_image(image_b64) if image_b64 else None
        _validate(text, image)

        vector = self._compute(text, image)
        return {"embedding": vector, "dimension": len(vector)}

    def _compute(self, text: str, image: Optional[Image.Image]) -> List[float]:
        images = [image] if image is not None else None
        conversation = _format_as_conversation(text, image is not None)
        prompt_text = self.processor.apply_chat_template(
            conversation, tokenize=False, add_generation_prompt=True
        )
        inputs = self.processor(text=[prompt_text], images=images, padding=True, return_tensors="pt")
        inputs = {key: value.to(self.device) for key, value in inputs.items()}

        with torch.inference_mode():
            outputs = self.model(**inputs)
            hidden = outputs.last_hidden_state
            attention_mask = inputs["attention_mask"]
            last_token_index = attention_mask.sum(dim=1) - 1
            embedding = hidden[0, last_token_index[0]]
            embedding = F.normalize(embedding, p=2, dim=0)

        return embedding.detach().cpu().float().tolist()

    @staticmethod
    def _decode_image(image_b64: str) -> Image.Image:
        try:
            image = Image.open(BytesIO(base64.b64decode(image_b64))).convert("RGB")
        except Exception as exc:
            raise ValueError(f"Invalid image_base64: {exc}") from exc
        image.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE), Image.Resampling.BICUBIC)
        return image


def _validate(text: str, image: Optional[Image.Image]) -> None:
    if not text and image is None:
        raise ValueError("Either 'text' or 'image_base64' must be provided")