File size: 6,089 Bytes
b6e0b0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6ac386
f086d7e
b6e0b0c
 
 
 
 
f086d7e
b6e0b0c
 
 
 
 
 
 
 
 
 
 
 
 
f086d7e
 
 
 
 
 
 
 
 
 
 
b6e0b0c
 
 
 
 
 
 
 
f086d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6e0b0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f086d7e
b6e0b0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f2ed19
b6e0b0c
 
b6ac386
 
 
 
 
 
 
b6e0b0c
 
4f2ed19
b6e0b0c
 
 
 
 
 
f086d7e
b6e0b0c
 
 
 
 
f086d7e
 
 
 
 
 
 
 
 
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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
"""
Weaver β€” OpenCLIP embedding service (Β§9, discovery v2 Β§10.4).

ONE free hosted model (HF Spaces, Docker, CPU) serving the whole vector space:
  - POST /embed         text  -> 512-vec   (search queries, Β§8.4)
  - POST /embed-image   image URLs -> 512-vecs (taste backfill + discovery candidates)
  - GET  /health

Text and images MUST share one space (that's CLIP) β€” so one service, both jobs.
This removes any need for a local machine / Colab: the Space embeds everything.

Deploy: Space (SDK: Docker), push this folder, set EMBED_TOKEN as a secret, point
the app's EMBED_ENDPOINT at https://<user>-<space>.hf.space

All vectors are OpenCLIP ViT-B/32, L2-normalized, 512-dim β€” matching the image
embedding pipeline.
"""

import io
import os
from urllib.parse import urlsplit
from urllib.request import urlopen

import numpy as np
import open_clip
import requests
import torch
import torch.nn as nn
from fastapi import FastAPI, Header, HTTPException
from PIL import Image
from pydantic import BaseModel

MODEL_NAME = "ViT-B-32"
PRETRAINED = "laion2b_s34b_b79k"
EMBED_TOKEN = os.environ.get("EMBED_TOKEN")  # optional shared secret
MAX_IMAGES = 64  # per request (batch to amortize cold start + HTTP)
FETCH_UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/120 Safari/537.36"
)

# LAION aesthetic predictor β€” a single Linear(512, 1) head trained on top of
# CLIP ViT-B/32 embeddings (the SAME vectors we already compute), so quality
# scoring is nearly free: no second model, no extra image fetch/decode. Output is
# roughly the AVA 1..10 aesthetic scale. Weights are the original LAION linear
# predictor for vit_b_32. If the download fails at startup, aesthetic scoring is
# simply disabled (the API returns null and the feed treats it as neutral).
AESTHETIC_URLS = [
    "https://raw.githubusercontent.com/LAION-AI/aesthetic-predictor/main/sa_0_4_vit_b_32_linear.pth",
    "https://github.com/LAION-AI/aesthetic-predictor/raw/main/sa_0_4_vit_b_32_linear.pth",
]

app = FastAPI(title="Weaver OpenCLIP encoder")

device = "cuda" if torch.cuda.is_available() else "cpu"
model, _, preprocess = open_clip.create_model_and_transforms(MODEL_NAME, pretrained=PRETRAINED)
model = model.to(device).eval()
tokenizer = open_clip.get_tokenizer(MODEL_NAME)


def _load_aesthetic_head() -> nn.Module | None:
    """Load the LAION linear aesthetic head for ViT-B/32 (512-dim). None on failure."""
    head = nn.Linear(512, 1)
    for url in AESTHETIC_URLS:
        try:
            with urlopen(url, timeout=30) as resp:  # noqa: S310 β€” fixed trusted URLs
                state = torch.load(io.BytesIO(resp.read()), map_location="cpu")
            head.load_state_dict(state)
            head = head.to(device).eval()
            return head
        except Exception:  # noqa: BLE001 β€” any failure β†’ scoring disabled
            continue
    return None


aesthetic_head = _load_aesthetic_head()


def _check_auth(authorization: str | None) -> None:
    if EMBED_TOKEN and authorization != f"Bearer {EMBED_TOKEN}":
        raise HTTPException(status_code=401, detail="unauthorized")


class TextRequest(BaseModel):
    text: str


class ImageRequest(BaseModel):
    urls: list[str]


@app.get("/health")
def health():
    return {"ok": True, "device": device, "aesthetic": aesthetic_head is not None}


@app.post("/embed")
def embed_text(req: TextRequest, authorization: str | None = Header(default=None)):
    _check_auth(authorization)
    if not req.text.strip():
        raise HTTPException(status_code=422, detail="empty text")
    tokens = tokenizer([req.text]).to(device)
    with torch.no_grad():
        feat = model.encode_text(tokens)
        feat = feat / feat.norm(dim=-1, keepdim=True)
    return {"embedding": feat.cpu().numpy()[0].astype(np.float32).tolist()}


@app.post("/embed-image")
def embed_image(req: ImageRequest, authorization: str | None = Header(default=None)):
    """
    Batch image embedding. Returns one entry per input URL, in order. Failed
    fetches/decodes return null (caller filters) so one bad image never fails
    the batch.
    """
    _check_auth(authorization)
    urls = req.urls[:MAX_IMAGES]
    if not urls:
        raise HTTPException(status_code=422, detail="no urls")

    tensors, slots = [], []
    dims: list[list[int] | None] = [None] * len(urls)
    for i, url in enumerate(urls):
        try:
            # Send a same-origin Referer alongside the browser UA. Some image hosts
            # (e.g. artic.edu's IIIF server) 403 a UA-only request but serve fine
            # when a Referer is present; deriving it from the image's own origin is
            # generic and harmless to hosts that ignore it.
            parts = urlsplit(url)
            referer = f"{parts.scheme}://{parts.netloc}/" if parts.scheme and parts.netloc else url
            r = requests.get(url, headers={"User-Agent": FETCH_UA, "Referer": referer}, timeout=30)
            r.raise_for_status()
            img = Image.open(io.BytesIO(r.content)).convert("RGB")
            dims[i] = [img.width, img.height]  # source-agnostic dims (no layout shift)
            tensors.append(preprocess(img))
            slots.append(i)
        except Exception:  # noqa: BLE001 β€” skip bad image, keep batch
            pass

    out: list[list[float] | None] = [None] * len(urls)
    aesthetics: list[float | None] = [None] * len(urls)
    if tensors:
        batch = torch.stack(tensors).to(device)
        with torch.no_grad():
            feats = model.encode_image(batch)
            feats = feats / feats.norm(dim=-1, keepdim=True)
            # Aesthetic score off the SAME normalized embedding (near-free).
            aes = aesthetic_head(feats).squeeze(-1).cpu().numpy() if aesthetic_head else None
        feats_np = feats.cpu().numpy().astype(np.float32)
        for k, slot in enumerate(slots):
            out[slot] = feats_np[k].tolist()
            if aes is not None:
                aesthetics[slot] = float(aes[k])

    return {"embeddings": out, "dims": dims, "aesthetics": aesthetics}