kanhacoderx's picture
Upload 4 files
eafadff verified
from pathlib import Path
from typing import Optional, List
import tempfile
import threading
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from PIL import Image
import torch
from sentence_transformers import SentenceTransformer, util
MODEL_NAME = "Qwen/Qwen3-VL-Embedding-2B"
app = FastAPI(
title="Visual Evidence Verification API",
description=(
"Verifies whether an uploaded image supports a multilingual citizen "
"complaint using Qwen3-VL multimodal embeddings."
),
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # later replace with your Vercel frontend URL
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# =========================
# API Schemas
# =========================
class VerificationResponse(BaseModel):
complaint_text: str
image_match_score: float
verification_status: str
image_supports_complaint: bool
strong_threshold: float
partial_threshold: float
method: str
model: str
class HealthResponse(BaseModel):
status: str
model_name: str
model_loaded: bool
device: str
# =========================
# Service
# =========================
class VisualEvidenceVerifier:
"""
Multilingual image-text verification using Qwen3-VL embeddings.
Logic:
- Encode complaint text
- Encode uploaded image
- Compare embeddings using cosine similarity
- Return match/partial/weak verification result
"""
def __init__(
self,
model_name: str = MODEL_NAME,
strong_threshold: float = 0.55,
partial_threshold: float = 0.35,
):
self.model_name = model_name
self.strong_threshold = strong_threshold
self.partial_threshold = partial_threshold
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model: Optional[SentenceTransformer] = None
self._lock = threading.Lock()
def load_model(self):
"""
Lazy model loading.
This prevents the Space from failing during startup if loading is slow.
First /verify request will load the model.
"""
if self.model is None:
with self._lock:
if self.model is None:
self.model = SentenceTransformer(
self.model_name,
device=self.device,
)
return self.model
def _load_image(self, image_path: Path) -> Image.Image:
try:
return Image.open(image_path).convert("RGB")
except Exception as error:
raise ValueError(f"Invalid image file: {error}")
def _decide_status(self, score: float):
if score >= self.strong_threshold:
return "strong_match", True
if score >= self.partial_threshold:
return "partial_match", True
return "weak_match", False
def verify(
self,
complaint_text: str,
image_path: Path,
) -> VerificationResponse:
if not complaint_text or len(complaint_text.strip()) < 3:
raise ValueError("Complaint text is too short.")
if not image_path.exists():
raise FileNotFoundError(f"Image not found: {image_path}")
model = self.load_model()
image = self._load_image(image_path)
text_embedding = model.encode(
[complaint_text],
convert_to_tensor=True,
normalize_embeddings=True,
)
image_embedding = model.encode(
[image],
convert_to_tensor=True,
normalize_embeddings=True,
)
score = float(util.cos_sim(text_embedding, image_embedding)[0][0])
status, supports = self._decide_status(score)
return VerificationResponse(
complaint_text=complaint_text,
image_match_score=round(score, 4),
verification_status=status,
image_supports_complaint=supports,
strong_threshold=self.strong_threshold,
partial_threshold=self.partial_threshold,
method="qwen3_vl_embedding_image_text_similarity",
model=self.model_name,
)
verifier = VisualEvidenceVerifier()
# =========================
# Routes
# =========================
@app.get("/", response_model=HealthResponse)
def home():
return HealthResponse(
status="running",
model_name=MODEL_NAME,
model_loaded=verifier.model is not None,
device=verifier.device,
)
@app.get("/health", response_model=HealthResponse)
def health():
return HealthResponse(
status="ok",
model_name=MODEL_NAME,
model_loaded=verifier.model is not None,
device=verifier.device,
)
@app.post("/load-model")
def load_model():
"""
Optional endpoint to warm up the model before demo.
First call may take time.
"""
verifier.load_model()
return {
"status": "loaded",
"model": MODEL_NAME,
"device": verifier.device,
}
@app.post("/verify-image-evidence", response_model=VerificationResponse)
async def verify_image_evidence(
complaint_text: str = Form(...),
file: UploadFile = File(...),
):
allowed_extensions = {".jpg", ".jpeg", ".png", ".webp"}
suffix = Path(file.filename).suffix.lower()
if suffix not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Unsupported image type '{suffix}'. Use jpg, jpeg, png, or webp.",
)
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_path = Path(temp_file.name)
temp_file.write(await file.read())
try:
return verifier.verify(
complaint_text=complaint_text,
image_path=temp_path,
)
except Exception as error:
raise HTTPException(status_code=500, detail=str(error))
finally:
if temp_path.exists():
temp_path.unlink()
@app.post("/debug-compare-texts")
def debug_compare_texts(
text_a: str = Form(...),
text_b: str = Form(...),
):
"""
Debug endpoint to verify model embedding similarity for two texts.
Useful before testing image upload.
"""
model = verifier.load_model()
embeddings = model.encode(
[text_a, text_b],
convert_to_tensor=True,
normalize_embeddings=True,
)
score = float(util.cos_sim(embeddings[0], embeddings[1]))
return {
"text_a": text_a,
"text_b": text_b,
"similarity_score": round(score, 4),
"model": MODEL_NAME,
}