Verm1ion's picture
Add branded HTML landing page at / (additive; endpoints unchanged)
0d61ba5 verified
Raw
History Blame Contribute Delete
15.9 kB
"""
Signature Verification — Stateless REST API (FastAPI)
=====================================================
Stateless compute service for the TÜBİTAK 2209-A signature verification web app.
There is NO database and NO enrollment storage here. Reference embeddings are
passed in per request. All state (enrolled persons, their embeddings) lives in
Supabase and is orchestrated by the Next.js server, which is the ONLY caller of
this API (server-to-server, authenticated with X-API-Key).
Endpoints (all require header X-API-Key except /health):
POST /embed — compute reference embeddings from N signature images
POST /verify — verify a signature image against reference embeddings
POST /verify-pdf — extract signature from a PDF, verify against embeddings
GET /health — liveness (no auth)
Model + preprocessing + decision logic are UNCHANGED (inference.py / pdf_extractor.py).
Run:
uvicorn api:app --host 0.0.0.0 --port 7860
"""
import io
import os
import json
from pathlib import Path
from typing import Optional
import numpy as np
from fastapi import (
FastAPI, File, UploadFile, Form, HTTPException, Request, Header, Depends,
)
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from PIL import Image
from inference import SignatureVerifier
from pdf_extractor import extract_signature_from_pdf, image_to_base64
# ── Configuration (env-driven) ───────────────────────────────────────────
MODEL_PATH = os.environ.get(
"MODEL_PATH", str(Path(__file__).parent / "best_model.pth")
)
API_SHARED_SECRET = os.environ.get("API_SHARED_SECRET", "")
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
# Tier 1: raised from model EER (0.8147) to 0.88 to reduce false positives.
DEFAULT_THRESHOLD = float(os.environ.get("DEFAULT_THRESHOLD", "0.88"))
# ── App Setup ────────────────────────────────────────────────────────────
app = FastAPI(
title="Signature Verification API (stateless)",
description="TÜBİTAK 2209-A — İmza Doğrulama (stateless compute)",
version="2.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if ALLOWED_ORIGIN == "*" else [ALLOWED_ORIGIN],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": f"Internal Server Error: {str(exc)}"},
)
# ── Auth dependency ──────────────────────────────────────────────────────
def require_key(x_api_key: Optional[str] = Header(None)):
"""Reject requests without the shared secret. If no secret is configured
(local dev), auth is skipped. In production the secret is always set."""
if not API_SHARED_SECRET:
return
if x_api_key != API_SHARED_SECRET:
raise HTTPException(status_code=401, detail="unauthorized")
# ── Model bootstrap ──────────────────────────────────────────────────────
verifier: Optional[SignatureVerifier] = None
def _ensure_model_path() -> str:
"""Return a local path to the model weights, downloading from a private
Hugging Face model repo if the file is not present (used on HF Spaces where
the 148MB weight is not committed to the Space repo)."""
if os.path.exists(MODEL_PATH):
return MODEL_PATH
repo = os.environ.get("HF_MODEL_REPO")
if repo:
from huggingface_hub import hf_hub_download
return hf_hub_download(
repo_id=repo,
filename=os.environ.get("HF_MODEL_FILE", "best_model.pth"),
token=os.environ.get("HF_TOKEN"),
)
raise RuntimeError(
f"Model file not found at {MODEL_PATH} and HF_MODEL_REPO not set."
)
@app.on_event("startup")
async def startup():
global verifier
path = _ensure_model_path()
verifier = SignatureVerifier(path)
print(f"Stateless API ready. Default threshold={DEFAULT_THRESHOLD} "
f"(model EER: {verifier.threshold:.6f})")
# ── Helpers ──────────────────────────────────────────────────────────────
async def read_image(file: UploadFile) -> np.ndarray:
"""Read an uploaded image file into a numpy RGB array."""
contents = await file.read()
image = Image.open(io.BytesIO(contents)).convert("RGB")
return np.array(image)
def _parse_reference_embeddings(reference_embeddings: str) -> list[np.ndarray]:
"""Parse the JSON list-of-lists sent by the Next.js server into a list of
256-dim float32 numpy vectors."""
try:
raw = json.loads(reference_embeddings)
except Exception:
raise HTTPException(
status_code=400, detail="reference_embeddings geçerli JSON değil."
)
if not isinstance(raw, list) or len(raw) == 0:
raise HTTPException(
status_code=400, detail="reference_embeddings boş veya hatalı."
)
return [np.asarray(v, dtype=np.float32) for v in raw]
def _decide(query_embedding: np.ndarray, refs: list[np.ndarray], threshold: float) -> dict:
"""Run the (unchanged) verification decision. Multi-reference when >=2 refs."""
if len(refs) >= 2:
return verifier.verify_multi_reference(query_embedding, refs, threshold=threshold)
return verifier.verify(query_embedding, refs[0], threshold=threshold)
# ── Endpoints ────────────────────────────────────────────────────────────
@app.post("/embed", dependencies=[Depends(require_key)])
async def embed(
signatures: list[UploadFile] = File(
..., description="1 veya daha fazla imza görseli"
),
):
"""Compute reference embeddings for a person from 1+ signature images.
Returns the centroid + per-image embeddings (to be stored in Supabase)."""
if len(signatures) == 0:
raise HTTPException(status_code=400, detail="En az 1 imza görseli yükleyin.")
images = [await read_image(s) for s in signatures]
reference_embedding = verifier.compute_reference_embedding(images)
individual_embeddings = verifier.compute_reference_embeddings_list(images)
# Quality check: warn if any reference is far from the centroid.
warnings_list = []
for i in range(len(individual_embeddings)):
sim_to_centroid = verifier.cosine_similarity(
individual_embeddings[i], reference_embedding
)
if sim_to_centroid < 0.70:
warnings_list.append(
f"İmza #{i+1} referans setinden düşük benzerlik gösteriyor "
f"({sim_to_centroid:.3f}). Farklı kalitede veya yanlış imza olabilir."
)
return {
"reference_embedding": reference_embedding.tolist(),
"individual_embeddings": [e.tolist() for e in individual_embeddings],
"num_signatures": len(signatures),
"warnings": warnings_list,
}
@app.post("/verify", dependencies=[Depends(require_key)])
async def verify(
signature: UploadFile = File(..., description="Doğrulanacak imza görseli"),
reference_embeddings: str = Form(..., description="JSON [[256], ...]"),
threshold: Optional[float] = Form(None),
):
"""Verify a signature image against a person's reference embeddings."""
refs = _parse_reference_embeddings(reference_embeddings)
img = await read_image(signature)
query_embedding = verifier.extract_embedding(img)
thr = float(threshold) if threshold is not None else DEFAULT_THRESHOLD
result = _decide(query_embedding, refs, thr)
# Previews: original + what the model sees after Otsu.
original_b64 = image_to_base64(img, max_dim=600)
import cv2
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) if len(img.shape) == 3 else img
_, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
otsu_rgb = cv2.cvtColor(otsu, cv2.COLOR_GRAY2RGB)
otsu_b64 = image_to_base64(otsu_rgb, max_dim=600)
return {
"mode": "image",
"verified": result["verified"],
"confidence": result["confidence"],
"similarity": result["similarity"],
"threshold": result["threshold"],
"verdict": "GERCEK IMZA" if result["verified"] else "SAHTE IMZA",
"min_similarity": result.get("min_similarity"),
"max_similarity": result.get("max_similarity"),
"std_similarity": result.get("std_similarity"),
"consistency": result.get("consistency"),
"uploaded_signature_base64": original_b64,
"otsu_processed_base64": otsu_b64,
}
@app.post("/verify-pdf", dependencies=[Depends(require_key)])
async def verify_pdf(
pdf_file: UploadFile = File(..., description="İmza içeren PDF belgesi"),
reference_embeddings: str = Form(..., description="JSON [[256], ...]"),
threshold: Optional[float] = Form(None),
):
"""Extract a signature from a PDF and verify it against reference embeddings."""
refs = _parse_reference_embeddings(reference_embeddings)
pdf_bytes = await pdf_file.read()
if len(pdf_bytes) < 100:
raise HTTPException(status_code=400, detail="Geçersiz veya boş PDF dosyası.")
extraction = extract_signature_from_pdf(pdf_bytes)
if not extraction["extraction_success"] or extraction["signature_image"] is None:
return JSONResponse(status_code=422, content={
"detail": "PDF'den imza çıkarılamadı.",
"extraction": {
"extraction_success": False,
"scenario_detected": extraction["scenario_detected"],
"steps": extraction["steps"],
"blue_ink_pixel_count": extraction["blue_ink_pixel_count"],
},
})
sig_img = extraction["signature_image"]
query_embedding = verifier.extract_embedding(sig_img)
thr = float(threshold) if threshold is not None else DEFAULT_THRESHOLD
result = _decide(query_embedding, refs, thr)
sig_clean = extraction.get("signature_clean", sig_img)
extracted_b64 = image_to_base64(sig_clean, max_dim=600)
page_b64 = image_to_base64(extraction["page_image_rgb"], max_dim=400)
return {
"mode": "pdf",
"verified": result["verified"],
"confidence": result["confidence"],
"similarity": result["similarity"],
"threshold": result["threshold"],
"verdict": "GERCEK IMZA" if result["verified"] else "SAHTE IMZA",
"min_similarity": result.get("min_similarity"),
"max_similarity": result.get("max_similarity"),
"std_similarity": result.get("std_similarity"),
"consistency": result.get("consistency"),
"extraction": {
"extraction_success": True,
"scenario_detected": extraction["scenario_detected"],
"steps": extraction["steps"],
"signature_bbox": extraction["signature_bbox"],
"blue_ink_pixel_count": extraction["blue_ink_pixel_count"],
},
"extracted_signature_base64": extracted_b64,
"page_preview_base64": page_b64,
}
LANDING_HTML = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signature Verification API</title><style>
:root{--bg:#0f1220;--panel:#171b2e;--ink:#e9ecf5;--sub:#a3aac2;--line:#2a3050;--a:#6f6cff;--b:#33c0ff;--ok:#3ecf8e}
@media(prefers-color-scheme:light){:root{--bg:#f5f7fc;--panel:#fff;--ink:#161a2b;--sub:#5b6180;--line:#e4e8f4;--a:#5561e6;--b:#1499e0;--ok:#1a9e6a}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.6}
.wrap{max-width:760px;margin:0 auto;padding:0 22px}
.hero{text-align:center;padding:60px 20px 26px;background:radial-gradient(900px 340px at 50% -10%,rgba(111,108,255,.20),transparent 60%),radial-gradient(700px 320px at 50% 0,rgba(51,192,255,.14),transparent 55%)}
.hero h1{font-size:2rem;margin:.15em 0;background:linear-gradient(90deg,var(--a),var(--b));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.hero p{color:var(--sub);margin:.2em auto 0;max-width:560px}
.pill{display:inline-block;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--sub);border:1px solid var(--line);border-radius:999px;padding:5px 12px;margin-bottom:14px}
.live{color:var(--ok);font-weight:700}
.grid{display:grid;grid-template-columns:1fr;gap:12px;margin:26px 0}
.ep{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;display:flex;gap:14px;align-items:baseline;flex-wrap:wrap}
.m{font-weight:800;font-size:.72rem;letter-spacing:.05em;padding:3px 9px;border-radius:7px;color:#fff}
.get{background:#2a9d5b}.post{background:#5561e6}
.path{font-family:ui-monospace,Menlo,Consolas,monospace;font-weight:700}
.desc{color:var(--sub);font-size:.92rem;flex:1;min-width:180px}
.auth{font-size:.7rem;color:var(--sub);border:1px solid var(--line);border-radius:6px;padding:2px 7px}
.cta{text-align:center;margin:8px 0 40px}
.btn{display:inline-block;border:1px solid var(--line);border-radius:10px;padding:10px 18px;margin:4px;font-weight:600;color:var(--ink);text-decoration:none;background:var(--panel)}
.btn.p{background:linear-gradient(90deg,var(--a),var(--b));color:#fff;border:none}
footer{color:var(--sub);font-size:.82rem;text-align:center;padding:0 20px 50px}
</style></head><body>
<div class="hero"><div class="pill">TÜBİTAK 2209-A · stateless</div>
<h1>✍️ Signature Verification API</h1>
<p>Offline handwritten-signature verification. <span class="live">● live</span> — a stateless compute service; reference embeddings travel with each request, no data is stored here.</p></div>
<div class="wrap">
<div class="grid">
<div class="ep"><span class="m post">POST</span><span class="path">/embed</span><span class="desc">compute reference embeddings from signature images</span><span class="auth">X-API-Key</span></div>
<div class="ep"><span class="m post">POST</span><span class="path">/verify</span><span class="desc">verify a signature image against reference embeddings</span><span class="auth">X-API-Key</span></div>
<div class="ep"><span class="m post">POST</span><span class="path">/verify-pdf</span><span class="desc">extract a signature from a PDF and verify it</span><span class="auth">X-API-Key</span></div>
<div class="ep"><span class="m get">GET</span><span class="path">/health</span><span class="desc">liveness check</span><span class="auth">public</span></div>
</div>
<div class="cta"><a class="btn p" href="/docs">Interactive docs</a><a class="btn" href="/health">Health</a></div>
</div>
<footer>ConvNeXt-Tiny signature encoder · model: <b>Verm1ion/imza-signature-model</b> · orchestrated server-to-server by a Next.js backend.</footer>
</body></html>"""
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def landing():
return LANDING_HTML
@app.get("/health")
async def health():
return {
"status": "ok",
"model_loaded": verifier is not None,
"threshold_default": DEFAULT_THRESHOLD,
"model_eer_threshold": float(verifier.threshold) if verifier else None,
}