ModelByNikhil's picture
Update app.py
5245eff verified
Raw History Blame Contribute Delete
9.16 kB
"""
app.py β€” Fashion Recommendation API (CLIP + FAISS + Re-ranking)
"""
import os
import io
import pickle
import tempfile
import requests
import numpy as np
import pandas as pd
from PIL import Image
from contextlib import asynccontextmanager
from fastapi import FastAPI, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import torch
import open_clip
import faiss
from dotenv import load_dotenv
load_dotenv()
# ─────────────────────────────────────────────
# ENV CONFIG
# ─────────────────────────────────────────────
HF_OWNER = os.getenv("HF_DATASET_OWNER")
HF_REPO = os.getenv("HF_DATASET_REPO")
CLOUDINARY_CLOUD = os.getenv("CLOUDINARY_CLOUD_NAME")
if not all([HF_OWNER, HF_REPO, CLOUDINARY_CLOUD]):
raise ValueError("❌ Missing env vars: HF_DATASET_OWNER, HF_DATASET_REPO, CLOUDINARY_CLOUD_NAME")
BASE_HF = f"https://huggingface.co/datasets/{HF_OWNER}/{HF_REPO}/resolve/main"
EMBEDDINGS_URL = f"{BASE_HF}/clip_embeddings.pkl"
FILENAMES_URL = f"{BASE_HF}/clip_filenames.pkl"
FAISS_URL = f"{BASE_HF}/faiss_index.bin"
CSV_URL = f"{BASE_HF}/styles.csv"
CLOUDINARY_URL = f"https://res.cloudinary.com/{CLOUDINARY_CLOUD}/image/upload/"
CLIP_MODEL_NAME = "ViT-B-32"
CLIP_PRETRAINED = "openai"
FAISS_NPROBE = 10
ANN_CANDIDATES = 50
FINAL_TOP_K = 5
ml_models = {}
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# ─────────────────────────────────────────────
# HELPERS: Download
# ─────────────────────────────────────────────
def download_bytes(url: str) -> bytes:
print(f" ↓ {url}")
r = requests.get(url, stream=True)
r.raise_for_status()
return r.content
def load_pickle(url: str):
return pickle.loads(download_bytes(url))
def load_csv(url: str) -> pd.DataFrame:
content = download_bytes(url)
return pd.read_csv(io.BytesIO(content), on_bad_lines="skip")
def load_faiss_index(url: str) -> faiss.Index:
data = download_bytes(url)
with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as f:
f.write(data)
tmp_path = f.name
index = faiss.read_index(tmp_path)
os.unlink(tmp_path)
return index
# ─────────────────────────────────────────────
# STARTUP / SHUTDOWN
# ─────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
print(f"πŸš€ Starting up (device={DEVICE})…")
try:
print("Loading CLIP model…")
clip_model, _, preprocess = open_clip.create_model_and_transforms(
CLIP_MODEL_NAME, pretrained=CLIP_PRETRAINED, device=DEVICE
)
clip_model.eval()
print("Downloading embeddings, filenames, FAISS index, styles…")
embeddings = np.array(load_pickle(EMBEDDINGS_URL), dtype="float32")
filenames = load_pickle(FILENAMES_URL)
styles_df = load_csv(CSV_URL)
faiss_index = load_faiss_index(FAISS_URL)
if hasattr(faiss_index, "nprobe"):
faiss_index.nprobe = FAISS_NPROBE
ml_models.update({
"clip_model": clip_model,
"preprocess": preprocess,
"embeddings": embeddings,
"filenames": filenames,
"styles_df": styles_df,
"faiss_index": faiss_index,
})
print(f"βœ… Ready! Index has {faiss_index.ntotal} vectors.")
except Exception as e:
print(f"πŸ›‘ Startup error: {e}")
raise
yield
ml_models.clear()
print("πŸ›‘ Shutdown.")
# ─────────────────────────────────────────────
# APP
# ─────────────────────────────────────────────
app = FastAPI(title="Klaro β€” Fashion Recommendation API (CLIP + FAISS)", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
# ─────────────────────────────────────────────
# CORE: Feature extraction
# ─────────────────────────────────────────────
@torch.no_grad()
def encode_image(img: Image.Image) -> np.ndarray:
preprocess = ml_models["preprocess"]
clip_model = ml_models["clip_model"]
tensor = preprocess(img).unsqueeze(0).to(DEVICE)
features = clip_model.encode_image(tensor)
features = features / features.norm(dim=-1, keepdim=True)
return features.cpu().numpy().astype("float32") # (1, 512)
# ─────────────────────────────────────────────
# CORE: Two-stage retrieval
# ─────────────────────────────────────────────
def faiss_retrieve(query_vec: np.ndarray, k: int):
index = ml_models["faiss_index"]
scores, indices = index.search(query_vec, k)
return scores[0], indices[0]
def rerank(query_vec: np.ndarray, candidate_indices: np.ndarray, top_k: int):
embeddings = ml_models["embeddings"]
candidate_embs = embeddings[candidate_indices]
scores = (candidate_embs @ query_vec.T).flatten()
order = np.argsort(scores)[::-1][:top_k]
return scores[order], candidate_indices[order]
# ─────────────────────────────────────────────
# HELPERS: Metadata ← FIXED (ml_models + new CSV columns)
# ─────────────────────────────────────────────
def get_metadata(filename: str) -> dict:
df = ml_models["styles_df"] # ← was state["styles_df"], now fixed
try:
image_id = int(os.path.splitext(filename)[0])
row = df[df["Product ID"] == image_id]
if not row.empty:
return {
"gender": str(row["Age Group"].values[0]),
"articleType": str(row["Sub Category"].values[0]),
"baseColour": str(row["Base Colour"].values[0]),
"usage": str(row["Season"].values[0]),
"name": str(row["Product Name"].values[0]),
"brand": str(row["Brand"].values[0]),
"price": float(row["Price"].values[0]),
"discountedPrice": float(row["Discounted Price"].values[0]),
}
except Exception as e:
print(f"Metadata error for {filename}: {e}")
return {}
# ─────────────────────────────────────────────
# ENDPOINT
# ─────────────────────────────────────────────
@app.post("/recommend")
async def recommend_fashion(file: UploadFile = File(...)):
try:
contents = await file.read()
img = Image.open(io.BytesIO(contents)).convert("RGB")
# Stage 1: CLIP encode + FAISS ANN
query_vec = encode_image(img) # (1, 512)
_, ann_indices = faiss_retrieve(query_vec, k=ANN_CANDIDATES)
ann_indices = ann_indices[ann_indices >= 0] # remove -1s
# Stage 2: Exact cosine re-rank β†’ top-5
scores, top_indices = rerank(query_vec, ann_indices, top_k=FINAL_TOP_K)
filenames = ml_models["filenames"]
results = []
for idx, score in zip(top_indices, scores):
filename = os.path.basename(filenames[idx])
metadata = get_metadata(filename)
results.append({
"filename": filename,
"image_url": CLOUDINARY_URL + filename,
"metadata": metadata,
"score": float(score),
})
return {"recommendations": results}
except Exception as e:
import traceback
traceback.print_exc()
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/health")
def health():
idx = ml_models.get("faiss_index")
return {
"status": "ok",
"index_size": idx.ntotal if idx else 0,
"device": DEVICE,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)