treeops-bioclip / main.py
rudolfseglitis's picture
Deploy TreeOps BioCLIP FastAPI sidecar
463e218 verified
Raw
History Blame Contribute Delete
6.44 kB
"""BioCLIP 2 zero-shot tree identification service.
POST /identify — multipart image + JSON labels → top-5 softmax over candidates.
Optional bearer auth via BIOCLIP_SERVICE_TOKEN (or BIOCLIP_HF_TOKEN).
"""
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import os
import secrets
from contextlib import asynccontextmanager
from typing import Any
import open_clip
import torch
import torch.nn.functional as F
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, UploadFile
from PIL import Image
MODEL_ID = os.environ.get("BIOCLIP_MODEL_ID", "hf-hub:imageomics/bioclip-2").strip()
_model: Any = None
_preprocess: Any = None
_tokenizer: Any = None
_device: torch.device | None = None
_infer_lock = asyncio.Lock()
# Text encode dominates CPU cost for large closed sets — cache by label fingerprint.
_text_cache_key: str | None = None
_text_features: torch.Tensor | None = None
def _expected_token() -> str | None:
token = (
os.environ.get("BIOCLIP_SERVICE_TOKEN", "").strip()
or os.environ.get("BIOCLIP_HF_TOKEN", "").strip()
)
return token or None
def require_bearer(authorization: str | None = Header(default=None)) -> None:
expected = _expected_token()
if not expected:
return
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
got = authorization[len("Bearer ") :].strip()
if not secrets.compare_digest(got, expected):
raise HTTPException(status_code=401, detail="Invalid Bearer token")
def _load_model() -> None:
global _model, _preprocess, _tokenizer, _device
_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model, _, preprocess = open_clip.create_model_and_transforms(MODEL_ID)
model = model.to(_device)
model.eval()
_model = model
_preprocess = preprocess
_tokenizer = open_clip.get_tokenizer(MODEL_ID)
@asynccontextmanager
async def lifespan(_app: FastAPI):
_load_model()
yield
app = FastAPI(title="BioCLIP 2 Tree ID", lifespan=lifespan)
@app.get("/health")
def health() -> dict[str, Any]:
return {
"status": "ok",
"model": MODEL_ID,
"device": str(_device) if _device is not None else "loading",
"auth_required": _expected_token() is not None,
"text_cache": _text_cache_key is not None,
}
def _labels_fingerprint(label_list: list[str]) -> str:
joined = "\n".join(name.strip() for name in label_list)
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
def _text_features_for(label_list: list[str]) -> torch.Tensor:
"""Encode candidate labels once per distinct set (reuse across photos)."""
global _text_cache_key, _text_features
assert _model is not None and _tokenizer is not None and _device is not None
key = _labels_fingerprint(label_list)
if _text_cache_key == key and _text_features is not None:
return _text_features
texts = [f"a photo of {name.strip()}" for name in label_list]
text_tokens = _tokenizer(texts).to(_device)
with torch.no_grad():
feats = _model.encode_text(text_tokens)
feats = F.normalize(feats, dim=-1)
_text_cache_key = key
_text_features = feats
return feats
def _identify_sync(raw: bytes, labels_json: str) -> dict[str, Any]:
if _model is None or _preprocess is None or _tokenizer is None or _device is None:
raise HTTPException(status_code=503, detail="Model not loaded")
try:
label_list = json.loads(labels_json)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="labels must be a JSON array") from exc
if not isinstance(label_list, list) or not label_list:
raise HTTPException(status_code=400, detail="labels must be a non-empty JSON array")
if not all(isinstance(x, str) and x.strip() for x in label_list):
raise HTTPException(status_code=400, detail="each label must be a non-empty string")
# Soft upper bound to avoid OOM on CPU Spaces with huge closed sets.
max_labels = int(os.environ.get("BIOCLIP_MAX_LABELS", "1000"))
if len(label_list) > max_labels:
raise HTTPException(
status_code=400,
detail=f"at most {max_labels} labels allowed (got {len(label_list)})",
)
if not raw:
raise HTTPException(status_code=400, detail="empty image")
try:
pil = Image.open(io.BytesIO(raw)).convert("RGB")
except Exception as exc:
raise HTTPException(status_code=400, detail=f"invalid image: {exc}") from exc
image_tensor = _preprocess(pil).unsqueeze(0).to(_device)
text_features = _text_features_for(label_list)
with torch.no_grad():
image_features = _model.encode_image(image_tensor)
image_features = F.normalize(image_features, dim=-1)
logits = (image_features @ text_features.T).squeeze(0)
probs = F.softmax(logits.float(), dim=-1)
ranked = sorted(
(
{"latin": label_list[i].strip(), "probability": float(probs[i].item())}
for i in range(len(label_list))
),
key=lambda x: x["probability"],
reverse=True,
)
# Absolute closed-set softmax — often tiny across ~800 taxa; do not renorm
# (renorm / genus rollups invent false confidence when the distribution is flat).
return {"top5": ranked[:5]}
@app.post("/identify", dependencies=[Depends(require_bearer)])
async def identify(
image: UploadFile = File(...),
labels: str = Form(...),
) -> dict[str, Any]:
"""Zero-shot classify an image against candidate latin names only."""
raw = await image.read()
# One inference at a time — CPU Spaces OOM/timeout when Vercel fans out 6 photos.
async with _infer_lock:
return await asyncio.to_thread(_identify_sync, raw, labels)
def main() -> None:
import uvicorn
# Hugging Face Spaces inject SPACE_ID and expect port 7860.
default_port = "7860" if os.environ.get("SPACE_ID") else "8090"
port = int(os.environ.get("PORT", default_port))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)
if __name__ == "__main__":
main()