skram
/

skram commited on
Commit
92cec2e
·
verified ·
1 Parent(s): 78aa415

Add custom Inference Endpoints handler for BioCLIP 2 (open_clip ViT-L/14)

Browse files
Files changed (1) hide show
  1. handler.py +107 -0
handler.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Custom handler for Hugging Face Inference Endpoints.
2
+ # Serves imageomics/bioclip-2 (open_clip ViT-L/14, BioCLIP 2, 768-dim embeddings).
3
+ #
4
+ # API contract (POST JSON to the endpoint):
5
+ # {"inputs": {
6
+ # "image": "<base64-encoded image bytes>", # or "https://...jpg" URL
7
+ # "candidate_labels": ["Canis familiaris (dog)", ...], # required for classify
8
+ # "mode": "classify" # default: zero-shot classification over candidate_labels
9
+ # }}
10
+ # -> [{"label": "...", "score": 0.93}, ...] sorted by score desc
11
+ #
12
+ # {"inputs": {"image": "<base64>", "mode": "embed"}}
13
+ # -> {"embedding": [768 floats]} (L2-normalized image embedding)
14
+ #
15
+ # {"inputs": {"text": "...", "mode": "embed_text"}}
16
+ # -> {"embedding": [768 floats]} (L2-normalized text embedding)
17
+
18
+ from typing import Any, Dict, List, Union
19
+
20
+ import base64
21
+ import io
22
+ import urllib.request
23
+
24
+ import open_clip
25
+ import torch
26
+ import torch.nn.functional as F
27
+ from PIL import Image
28
+
29
+
30
+ class EndpointHandler:
31
+ def __init__(self, path: str = ""):
32
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
33
+
34
+ # The endpoint mounts the repo contents at `path`; open_clip's
35
+ # "local-dir:" loader reads open_clip_config.json + weights + tokenizer
36
+ # from there. Fall back to the Hub if the mounted path is empty.
37
+ model_id = f"local-dir:{path}" if path else "hf-hub:imageomics/bioclip-2"
38
+
39
+ load_kwargs = {}
40
+ if self.device == "cuda":
41
+ load_kwargs["precision"] = "fp16" # ~0.9GB VRAM instead of ~1.7GB
42
+ try:
43
+ self.model, _, self.preprocess = open_clip.create_model_and_transforms(
44
+ model_id, **load_kwargs
45
+ )
46
+ except TypeError:
47
+ # open_clip versions without the `precision` factory kwarg
48
+ self.model, _, self.preprocess = open_clip.create_model_and_transforms(model_id)
49
+
50
+ self.model = self.model.to(self.device).eval()
51
+ self.tokenizer = open_clip.get_tokenizer(model_id)
52
+ print(f"[bioclip-2 handler] loaded {model_id} on {self.device}")
53
+
54
+ def _load_image(self, image_field: str) -> Image.Image:
55
+ if image_field.startswith("http://") or image_field.startswith("https://"):
56
+ with urllib.request.urlopen(image_field, timeout=15) as r:
57
+ raw = r.read()
58
+ else:
59
+ if "," in image_field[:64] and image_field[:5].lower() == "data:":
60
+ image_field = image_field.split(",", 1)[1]
61
+ raw = base64.b64decode(image_field)
62
+ return Image.open(io.BytesIO(raw)).convert("RGB")
63
+
64
+ def _autocast(self):
65
+ # fp16 weights on CUDA: align input/weight dtypes via autocast —
66
+ # casting the image tensor manually breaks the first conv in open_clip.
67
+ return torch.autocast(
68
+ device_type=self.device, dtype=torch.float16, enabled=self.device == "cuda"
69
+ )
70
+
71
+ @torch.no_grad()
72
+ def __call__(self, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Any:
73
+ # Some handler generations wrap payloads in a list; accept both.
74
+ if isinstance(data, list):
75
+ data = data[0]
76
+
77
+ inputs = data.pop("inputs", data)
78
+ mode = inputs.pop("mode", "classify")
79
+
80
+ if mode == "embed_text":
81
+ with self._autocast():
82
+ tok = self.tokenizer([inputs["text"]]).to(self.device)
83
+ emb = F.normalize(self.model.encode_text(tok), dim=-1)[0].float()
84
+ return {"embedding": emb.tolist()}
85
+
86
+ image = self._load_image(inputs["image"])
87
+ img = self.preprocess(image).unsqueeze(0).to(self.device)
88
+
89
+ with self._autocast():
90
+ img_emb = F.normalize(self.model.encode_image(img), dim=-1)[0].float()
91
+
92
+ if mode == "embed":
93
+ return {"embedding": img_emb.tolist()}
94
+
95
+ labels: List[str] = inputs["candidate_labels"]
96
+
97
+ with self._autocast():
98
+ tok = self.tokenizer(labels).to(self.device)
99
+ txt_emb = F.normalize(self.model.encode_text(tok), dim=-1).float()
100
+ logits = (self.model.logit_scale.exp() * (img_emb @ txt_emb.T)).float()
101
+
102
+ # Standard CLIP zero-shot scoring: logit_scale * (img . txt^T), softmax
103
+ # over candidates — the recipe from the BioCLIP model card.
104
+ probs = F.softmax(logits.squeeze(0), dim=0)
105
+
106
+ ranked = sorted(zip(labels, probs.tolist()), key=lambda x: -x[1])
107
+ return [{"label": label, "score": float(score)} for label, score in ranked]