| """ |
| Custom inference handler for Hugging Face Inference Endpoints. |
| |
| Serves the Marqo GCL ViT-L-14 open_clip checkpoint as a pure encoder: |
| given text and/or image(s), it returns L2-normalized embeddings, exactly |
| matching the notebook's encode_text / encode_image + normalize steps. |
| |
| Request (JSON): |
| { "inputs": { "text": ["a red dress", ...], # optional |
| "image": ["<base64-encoded-image>", ...] } } # optional |
| |
| Response (JSON): |
| { "text_embeddings": [[... 768 floats ...], ...], # if text sent |
| "image_embeddings": [[... 768 floats ...], ...] } # if image sent |
| |
| Fusion (alpha*img + (1-alpha)*txt) is intentionally NOT done here — that |
| happens in the n8n Code node, so this endpoint stays a clean encoder. |
| """ |
|
|
| import os |
| import io |
| import glob |
| import base64 |
|
|
| import torch |
| import open_clip |
| from PIL import Image |
|
|
|
|
| class EndpointHandler: |
| def __init__(self, path: str = ""): |
| |
| self.device = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| |
| arch = os.environ.get("OPEN_CLIP_ARCH", "ViT-L-14") |
|
|
| |
| self.model, _, self.preprocess = open_clip.create_model_and_transforms( |
| arch, pretrained=None |
| ) |
| self.tokenizer = open_clip.get_tokenizer(arch) |
|
|
| ckpt_path = self._find_checkpoint(path) |
| self._load_weights(ckpt_path) |
|
|
| self.model = self.model.to(self.device).eval() |
|
|
| |
| def _find_checkpoint(self, path: str) -> str: |
| |
| env = os.environ.get("CHECKPOINT_FILE") |
| if env: |
| return env if os.path.isabs(env) else os.path.join(path, env) |
|
|
| candidates = ( |
| glob.glob(os.path.join(path, "*.pt")) |
| + glob.glob(os.path.join(path, "*.bin")) |
| + glob.glob(os.path.join(path, "*.safetensors")) |
| ) |
| if not candidates: |
| raise FileNotFoundError( |
| f"No .pt/.bin/.safetensors checkpoint found in '{path}'. " |
| "Set CHECKPOINT_FILE env var to the filename." |
| ) |
| |
| return max(candidates, key=os.path.getsize) |
|
|
| |
| def _load_weights(self, ckpt_path: str) -> None: |
| |
| |
| |
| try: |
| open_clip.load_checkpoint(self.model, ckpt_path, strict=False) |
| return |
| except Exception as e: |
| print(f"[handler] open_clip.load_checkpoint fallback triggered: {e}") |
|
|
| |
| |
| ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False) |
|
|
| if isinstance(ckpt, dict): |
| state_dict = ckpt.get("state_dict", ckpt.get("model", ckpt)) |
| else: |
| state_dict = ckpt |
|
|
| |
| state_dict = { |
| (k[len("module."):] if k.startswith("module.") else k): v |
| for k, v in state_dict.items() |
| } |
|
|
| missing, unexpected = self.model.load_state_dict(state_dict, strict=False) |
| if missing: |
| print(f"[handler] missing keys (first 5): {list(missing)[:5]}") |
| if unexpected: |
| print(f"[handler] unexpected keys (first 5): {list(unexpected)[:5]}") |
|
|
| @torch.no_grad() |
| def __call__(self, data: dict) -> dict: |
| |
| inputs = data.get("inputs", data) or {} |
| if not isinstance(inputs, dict): |
| return {"error": "Send an object under 'inputs' with 'text' and/or 'image'."} |
|
|
| out = {} |
|
|
| |
| texts = inputs.get("text") |
| if texts: |
| if isinstance(texts, str): |
| texts = [texts] |
| tokens = self.tokenizer(texts).to(self.device) |
| feats = self.model.encode_text(tokens) |
| feats = feats / feats.norm(dim=-1, keepdim=True) |
| out["text_embeddings"] = feats.cpu().tolist() |
|
|
| |
| images = inputs.get("image") |
| if images: |
| if isinstance(images, str): |
| images = [images] |
| tensors = [] |
| for b64 in images: |
| raw = base64.b64decode(b64) |
| img = Image.open(io.BytesIO(raw)).convert("RGB") |
| tensors.append(self.preprocess(img)) |
| batch = torch.stack(tensors).to(self.device) |
| feats = self.model.encode_image(batch) |
| feats = feats / feats.norm(dim=-1, keepdim=True) |
| out["image_embeddings"] = feats.cpu().tolist() |
|
|
| if not out: |
| return { |
| "error": "Provide 'text' and/or 'image' (base64) under 'inputs'.", |
| "example": {"inputs": {"text": ["a red dress"]}}, |
| } |
| return out |
|
|