EcoSort_AI / app.py
SameerSingh2's picture
added
6261452
Raw History Blame Contribute Delete
13.5 kB
import base64
import io
import math
import os
import time
try:
import spaces
except ImportError:
class _SpacesFallback:
@staticmethod
def GPU(*_args, **_kwargs):
def decorator(func):
return func
return decorator
spaces = _SpacesFallback()
import gradio as gr
import numpy as np
import torch
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from PIL import Image
from transformers import ViTForImageClassification, ViTImageProcessor
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
def load_model_safely():
"""Load model with fallback options and proper error handling.
Returns (processor, model, model_id) — model_id names the source that
actually loaded, so the analysis panel reports the real weights in use.
"""
try:
if os.path.exists("./waste-classification"):
print("Loading model from local clone...")
processor = ViTImageProcessor.from_pretrained("./waste-classification")
model = ViTForImageClassification.from_pretrained(
"./waste-classification", attn_implementation="eager"
)
print("Successfully loaded model from local clone")
return processor, model, "local: ./waste-classification"
except Exception as e:
print(f"Failed to load from local clone: {e}")
try:
print("Loading model from HuggingFace...")
processor = ViTImageProcessor.from_pretrained(
"watersplash/waste-classification", cache_dir="./cache"
)
# eager attention so /api/explain can read attention weights (sdpa cannot)
model = ViTForImageClassification.from_pretrained(
"watersplash/waste-classification", cache_dir="./cache", attn_implementation="eager"
)
print("Successfully loaded model from HuggingFace")
return processor, model, "watersplash/waste-classification"
except Exception as e:
print(f"Failed to load from HuggingFace: {e}")
try:
print("Loading base ViT model as fallback...")
processor = ViTImageProcessor.from_pretrained("google/vit-base-patch16-224-in21k")
model = ViTForImageClassification.from_pretrained(
"google/vit-base-patch16-224-in21k",
attn_implementation="eager",
num_labels=12,
id2label={
"0": "battery", "1": "biological", "2": "brown-glass", "3": "cardboard",
"4": "clothes", "5": "green-glass", "6": "metal", "7": "paper",
"8": "plastic", "9": "shoes", "10": "trash", "11": "white-glass",
},
label2id={
"battery": "0", "biological": "1", "brown-glass": "2", "cardboard": "3",
"clothes": "4", "green-glass": "5", "metal": "6", "paper": "7",
"plastic": "8", "shoes": "9", "trash": "10", "white-glass": "11",
},
)
print("Loaded base ViT model as fallback (untrained)")
return processor, model, "google/vit-base-patch16-224-in21k (untrained fallback)"
except Exception as e:
print(f"Failed to load fallback model: {e}")
return None, None, "none"
print("Initializing model...")
processor, model, MODEL_ID = load_model_safely()
if model is not None:
model.to(DEVICE)
model.eval()
CLASS_NAMES = [
"Battery", "Biological", "Brown-glass", "Cardboard", "Clothes",
"Green-glass", "Metal", "Paper", "Plastic", "Shoes", "Trash", "White-glass",
]
# Waste segregation mapping: which bin each class belongs to
BIN_MAP = {
"Biological": "green",
"Cardboard": "blue", "Paper": "blue", "Plastic": "blue", "Metal": "blue",
"Brown-glass": "blue", "Green-glass": "blue", "White-glass": "blue",
"Clothes": "blue", "Shoes": "blue",
"Battery": "red", "Trash": "red",
}
BIN_INFO = {
"green": {"name": "Green Bin", "desc": "Wet / Biodegradable Waste"},
"blue": {"name": "Blue Bin", "desc": "Dry / Recyclable Waste"},
"red": {"name": "Red Bin", "desc": "Hazardous / Reject Waste"},
}
def _classify_waste_impl(image: Image.Image):
"""Classify a PIL image, return (label, confidence, bin_color, top, analysis)"""
if processor is None or model is None:
raise RuntimeError("Model failed to load")
if image.mode != "RGB":
image = image.convert("RGB")
original_size = list(image.size)
started = time.perf_counter()
inputs = processor(images=image, return_tensors="pt")
inputs = {name: tensor.to(DEVICE) for name, tensor in inputs.items()}
with torch.no_grad():
outputs = model(**inputs)
predictions = torch.nn.functional.softmax(outputs.logits, dim=-1)
latency_ms = (time.perf_counter() - started) * 1000
confidence_scores = predictions[0].tolist()
ranked = sorted(range(len(confidence_scores)), key=lambda i: confidence_scores[i], reverse=True)
best_idx = ranked[0]
label = CLASS_NAMES[best_idx]
confidence = confidence_scores[best_idx]
bin_color = BIN_MAP.get(label, "red")
top = [
{"label": CLASS_NAMES[i], "confidence": round(confidence_scores[i], 4)}
for i in ranked[:3]
]
# Shannon entropy in bits; normalised against log2(n) so 0 = certain, 1 = uniform.
entropy = -sum(p * math.log2(p) for p in confidence_scores if p > 0)
max_entropy = math.log2(len(confidence_scores))
margin = confidence_scores[ranked[0]] - confidence_scores[ranked[1]]
analysis = {
"distribution": [
{"label": CLASS_NAMES[i], "confidence": round(confidence_scores[i], 6)}
for i in ranked
],
"entropy_bits": round(entropy, 4),
"entropy_normalized": round(entropy / max_entropy, 4) if max_entropy else 0.0,
"margin": round(margin, 4),
"logit_top": round(outputs.logits[0][best_idx].item(), 4),
"latency_ms": round(latency_ms, 1),
"device": str(next(model.parameters()).device),
"model": {
"id": MODEL_ID,
"architecture": model.config.architectures[0] if model.config.architectures else "ViTForImageClassification",
"parameters": sum(p.numel() for p in model.parameters()),
"input_size": [model.config.image_size, model.config.image_size],
"patch_size": model.config.patch_size,
"hidden_size": model.config.hidden_size,
"num_layers": model.config.num_hidden_layers,
"num_heads": model.config.num_attention_heads,
"num_classes": len(CLASS_NAMES),
"original_image_size": original_size,
"normalize_mean": list(processor.image_mean),
"normalize_std": list(processor.image_std),
},
}
return label, confidence, bin_color, top, analysis
@spaces.GPU(duration=60)
def classify_waste(image: Image.Image):
return _classify_waste_impl(image)
# --- FastAPI app: serves the vanilla JS frontend + classification API ---
app = FastAPI(title="Waste Classification")
@app.post("/api/classify")
async def api_classify(file: UploadFile = File(...)):
if processor is None or model is None:
raise HTTPException(status_code=503, detail="Model failed to load. Please try again later.")
try:
image_bytes = await file.read()
image = Image.open(io.BytesIO(image_bytes))
except Exception:
raise HTTPException(status_code=400, detail="Invalid image file.")
try:
label, confidence, bin_color, top, analysis = classify_waste(image)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Classification failed: {e}")
info = BIN_INFO[bin_color]
return JSONResponse({
"label": label,
"confidence": round(confidence, 4),
"bin_color": bin_color,
"bin_name": info["name"],
"bin_desc": info["desc"],
"top": top,
"analysis": analysis,
})
def _colorize(heat):
"""Map a 0..1 attention map to a blue→cyan→green→yellow→red ramp."""
stops = np.array([
[59, 76, 192], [77, 165, 220], [92, 200, 160],
[237, 205, 74], [214, 86, 51],
], dtype=np.float32)
pos = np.clip(heat, 0.0, 1.0) * (len(stops) - 1)
lo = np.floor(pos).astype(int)
hi = np.clip(lo + 1, 0, len(stops) - 1)
t = (pos - lo)[..., None]
return (stops[lo] * (1 - t) + stops[hi] * t).astype(np.uint8)
@spaces.GPU(duration=60)
def attention_rollout(image: Image.Image):
"""Overlay ViT attention rollout on the image; returns a PNG data URL.
Rollout follows Abnar & Zuidema (2020): average heads per layer, add the
residual connection as an identity, renormalise, then multiply across
layers to trace CLS attention back to the input patches.
"""
if processor is None or model is None:
raise RuntimeError("Model failed to load")
if image.mode != "RGB":
image = image.convert("RGB")
started = time.perf_counter()
inputs = processor(images=image, return_tensors="pt")
inputs = {name: tensor.to(DEVICE) for name, tensor in inputs.items()}
with torch.no_grad():
outputs = model(**inputs, output_attentions=True)
rollout = None
for layer in outputs.attentions:
attn = layer[0].mean(dim=0)
attn = attn + torch.eye(attn.size(0), device=attn.device)
attn = attn / attn.sum(dim=-1, keepdim=True)
rollout = attn if rollout is None else attn @ rollout
grid = model.config.image_size // model.config.patch_size
heat = rollout[0, 1:].reshape(grid, grid).detach().cpu().numpy()
# Rollout is diffuse by nature; stretch the informative band so the
# attended region reads clearly instead of a flat wash.
lo, hi = np.percentile(heat, 55), np.percentile(heat, 99.5)
heat = np.clip((heat - lo) / (hi - lo + 1e-8), 0.0, 1.0) ** 1.3
# Render at most 512px on the long side to keep the payload small.
preview = image.copy()
preview.thumbnail((512, 512), Image.LANCZOS)
heat_img = Image.fromarray((heat * 255).astype(np.uint8)).resize(preview.size, Image.BICUBIC)
heat_arr = np.asarray(heat_img, dtype=np.float32) / 255.0
base = np.asarray(preview, dtype=np.float32)
alpha = (0.72 * heat_arr)[..., None]
blended = base * (1 - alpha) + _colorize(heat_arr).astype(np.float32) * alpha
buffer = io.BytesIO()
Image.fromarray(blended.astype(np.uint8)).save(buffer, format="PNG", optimize=True)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return {
"heatmap": f"data:image/png;base64,{encoded}",
"grid": [grid, grid],
"patches": grid * grid,
"layers": len(outputs.attentions),
"latency_ms": round((time.perf_counter() - started) * 1000, 1),
}
@app.post("/api/explain")
async def api_explain(file: UploadFile = File(...)):
if processor is None or model is None:
raise HTTPException(status_code=503, detail="Model failed to load. Please try again later.")
try:
image_bytes = await file.read()
image = Image.open(io.BytesIO(image_bytes))
except Exception:
raise HTTPException(status_code=400, detail="Invalid image file.")
try:
return JSONResponse(attention_rollout(image))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Explanation failed: {e}")
@app.get("/api/health")
async def health():
return {"status": "ok" if (model is not None and processor is not None) else "model_not_loaded"}
# --- Minimal Gradio Blocks, mounted at /gradio purely to satisfy the Gradio SDK ---
@spaces.GPU(duration=60)
def _gradio_classify(image):
if image is None:
return {"Please upload an image": 1.0}
try:
label, confidence, bin_color, _top, _analysis = classify_waste(image)
info = BIN_INFO[bin_color]
return {f"{label} → {info['name']}": confidence}
except Exception as e:
return {f"Error: {e}": 1.0}
with gr.Blocks(title="Waste Classification (debug UI)") as demo:
gr.Markdown("### Debug UI — the real app lives at the Space root URL")
gr.Interface(
fn=_gradio_classify,
inputs=gr.Image(type="pil", label="Upload Waste Image"),
outputs=gr.Textbox(label="Result"),
)
# Serve the vanilla JS frontend at the root when the FastAPI app is used outside ZeroGPU.
app.mount("/", StaticFiles(directory="static", html=True), name="static")
@spaces.GPU(duration=60)
def _zerogpu_classify(image):
if image is None:
return "Please upload an image."
label, confidence, bin_color, _top, _analysis = _classify_waste_impl(image)
info = BIN_INFO[bin_color]
return (
f"Prediction: {label}\n"
f"Confidence: {confidence:.1%}\n"
f"Bin: {info['name']}\n"
f"Description: {info['desc']}"
)
with gr.Blocks(title="Waste Classification") as zerogpu_demo:
gr.Markdown("### Waste Classification")
zerogpu_image = gr.Image(type="pil", label="Upload Waste Image")
zerogpu_button = gr.Button("Classify")
zerogpu_result = gr.Textbox(label="Result")
zerogpu_button.click(
fn=_zerogpu_classify,
inputs=zerogpu_image,
outputs=zerogpu_result,
api_name="classify",
)
if __name__ == "__main__":
zerogpu_demo.launch(server_name="0.0.0.0", server_port=7860)