← back to Handbag Auth Nextjs
python-matcher/api/server.py
244 lines
"""
FastAPI server for handbag image matching
Provides endpoints for visual similarity search using vector embeddings
"""
import os
import sys
import io
import psycopg2
import requests
from typing import Optional
from fastapi import FastAPI, UploadFile, File, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from PIL import Image
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from models.handbag_embedder import image_to_vec
# Configuration
DB_DSN = os.environ.get("DATABASE_URL", "postgresql://localhost/handbags")
API_PORT = int(os.environ.get("API_PORT", "8000"))
# Initialize FastAPI
app = FastAPI(
title="Handbag Visual Matching API",
description="Vector-based image similarity search for handbag identification",
version="1.0.0"
)
# CORS - lock down in production
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: Restrict to specific domains
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def get_conn():
"""Get database connection"""
return psycopg2.connect(DB_DSN)
class HandbagURLRequest(BaseModel):
image_url: str = Field(..., description="URL of handbag image to match")
top_k: int = Field(5, ge=1, le=50, description="Number of top matches to return")
min_similarity: float = Field(0.0, ge=0.0, le=1.0, description="Minimum similarity score (0-1)")
class HandbagMatch(BaseModel):
id: int
sku: str
brand: str
model_name: str
pattern_name: Optional[str]
colorway: Optional[str]
material: Optional[str]
hardware: Optional[str]
size: Optional[str]
condition: Optional[str]
price_usd: Optional[float]
image_url: str
similarity: float
source: Optional[str]
def query_handbags(vec, top_k=5, min_similarity=0.0):
"""
Query handbags by vector similarity
Args:
vec: 768-dim vector as list
top_k: Number of results
min_similarity: Minimum similarity threshold (0-1)
Returns:
List of matching handbags with similarity scores
"""
vec_str = "[" + ",".join(str(x) for x in vec) + "]"
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
id, sku, brand, model_name, pattern_name,
colorway, material, hardware, size, condition,
price_usd, image_url, source,
1 - (embedding <-> %s::vector) AS similarity
FROM handbags
WHERE embedding IS NOT NULL
AND is_active = true
AND 1 - (embedding <-> %s::vector) >= %s
ORDER BY embedding <-> %s::vector
LIMIT %s;
""",
(vec_str, vec_str, min_similarity, vec_str, top_k)
)
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
return [dict(zip(cols, r)) for r in rows]
finally:
conn.close()
@app.get("/")
def root():
"""Health check endpoint"""
return {
"service": "Handbag Visual Matching API",
"version": "1.0.0",
"status": "healthy"
}
@app.get("/stats")
def get_stats():
"""Get database statistics"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
COUNT(*) as total_handbags,
COUNT(embedding) as embedded_handbags,
COUNT(DISTINCT brand) as unique_brands,
COUNT(DISTINCT model_name) as unique_models
FROM handbags
WHERE is_active = true;
""")
row = cur.fetchone()
return {
"total_handbags": row[0],
"embedded_handbags": row[1],
"unique_brands": row[2],
"unique_models": row[3],
"embedding_coverage": f"{(row[1]/row[0]*100):.1f}%" if row[0] > 0 else "0%"
}
finally:
conn.close()
@app.post("/handbags/match/url", response_model=dict)
def match_handbag_by_url(req: HandbagURLRequest):
"""
Match handbag from image URL
Returns top K similar handbags from catalog
"""
try:
headers = {'User-Agent': 'Mozilla/5.0 (compatible; HandbagMatcher/1.0)'}
resp = requests.get(req.image_url, timeout=10, headers=headers)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content)).convert("RGB")
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Could not fetch or read image: {str(e)}"
)
# Generate embedding
vec = image_to_vec(img)
# Query database
results = query_handbags(vec, top_k=req.top_k, min_similarity=req.min_similarity)
return {
"query_image": req.image_url,
"matches": results,
"count": len(results)
}
@app.post("/handbags/match/upload", response_model=dict)
async def match_handbag_upload(
file: UploadFile = File(..., description="Handbag image file"),
top_k: int = Query(5, ge=1, le=50, description="Number of matches"),
min_similarity: float = Query(0.0, ge=0.0, le=1.0, description="Minimum similarity")
):
"""
Match handbag from uploaded image file
Accepts JPEG, PNG, WebP formats
Returns top K similar handbags from catalog
"""
# Read file
content = await file.read()
try:
img = Image.open(io.BytesIO(content)).convert("RGB")
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid image file: {str(e)}"
)
# Generate embedding
vec = image_to_vec(img)
# Query database
results = query_handbags(vec, top_k=top_k, min_similarity=min_similarity)
return {
"query_image": file.filename,
"matches": results,
"count": len(results)
}
@app.get("/handbags/{sku}")
def get_handbag_by_sku(sku: str):
"""Get handbag details by SKU"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT id, sku, brand, model_name, pattern_name,
colorway, material, hardware, size, condition,
price_usd, price_jpy, image_url, source,
verified, created_at, updated_at
FROM handbags
WHERE sku = %s AND is_active = true;
""", (sku,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Handbag not found")
cols = [d[0] for d in cur.description]
return dict(zip(cols, row))
finally:
conn.close()
if __name__ == "__main__":
import uvicorn
print(f"🚀 Starting Handbag Matching API on port {API_PORT}")
print(f"📊 Database: {DB_DSN}")
uvicorn.run(app, host="0.0.0.0", port=API_PORT)