logo-extractor / app.py
freebg's picture
Update app.py
c8f2f20 verified
Raw
History Blame Contribute Delete
59.5 kB
"""
Logo Extractor β€” Full Stack SaaS v2.0
========================================
Fixes in this version:
1. MASTER_API_KEY secret support (admin can use it as X-API-Key directly)
2. _save_users() runs in background thread β€” never blocks requests
3. Usage counter update is fire-and-forget (no HF push on every extract)
4. Model cached to /tmp/logo_detector.onnx β€” survives across soft restarts
5. HF Hub model pull uses correct repo path
6. Admin panel fully wired β€” approve/reject/revoke/restore/plan-change
7. Startup model export wrapped with timeout guard
8. All auth paths unified (X-API-Key accepts both user keys AND master key)
HF Space Secrets to set (Settings β†’ Variables and secrets):
ADMIN_PASSWORD β€” Admin panel login password
MASTER_API_KEY β€” Super API key that always works (for your own testing)
APP_SECRET_KEY β€” Backend-server β†’ HF secret (Phase 2)
HF_TOKEN β€” Your HF token (model download + users.json push)
HF_REPO_ID β€” e.g. freebg/logo-extractor
"""
from __future__ import annotations
import os, gc, io, time, base64, json, logging, secrets, threading, uuid
from contextlib import asynccontextmanager
from typing import Optional
from datetime import datetime, timezone
import cv2
import numpy as np
from fastapi import (FastAPI, File, Header, HTTPException,
UploadFile, Request)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
import onnxruntime as ort
from PIL import Image
# ─── Logging ────────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s")
log = logging.getLogger("logo-ex")
# ─── Config ─────────────────────────────────────────────────────────────────
MODEL_PATH = os.getenv("MODEL_PATH", "/tmp/logo_detector.onnx")
MODEL_URL = os.getenv("MODEL_URL", "")
APP_SECRET_KEY = os.getenv("APP_SECRET_KEY", "") # backend→HF
MASTER_API_KEY = os.getenv("MASTER_API_KEY", "") # always-valid super key
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "changeme")
HF_TOKEN = os.getenv("HF_TOKEN", "")
HF_REPO_ID = os.getenv("HF_REPO_ID", "") # e.g. freebg/logo-extractor
CONF_THRESHOLD = float(os.getenv("CONF_THRESHOLD", "0.35"))
IOU_THRESHOLD = float(os.getenv("IOU_THRESHOLD", "0.45"))
CROP_PADDING = int(os.getenv("CROP_PADDING", "10"))
MAX_IMAGE_SIZE = int(os.getenv("MAX_IMAGE_SIZE", "1920"))
INPUT_SIZE = 640
USERS_FILE = "/tmp/users.json" # /tmp survives soft restarts, no perm issues
# ─── Global state ────────────────────────────────────────────────────────────
ort_session: Optional[ort.InferenceSession] = None
_admin_tokens: set[str] = set()
_stats: dict = {
"total_requests": 0, "total_logos": 0, "errors": 0,
"start_time": datetime.now(timezone.utc).isoformat()
}
# ════════════════════════════════════════════════════════════════════════════
# USER DATABASE (/tmp/users.json ←→ HF repo)
# ════════════════════════════════════════════════════════════════════════════
def _empty_db() -> dict:
return {"pending": [], "active": [], "revoked": []}
def _load_users() -> dict:
if os.path.exists(USERS_FILE):
try:
with open(USERS_FILE) as f:
return json.load(f)
except Exception:
pass
return _empty_db()
def _push_to_repo(path: str) -> None:
"""Push users.json to HF repo β€” called in background thread."""
if not (HF_TOKEN and HF_REPO_ID):
return
try:
from huggingface_hub import HfApi
HfApi(token=HF_TOKEN).upload_file(
path_or_fileobj=path,
path_in_repo="users.json",
repo_id=HF_REPO_ID,
repo_type="space",
commit_message="auto: update users.json",
)
log.info("users.json pushed to repo")
except Exception as e:
log.warning("Repo push failed (local OK): %s", e)
def _save_users(db: dict) -> None:
"""Save locally then push to HF repo in background (non-blocking)."""
with open(USERS_FILE, "w") as f:
json.dump(db, f, indent=2)
t = threading.Thread(target=_push_to_repo, args=(USERS_FILE,), daemon=True)
t.start()
def _pull_from_repo() -> None:
"""Pull users.json from HF repo at startup."""
if not (HF_TOKEN and HF_REPO_ID):
log.warning("HF_TOKEN/HF_REPO_ID not set β€” using local users.json only")
return
try:
from huggingface_hub import hf_hub_download
path = hf_hub_download(
repo_id=HF_REPO_ID,
filename="users.json",
repo_type="space",
token=HF_TOKEN,
local_dir="/tmp",
local_dir_use_symlinks=False,
)
# hf_hub_download may save as /tmp/users.json already
if path != USERS_FILE and os.path.exists(path):
import shutil; shutil.copy(path, USERS_FILE)
log.info("users.json pulled from repo (%d bytes)",
os.path.getsize(USERS_FILE))
except Exception as e:
log.warning("Pull failed (first run?): %s", e)
def _gen_key() -> str:
return f"logo-{secrets.token_hex(4)}-{secrets.token_hex(4)}-{secrets.token_hex(3)}"
# ════════════════════════════════════════════════════════════════════════════
# AUTH
# ════════════════════════════════════════════════════════════════════════════
def verify_api_key(key: str) -> Optional[dict]:
"""
Returns user record if valid active key.
Also accepts MASTER_API_KEY (returns None as user = master access).
Raises 401 otherwise.
"""
if not key:
raise HTTPException(401, "X-API-Key header required")
# Master key β€” always valid
if MASTER_API_KEY and key == MASTER_API_KEY:
return None # None = master/admin caller
# User keys
db = _load_users()
for u in db.get("active", []):
if u.get("api_key") == key:
return u
raise HTTPException(401, "Invalid or inactive API key")
def verify_backend_secret(secret: str) -> None:
if not APP_SECRET_KEY:
raise HTTPException(503, "APP_SECRET_KEY not configured")
if secret != APP_SECRET_KEY:
raise HTTPException(401, "Invalid backend secret")
def _check_admin(token: str) -> None:
if not token or token not in _admin_tokens:
raise HTTPException(401, "Invalid admin session β€” please login again")
# ════════════════════════════════════════════════════════════════════════════
# MODEL
# ════════════════════════════════════════════════════════════════════════════
def ensure_model() -> None:
if os.path.exists(MODEL_PATH):
log.info("Model found: %s (%.1f MB)",
MODEL_PATH, os.path.getsize(MODEL_PATH) / 1e6)
return
# Option 1: direct URL
if MODEL_URL:
import urllib.request
log.info("Downloading model from MODEL_URL …")
urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
log.info("Downloaded β†’ %s", MODEL_PATH)
return
# Option 2: openfoodfacts HF model (logo-specific, best quality)
log.info("Trying openfoodfacts/universal-logo-detector …")
try:
import shutil
import onnx # noqa β€” must be importable before ultralytics export
from ultralytics import YOLO
m = YOLO("hf_hub:openfoodfacts/universal-logo-detector")
exported = m.export(format="onnx", imgsz=INPUT_SIZE, opset=17,
simplify=True, dynamic=False)
found = None
for c in [str(exported) if exported else "",
"universal-logo-detector.onnx", "best.onnx"]:
if c and os.path.exists(c):
found = c; break
if not found:
for root, _, files in os.walk("."):
for fn in files:
if fn.endswith(".onnx"):
found = os.path.join(root, fn); break
if found:
shutil.move(found, MODEL_PATH)
log.info("Logo model ready β†’ %s (%.1f MB)",
MODEL_PATH, os.path.getsize(MODEL_PATH) / 1e6)
return
except Exception as e:
log.warning("HF logo model failed: %s", e)
# Option 3: YOLOv8n fallback (general detector β€” logo class not present)
log.warning("Falling back to YOLOv8n (general detector) …")
import shutil
import onnx # noqa
from ultralytics import YOLO
m = YOLO("yolov8n.pt")
m.export(format="onnx", imgsz=INPUT_SIZE, opset=17,
simplify=True, dynamic=False)
for c in ["yolov8n.onnx", "yolov8n/yolov8n.onnx"]:
if os.path.exists(c):
shutil.move(c, MODEL_PATH)
log.info("Fallback model saved β†’ %s", MODEL_PATH)
return
def load_ort() -> ort.InferenceSession:
opts = ort.SessionOptions()
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
opts.intra_op_num_threads = 2
opts.inter_op_num_threads = 1
sess = ort.InferenceSession(MODEL_PATH, sess_options=opts,
providers=["CPUExecutionProvider"])
log.info("ONNX session ready | in=%s out=%s",
sess.get_inputs()[0].shape, sess.get_outputs()[0].shape)
return sess
# ════════════════════════════════════════════════════════════════════════════
# IMAGE PROCESSING
# ════════════════════════════════════════════════════════════════════════════
def preprocess(img: np.ndarray):
h, w = img.shape[:2]
scale = min(INPUT_SIZE / w, INPUT_SIZE / h)
nw, nh = int(w * scale), int(h * scale)
resized = cv2.resize(img, (nw, nh))
px = (INPUT_SIZE - nw) // 2
py = (INPUT_SIZE - nh) // 2
padded = cv2.copyMakeBorder(
resized, py, INPUT_SIZE - nh - py, px, INPUT_SIZE - nw - px,
cv2.BORDER_CONSTANT, value=(114, 114, 114))
rgb = cv2.cvtColor(padded, cv2.COLOR_BGR2RGB)
t = rgb.astype(np.float32) / 255.0
return np.expand_dims(np.transpose(t, (2, 0, 1)), 0), scale, px, py
def _nms(boxes: np.ndarray, scores: np.ndarray, thresh: float) -> list[int]:
if not len(boxes):
return []
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
areas = (x2 - x1) * (y2 - y1)
order = scores.argsort()[::-1]
keep: list[int] = []
while order.size:
i = order[0]; keep.append(int(i))
xx1 = np.maximum(x1[i], x1[order[1:]])
yy1 = np.maximum(y1[i], y1[order[1:]])
xx2 = np.minimum(x2[i], x2[order[1:]])
yy2 = np.minimum(y2[i], y2[order[1:]])
inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-6)
order = order[1:][iou <= thresh]
return keep
def postprocess(raw: np.ndarray, ow: int, oh: int,
scale: float, px: int, py: int) -> list[dict]:
pred = raw[0].T
cx, cy, bw, bh = pred[:, 0], pred[:, 1], pred[:, 2], pred[:, 3]
conf = pred[:, 4:].max(1) if pred.shape[1] > 5 else pred[:, 4]
mask = conf >= CONF_THRESHOLD
if not mask.any():
return []
cx, cy, bw, bh, conf = (cx[mask], cy[mask], bw[mask],
bh[mask], conf[mask])
x1, y1 = cx - bw / 2, cy - bh / 2
x2, y2 = cx + bw / 2, cy + bh / 2
boxes = np.stack([x1, y1, x2, y2], 1)
keep = _nms(boxes, conf, IOU_THRESHOLD)
out = []
for box, score in zip(boxes[keep], conf[keep]):
ox1 = max(0, int((box[0] - px) / scale))
oy1 = max(0, int((box[1] - py) / scale))
ox2 = min(ow, int((box[2] - px) / scale))
oy2 = min(oh, int((box[3] - py) / scale))
if ox2 > ox1 and oy2 > oy1:
out.append({"x1": ox1, "y1": oy1, "x2": ox2, "y2": oy2,
"confidence": round(float(score), 4)})
return out
def crop_b64(img: np.ndarray, box: dict, fmt: str = "PNG") -> tuple:
h, w = img.shape[:2]
x1 = max(0, box["x1"] - CROP_PADDING)
y1 = max(0, box["y1"] - CROP_PADDING)
x2 = min(w, box["x2"] + CROP_PADDING)
y2 = min(h, box["y2"] + CROP_PADDING)
crop = img[y1:y2, x1:x2]
if fmt == "TRANSPARENT":
rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
mask = np.all(rgb > 240, 2).astype(np.uint8) * 255
rgba = np.dstack([rgb, 255 - mask])
pil = Image.fromarray(rgba, "RGBA")
else:
pil = Image.fromarray(cv2.cvtColor(crop, cv2.COLOR_BGR2RGB))
buf = io.BytesIO()
pil.save(buf, format="PNG" if fmt != "JPEG" else "JPEG")
return base64.b64encode(buf.getvalue()).decode(), x2 - x1, y2 - y1
# ════════════════════════════════════════════════════════════════════════════
# HTML UI
# ════════════════════════════════════════════════════════════════════════════
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Logo Extractor API</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--bg:#08080d;--s1:#101018;--s2:#17171f;--bd:#252535;
--ac:#7c5cfc;--a2:#a78bfa;--gn:#22d3a0;--rd:#ff4f6d;--yw:#fbbf24;
--tx:#ededf5;--mu:#72728f;
--ff:'Syne',sans-serif;--fm:'DM Mono',monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--tx);font-family:var(--ff);min-height:100vh}
.w{max-width:1120px;margin:0 auto;padding:0 20px}
/* noise */
body::before{content:'';position:fixed;inset:0;opacity:.03;pointer-events:none;z-index:0;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
.z{position:relative;z-index:1}
/* header */
header{padding:22px 0 0;display:flex;align-items:center;justify-content:space-between}
.logo{display:flex;align-items:center;gap:9px;font-size:18px;font-weight:800;letter-spacing:-.4px}
.logo-i{width:32px;height:32px;background:var(--ac);border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:15px}
.logo span{color:var(--a2)}
.pill{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--mu);font-family:var(--fm);border:1px solid var(--bd);padding:5px 12px;border-radius:20px}
.dot{width:6px;height:6px;border-radius:50%;background:var(--gn);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
/* tabs */
.tabs{display:flex;border-bottom:1px solid var(--bd);margin-top:26px}
.tab{padding:10px 18px;font-size:13px;font-weight:600;cursor:pointer;color:var(--mu);
border:none;border-bottom:2px solid transparent;background:none;font-family:var(--ff);transition:all .15s}
.tab:hover{color:var(--tx)}
.tab.on{color:var(--a2);border-bottom-color:var(--ac)}
.pnl{display:none;padding:26px 0}
.pnl.on{display:block}
/* cards */
.card{background:var(--s1);border:1px solid var(--bd);border-radius:12px;padding:20px}
.card+.card,.card+.mb{margin-top:13px}
.mb{margin-bottom:13px}
.g2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:700px){.g2{grid-template-columns:1fr}}
/* form */
label,lbl{display:block;font-size:11px;font-weight:700;color:var(--mu);letter-spacing:.7px;text-transform:uppercase;margin-bottom:6px}
input,select,textarea{width:100%;background:var(--s2);border:1px solid var(--bd);border-radius:7px;padding:9px 12px;color:var(--tx);font-family:var(--fm);font-size:13px;outline:none;transition:border-color .15s}
input:focus,select:focus,textarea:focus{border-color:var(--ac)}
select option{background:var(--s2)}
.frow{display:flex;gap:8px;align-items:flex-end}
.frow input{flex:1}
.fld{margin-bottom:12px}
/* dropzone */
.dz{border:2px dashed var(--bd);border-radius:11px;padding:32px 16px;text-align:center;cursor:pointer;transition:all .18s;position:relative;overflow:hidden}
.dz:hover,.dz.ov{border-color:var(--ac);background:rgba(124,92,252,.04)}
.dz input{position:absolute;inset:0;opacity:0;cursor:pointer}
.dzi{font-size:28px;margin-bottom:8px;opacity:.3}
.dzt{font-size:13px;color:var(--mu)}
.dzt strong{color:var(--a2)}
#prev{max-width:100%;max-height:190px;border-radius:7px;margin-top:8px;display:none}
/* buttons */
.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 20px;border-radius:7px;
font-family:var(--ff);font-size:13px;font-weight:700;cursor:pointer;border:none;transition:all .15s}
.bp{background:var(--ac);color:#fff}.bp:hover{background:#6a4ee8}.bp:disabled{opacity:.4;cursor:not-allowed}
.bo{background:transparent;color:var(--a2);border:1px solid var(--ac)}.bo:hover{background:rgba(124,92,252,.1)}
.bg{background:rgba(34,211,160,.1);color:var(--gn);border:1px solid rgba(34,211,160,.3)}.bg:hover{background:rgba(34,211,160,.18)}
.br{background:rgba(255,79,109,.08);color:var(--rd);border:1px solid rgba(255,79,109,.3)}.br:hover{background:rgba(255,79,109,.16)}
.bw{background:rgba(251,191,36,.08);color:var(--yw);border:1px solid rgba(251,191,36,.3)}.bw:hover{background:rgba(251,191,36,.15)}
.bfull{width:100%;justify-content:center;margin-top:12px}
/* key toggle */
.kw{position:relative}.kw input{padding-right:38px}
.keye{position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--mu);cursor:pointer;font-size:15px}
/* result */
.ra{background:var(--s2);border:1px solid var(--bd);border-radius:11px;min-height:210px;display:flex;align-items:center;justify-content:center;overflow:hidden}
.ph{text-align:center;color:var(--mu);font-size:13px;padding:20px}
.phi{font-size:32px;margin-bottom:8px;opacity:.22}
.lg{display:grid;grid-template-columns:repeat(auto-fill,minmax(105px,1fr));gap:9px;padding:12px;width:100%}
.lt{background:var(--s1);border:1px solid var(--bd);border-radius:7px;padding:7px;text-align:center;animation:fi .25s ease}
@keyframes fi{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
.lt img{max-width:100%;max-height:70px;object-fit:contain;border-radius:4px}
.lc{font-size:10px;color:var(--gn);font-family:var(--fm);margin-top:4px}
.ld{font-size:10px;color:var(--a2);cursor:pointer;text-decoration:underline;margin-top:2px}
/* stats */
.sr{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:9px;margin-bottom:18px}
.sb{background:var(--s1);border:1px solid var(--bd);border-radius:9px;padding:13px}
.sn{font-size:22px;font-weight:800;color:var(--a2)}.sl{font-size:10px;color:var(--mu);margin-top:2px;font-family:var(--fm)}
/* table */
.tw{overflow-x:auto;border-radius:9px;border:1px solid var(--bd)}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;padding:9px 12px;font-size:10px;color:var(--mu);text-transform:uppercase;letter-spacing:.6px;border-bottom:1px solid var(--bd);background:var(--s2)}
td{padding:10px 12px;border-bottom:1px solid rgba(37,37,53,.7);font-family:var(--fm);vertical-align:middle}
tr:last-child td{border-bottom:none}
/* badges */
.bdg{display:inline-block;padding:2px 9px;border-radius:20px;font-size:10px;font-weight:700}
.bp2{background:rgba(34,211,160,.1);color:var(--gn);border:1px solid rgba(34,211,160,.25)}
.by{background:rgba(251,191,36,.1);color:var(--yw);border:1px solid rgba(251,191,36,.25)}
.br2{background:rgba(255,79,109,.1);color:var(--rd);border:1px solid rgba(255,79,109,.25)}
.bac{background:rgba(124,92,252,.1);color:var(--a2);border:1px solid rgba(124,92,252,.25)}
/* code */
pre{background:var(--s2);border:1px solid var(--bd);border-radius:9px;padding:14px;font-family:var(--fm);font-size:12px;overflow-x:auto;line-height:1.8;color:#c8d0d8}
.cl{font-size:10px;font-weight:700;color:var(--mu);letter-spacing:.7px;text-transform:uppercase;margin-bottom:5px}
.hg{color:var(--gn)}.hp{color:var(--a2)}.hy{color:var(--yw)}
/* progress */
.prog{height:3px;background:var(--bd);border-radius:2px;overflow:hidden;margin-top:7px;display:none}
.pb{height:100%;background:linear-gradient(90deg,var(--ac),var(--a2));width:0;transition:width .3s;border-radius:2px}
/* toast */
#toast{position:fixed;bottom:22px;right:22px;background:var(--s1);border:1px solid var(--bd);
padding:12px 18px;border-radius:9px;font-size:13px;z-index:9999;
transform:translateY(55px);opacity:0;transition:all .25s;max-width:310px}
#toast.on{transform:none;opacity:1}
#toast.ok{border-color:var(--gn);color:var(--gn)}
#toast.er{border-color:var(--rd);color:var(--rd)}
#toast.wn{border-color:var(--yw);color:var(--yw)}
/* spinner */
.sp{display:inline-block;width:15px;height:15px;border:2px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* modal */
.mbg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:100;align-items:center;justify-content:center}
.mbg.on{display:flex}
.mbox{background:var(--s1);border:1px solid var(--bd);border-radius:13px;padding:26px;max-width:460px;width:92%;animation:mi .18s ease}
@keyframes mi{from{transform:scale(.95);opacity:0}to{transform:scale(1);opacity:1}}
.mbox h3{font-size:16px;font-weight:800;margin-bottom:16px}
.mc{float:right;background:none;border:none;color:var(--mu);cursor:pointer;font-size:19px;margin-top:-4px}
footer{padding:26px 0;text-align:center;font-size:11px;color:var(--mu);border-top:1px solid var(--bd);margin-top:36px;font-family:var(--fm)}
</style>
</head>
<body>
<div class="w z">
<header>
<div class="logo"><div class="logo-i">βœ‚</div>Logo<span>Ex</span></div>
<div class="pill"><div class="dot"></div><span id="stxt">checking…</span></div>
</header>
<div class="tabs">
<button class="tab on" onclick="T('extract',this)">βœ‚ Extract</button>
<button class="tab" onclick="T('getkey',this)">πŸ“ Get API Key</button>
<button class="tab" onclick="T('docs',this)">πŸ“– Docs</button>
<button class="tab" onclick="T('admin',this)">πŸ” Admin</button>
</div>
<!-- ═══════ EXTRACT ═══════ -->
<div id="pnl-extract" class="pnl on">
<div class="g2">
<div>
<div class="card">
<label>πŸ”‘ API Key</label>
<div class="kw">
<input type="password" id="akey" placeholder="logo-xxxxxxxx-xxxxxxxx-xxxxxx">
<button class="keye" onclick="toggleK()">πŸ‘</button>
</div>
<div id="kv" style="margin-top:7px;font-size:11px;font-family:var(--fm)"></div>
</div>
<div class="card">
<label>πŸ–Ό Image</label>
<div class="dz" id="dz">
<input type="file" id="fi" accept="image/*" onchange="onF(this)">
<div class="dzi">πŸ–Ό</div>
<div class="dzt">Drop or <strong>click to upload</strong></div>
<div class="dzt" style="font-size:11px;margin-top:3px">JPEG Β· PNG Β· WEBP Β· max 15 MB</div>
<img id="prev">
</div>
<div class="prog" id="prog"><div class="pb" id="pb"></div></div>
</div>
<div class="card">
<label>βš™ Format</label>
<select id="fmt">
<option value="PNG">PNG β€” recommended</option>
<option value="JPEG">JPEG β€” smaller</option>
<option value="TRANSPARENT">Transparent PNG β€” remove white bg</option>
</select>
</div>
<button class="btn bp bfull" id="xb" onclick="doX()">
<span id="xi">βœ‚ Extract Logos</span>
</button>
</div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div style="font-size:11px;font-weight:700;color:var(--mu);text-transform:uppercase;letter-spacing:.6px" id="rt">Result</div>
<button class="btn bo" id="dla" style="display:none;padding:6px 12px;font-size:11px" onclick="dlA()">⬇ All</button>
</div>
<div class="ra" id="ra">
<div class="ph"><div class="phi">βœ‚</div>Upload image &amp; click<br><strong>Extract Logos</strong></div>
</div>
<div id="rm" style="margin-top:8px;font-size:11px;color:var(--mu);font-family:var(--fm)"></div>
</div>
</div>
</div>
<!-- ═══════ GET API KEY ═══════ -->
<div id="pnl-getkey" class="pnl">
<div class="g2">
<div>
<div class="card">
<div style="font-size:17px;font-weight:800;margin-bottom:5px">Request an API Key</div>
<div style="font-size:13px;color:var(--mu);margin-bottom:18px;line-height:1.7">Submit your details. Admin approves and your key is auto-generated β€” usually within 24 h.</div>
<div class="fld"><label>Full Name</label><input id="rn" placeholder="John Doe"></div>
<div class="fld"><label>Email</label><input id="re" placeholder="john@company.com"></div>
<div class="fld"><label>Plan</label>
<select id="rp">
<option value="free">Free β€” 50 extractions/day</option>
<option value="starter">Starter β€” 500/day ($9/mo)</option>
<option value="pro">Pro β€” Unlimited ($29/mo)</option>
</select>
</div>
<div class="fld"><label>Message (optional)</label><textarea id="rm2" rows="3" placeholder="Brief use-case description…"></textarea></div>
<button class="btn bp bfull" onclick="regReq()">πŸ“© Submit Request</button>
<div id="rr" style="margin-top:12px;font-size:13px"></div>
</div>
</div>
<div>
<div class="card mb">
<div style="font-weight:700;margin-bottom:12px">Check Request Status</div>
<label>Your Email</label>
<div class="frow">
<input id="ce" placeholder="your@email.com">
<button class="btn bo" onclick="chkSt()">Check</button>
</div>
<div id="cr" style="margin-top:12px;font-size:12px;font-family:var(--fm)"></div>
</div>
<div class="card">
<div style="font-weight:700;margin-bottom:10px">Plans</div>
<table>
<tr><th>Plan</th><th>Limit</th><th>Price</th></tr>
<tr><td><span class="bdg bac">Free</span></td><td>50/day</td><td style="color:var(--gn)">$0</td></tr>
<tr><td><span class="bdg by">Starter</span></td><td>500/day</td><td>$9/mo</td></tr>
<tr><td><span class="bdg by">Pro</span></td><td>Unlimited</td><td>$29/mo</td></tr>
</table>
</div>
</div>
</div>
</div>
<!-- ═══════ DOCS ═══════ -->
<div id="pnl-docs" class="pnl">
<div style="display:flex;flex-direction:column;gap:16px">
<div class="card">
<div style="font-size:19px;font-weight:800;margin-bottom:6px">Logo Extractor API</div>
<div style="color:var(--mu);font-size:13px;line-height:1.7">Extract logos from any image. Returns cropped logo images as base64 JSON. Supports PNG, JPEG, and transparent background.</div>
</div>
<div class="card">
<div class="cl">Endpoint</div>
<pre><span class="hp">POST</span> <span class="hy">/extract-logo</span>
<span class="hg">X-API-Key:</span> logo-xxxx-xxxx-xxxx
<span class="hg">Content-Type:</span> multipart/form-data
<span class="hg">Body:</span>
image file JPEG/PNG/WEBP, max 15 MB
output_format string PNG | JPEG | TRANSPARENT
max_logos int default 20</pre>
</div>
<div class="card">
<div class="cl">Response</div>
<pre>{
<span class="hg">"success"</span>: true,
<span class="hg">"count"</span>: 2,
<span class="hg">"elapsed_ms"</span>: 280.4,
<span class="hg">"logos"</span>: [{
<span class="hg">"base64"</span>: <span class="hy">"iVBORw0KGgo..."</span>,
<span class="hg">"mime_type"</span>: <span class="hy">"image/png"</span>,
<span class="hg">"width"</span>: 180, <span class="hg">"height"</span>: 90,
<span class="hg">"confidence"</span>: 0.912,
<span class="hg">"bbox"</span>: {<span class="hg">"x1"</span>:45,<span class="hg">"y1"</span>:12,<span class="hg">"x2"</span>:225,<span class="hg">"y2"</span>:102}
}]
}</pre>
</div>
<div class="card">
<div class="cl">Python</div>
<pre>import requests, base64
r = requests.post(
<span class="hy">"https://YOUR-SPACE.hf.space/extract-logo"</span>,
headers={<span class="hy">"X-API-Key"</span>: <span class="hy">"logo-xxxx-xxxx-xxxx"</span>},
files={<span class="hy">"image"</span>: open(<span class="hy">"img.jpg"</span>,<span class="hy">"rb"</span>)},
params={<span class="hy">"output_format"</span>:<span class="hy">"PNG"</span>}
)
for i,l in enumerate(r.json()[<span class="hy">"logos"</span>]):
open(f<span class="hy">"logo_{i}.png"</span>,<span class="hy">"wb"</span>).write(base64.b64decode(l[<span class="hy">"base64"</span>]))</pre>
</div>
<div class="card">
<div class="cl">cURL</div>
<pre>curl -X POST https://YOUR-SPACE.hf.space/extract-logo \
-H <span class="hy">"X-API-Key: logo-xxxx-xxxx-xxxx"</span> \
-F <span class="hy">"image=@logo.jpg"</span></pre>
</div>
</div>
</div>
<!-- ═══════ ADMIN ═══════ -->
<div id="pnl-admin" class="pnl">
<!-- Login -->
<div id="al" class="card" style="max-width:360px">
<div style="font-size:16px;font-weight:800;margin-bottom:14px">πŸ” Admin Login</div>
<label>Password</label>
<input type="password" id="ap" placeholder="Admin password" onkeyup="if(event.key==='Enter')aLogin()">
<button class="btn bp bfull" onclick="aLogin()">Login</button>
<div id="ae" style="margin-top:7px;font-size:12px;color:var(--rd)"></div>
</div>
<!-- Panel -->
<div id="ap2" style="display:none">
<div class="sr">
<div class="sb"><div class="sn" id="a1">0</div><div class="sl">Requests</div></div>
<div class="sb"><div class="sn" id="a2">0</div><div class="sl">Logos</div></div>
<div class="sb"><div class="sn" id="a3" style="color:var(--yw)">0</div><div class="sl">Pending</div></div>
<div class="sb"><div class="sn" id="a4" style="color:var(--gn)">0</div><div class="sl">Active Keys</div></div>
<div class="sb"><div class="sn" id="a5" style="color:var(--rd)">0</div><div class="sl">Revoked</div></div>
</div>
<!-- Pending -->
<div class="card mb">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
<div style="font-weight:700">⏳ Pending Requests</div>
<button class="btn bo" onclick="loadA()" style="padding:6px 11px;font-size:11px">↻ Refresh</button>
</div>
<div class="tw"><table>
<tr><th>Name</th><th>Email</th><th>Plan</th><th>Date</th><th>Message</th><th>Actions</th></tr>
<tbody id="tp"></tbody>
</table></div>
<div id="np" style="text-align:center;padding:18px;color:var(--mu);font-size:13px;display:none">No pending requests βœ“</div>
</div>
<!-- Active -->
<div class="card mb">
<div style="font-weight:700;margin-bottom:14px">βœ… Active Keys</div>
<div class="tw"><table>
<tr><th>Name</th><th>Email</th><th>Plan</th><th>API Key</th><th>Usage</th><th>Last Used</th><th>Approved</th><th>Actions</th></tr>
<tbody id="ta"></tbody>
</table></div>
</div>
<!-- Revoked -->
<div class="card">
<div style="font-weight:700;margin-bottom:14px">🚫 Revoked</div>
<div class="tw"><table>
<tr><th>Name</th><th>Email</th><th>Plan</th><th>Key (masked)</th><th>Actions</th></tr>
<tbody id="tr2"></tbody>
</table></div>
<div id="nr" style="text-align:center;padding:18px;color:var(--mu);font-size:13px;display:none">No revoked keys</div>
</div>
</div>
</div>
</div><!-- /w -->
<footer>Logo Extractor API &nbsp;Β·&nbsp; v2.0 &nbsp;Β·&nbsp; YOLOv8 + ONNX Runtime</footer>
<div id="toast"></div>
<!-- Approve modal -->
<div class="mbg" id="am">
<div class="mbox">
<button class="mc" onclick="cM()">βœ•</button>
<h3>βœ… Approve Request</h3>
<input type="hidden" id="amid">
<div class="fld"><label>Name</label><input id="amn" readonly style="opacity:.55"></div>
<div class="fld"><label>Email</label><input id="ame" readonly style="opacity:.55"></div>
<div class="fld"><label>Plan</label>
<select id="amp"><option value="free">Free</option><option value="starter">Starter</option><option value="pro">Pro</option></select>
</div>
<div class="fld"><label>Generated API Key</label><input id="amk" readonly style="font-family:var(--fm);opacity:.65"></div>
<button class="btn bg bfull" onclick="confApprove()">βœ… Approve &amp; Activate</button>
</div>
</div>
<!-- Plan change modal -->
<div class="mbg" id="pm">
<div class="mbox">
<button class="mc" onclick="cM2()">βœ•</button>
<h3>✏ Change Plan</h3>
<input type="hidden" id="pmid">
<div class="fld"><label>New Plan</label>
<select id="pmp"><option value="free">Free</option><option value="starter">Starter</option><option value="pro">Pro</option></select>
</div>
<button class="btn bp bfull" onclick="confPlan()">Save Plan</button>
</div>
</div>
<script>
var tok=null, logos=[], _adm=null;
// Tabs
function T(n,el){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));
document.querySelectorAll('.pnl').forEach(p=>p.classList.remove('on'));
document.getElementById('pnl-'+n).classList.add('on');
el.classList.add('on');
if(n==='admin'&&tok)loadA();
}
// Toast
function toast(m,t,d){
t=t||'ok';d=d||3000;
var el=document.getElementById('toast');
el.textContent=m;el.className='on '+t;
setTimeout(function(){el.className=''},d);
}
// Health poll
function poll(){
fetch('/health').then(r=>r.json()).then(d=>{
document.getElementById('stxt').textContent=
d.model_loaded?'API Running Β· Model Ready':'Running Β· Loading model…';
}).catch(()=>{document.getElementById('stxt').textContent='connecting…';});
}
poll();setInterval(poll,30000);
// File upload
function onF(inp){
var f=inp.files[0];if(!f)return;
var r=new FileReader();
r.onload=function(e){
var i=document.getElementById('prev');
i.src=e.target.result;i.style.display='block';
document.querySelector('.dzi').style.display='none';
document.querySelectorAll('.dzt').forEach(function(el){el.style.display='none';});
};r.readAsDataURL(f);
}
var dze=document.getElementById('dz');
dze.addEventListener('dragover',function(e){e.preventDefault();dze.classList.add('ov');});
dze.addEventListener('dragleave',function(){dze.classList.remove('ov');});
dze.addEventListener('drop',function(e){
e.preventDefault();dze.classList.remove('ov');
var f=e.dataTransfer.files[0];
if(f){document.getElementById('fi').files=e.dataTransfer.files;onF(document.getElementById('fi'));}
});
function toggleK(){var i=document.getElementById('akey');i.type=i.type==='password'?'text':'password';}
// Extract
async function doX(){
var key=document.getElementById('akey').value.trim();
var fi=document.getElementById('fi');
var fmt=document.getElementById('fmt').value;
if(!key){toast('Enter your API key','er');return;}
if(!fi.files[0]){toast('Upload an image first','er');return;}
var btn=document.getElementById('xb');
var inn=document.getElementById('xi');
btn.disabled=true;
inn.innerHTML='<span class="sp"></span> Extracting…';
var prog=document.getElementById('prog');
var bar=document.getElementById('pb');
prog.style.display='block';bar.style.width='0';
var pv=0;
var pt=setInterval(function(){pv=Math.min(pv+7,80);bar.style.width=pv+'%';},220);
var fd=new FormData();
fd.append('image',fi.files[0]);
try{
var r=await fetch('/extract-logo?output_format='+fmt,{
method:'POST',headers:{'X-API-Key':key},body:fd});
clearInterval(pt);bar.style.width='100%';
setTimeout(function(){prog.style.display='none';bar.style.width='0';},450);
var d=await r.json();
if(!r.ok){toast(d.detail||'Error','er');return;}
logos=d.logos||[];
renderRes(d);
toast('βœ“ '+d.count+' logo'+(d.count!==1?'s':'')+' found Β· '+d.elapsed_ms+'ms');
}catch(e){
clearInterval(pt);toast('Network error β€” try again','er');
}finally{
btn.disabled=false;inn.textContent='βœ‚ Extract Logos';
}
}
function renderRes(d){
var ra=document.getElementById('ra');
var rt=document.getElementById('rt');
var dla=document.getElementById('dla');
var rm=document.getElementById('rm');
if(!d.logos||!d.logos.length){
ra.innerHTML='<div class="ph"><div class="phi">πŸ”</div>No logos detected<br><small style="color:var(--mu)">Try a different image or lower CONF_THRESHOLD</small></div>';
rt.textContent='Result β€” 0 logos';dla.style.display='none';return;
}
rt.textContent='Result β€” '+d.count+' logo'+(d.count!==1?'s':'');
dla.style.display='';
rm.textContent='⚑ '+d.elapsed_ms+'ms · ONNX CPU';
ra.innerHTML='';
var g=document.createElement('div');g.className='lg';
d.logos.forEach(function(l,i){
var div=document.createElement('div');div.className='lt';
div.innerHTML='<img src="data:'+l.mime_type+';base64,'+l.base64+'">'
+'<div class="lc">⚑ '+(l.confidence*100).toFixed(1)+'%</div>'
+'<div class="ld" onclick="dlL('+i+')">⬇ Save</div>';
g.appendChild(div);
});
ra.appendChild(g);
}
function dlL(i){
var l=logos[i];if(!l)return;
var a=document.createElement('a');
a.href='data:'+l.mime_type+';base64,'+l.base64;
a.download='logo_'+(i+1)+'.png';a.click();
}
function dlA(){logos.forEach(function(_,i){setTimeout(function(){dlL(i);},i*80);});}
// Register
async function regReq(){
var name=document.getElementById('rn').value.trim();
var email=document.getElementById('re').value.trim();
var plan=document.getElementById('rp').value;
var msg=document.getElementById('rm2').value.trim();
var out=document.getElementById('rr');
if(!name||!email){toast('Name and email required','er');return;}
if(!email.includes('@')){toast('Valid email required','er');return;}
try{
var r=await fetch('/api/register',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({name,email,plan,message:msg})});
var d=await r.json();
if(r.ok){out.innerHTML='<span style="color:var(--gn)">βœ“ '+d.message+'</span>';toast('Submitted!');}
else{out.innerHTML='<span style="color:var(--rd)">βœ— '+d.detail+'</span>';}
}catch(e){toast('Network error','er');}
}
async function chkSt(){
var email=document.getElementById('ce').value.trim();
var out=document.getElementById('cr');
if(!email)return;
var r=await fetch('/api/status?email='+encodeURIComponent(email));
var d=await r.json();
if(d.status==='active')
out.innerHTML='<span style="color:var(--gn)">βœ“ Approved</span><br>Plan: <span style="color:var(--yw)">'+d.plan+'</span><br>Key: <span style="color:var(--a2)">'+d.api_key+'</span>';
else if(d.status==='pending')
out.innerHTML='<span style="color:var(--yw)">⏳ Pending review</span>';
else if(d.status==='revoked')
out.innerHTML='<span style="color:var(--rd)">βœ— Revoked β€” contact support</span>';
else
out.innerHTML='<span style="color:var(--mu)">No request found</span>';
}
// Admin login
async function aLogin(){
var pw=document.getElementById('ap').value;
var ae=document.getElementById('ae');
try{
var r=await fetch('/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});
var d=await r.json();
if(d.token){
tok=d.token;
document.getElementById('al').style.display='none';
document.getElementById('ap2').style.display='block';
loadA();
}else{ae.textContent='Wrong password';}
}catch(e){ae.textContent='Error connecting';}
}
function fd2(iso){
if(!iso)return'β€”';
return new Date(iso).toLocaleDateString('en',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
}
function mask(k){return k?k.substring(0,12)+'…'+k.slice(-4):'β€”';}
async function loadA(){
if(!tok)return;
var r=await fetch('/admin/data',{headers:{'X-Admin-Token':tok}});
if(r.status===401){tok=null;location.reload();return;}
var d=await r.json();
_adm=d;
document.getElementById('a1').textContent=d.stats.total_requests;
document.getElementById('a2').textContent=d.stats.total_logos;
document.getElementById('a3').textContent=d.pending.length;
document.getElementById('a4').textContent=d.active.length;
document.getElementById('a5').textContent=d.revoked.length;
// Pending
var tp=document.getElementById('tp');
var np=document.getElementById('np');
tp.innerHTML='';
if(!d.pending.length){np.style.display='block';}
else{
np.style.display='none';
d.pending.forEach(function(u){
var tr=document.createElement('tr');
tr.innerHTML='<td>'+u.name+'</td><td>'+u.email+'</td>'
+'<td><span class="bdg bac">'+u.plan+'</span></td>'
+'<td>'+fd2(u.requested_at)+'</td>'
+'<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+(u.message||'')+'">'+(u.message||'β€”')+'</td>'
+'<td style="white-space:nowrap">'
+'<button class="btn bg" onclick="openApr(\''+u.id+'\',\''+u.name+'\',\''+u.email+'\',\''+u.plan+'\')" style="padding:4px 10px;font-size:10px;margin-right:4px">βœ… Approve</button>'
+'<button class="btn br" onclick="rej(\''+u.id+'\')" style="padding:4px 10px;font-size:10px">βœ— Reject</button>'
+'</td>';
tp.appendChild(tr);
});
}
// Active
var ta=document.getElementById('ta');
ta.innerHTML='';
d.active.forEach(function(u){
var tr=document.createElement('tr');
tr.innerHTML='<td>'+u.name+'</td><td>'+u.email+'</td>'
+'<td><span class="bdg bp2">'+u.plan+'</span></td>'
+'<td style="font-family:var(--fm)">'+mask(u.api_key)
+' <span style="cursor:pointer;color:var(--a2)" onclick="navigator.clipboard.writeText(\''+u.api_key+'\').then(()=>toast(\'Copied!\'))" title="Copy full key">πŸ“‹</span></td>'
+'<td>'+( u.usage||0)+'</td>'
+'<td>'+fd2(u.last_used)+'</td>'
+'<td>'+fd2(u.approved_at)+'</td>'
+'<td style="white-space:nowrap">'
+'<button class="btn bw" onclick="openPlan(\''+u.id+'\',\''+u.plan+'\')" style="padding:4px 9px;font-size:10px;margin-right:4px">✏</button>'
+'<button class="btn br" onclick="rev(\''+u.id+'\')" style="padding:4px 9px;font-size:10px">🚫</button>'
+'</td>';
ta.appendChild(tr);
});
// Revoked
var tr2=document.getElementById('tr2');
var nr=document.getElementById('nr');
tr2.innerHTML='';
if(!d.revoked.length){nr.style.display='block';}
else{
nr.style.display='none';
d.revoked.forEach(function(u){
var tr=document.createElement('tr');
tr.innerHTML='<td>'+u.name+'</td><td>'+u.email+'</td>'
+'<td><span class="bdg br2">'+u.plan+'</span></td>'
+'<td style="font-family:var(--fm)">'+mask(u.api_key)+'</td>'
+'<td><button class="btn bg" onclick="react(\''+u.id+'\')" style="padding:4px 9px;font-size:10px">↩ Restore</button></td>';
tr2.appendChild(tr);
});
}
}
// Approve modal
function openApr(id,name,email,plan){
document.getElementById('amid').value=id;
document.getElementById('amn').value=name;
document.getElementById('ame').value=email;
document.getElementById('amp').value=plan;
var k='logo-'+Math.random().toString(36).slice(2,10)+'-'+Math.random().toString(36).slice(2,10)+'-'+Math.random().toString(36).slice(2,8);
document.getElementById('amk').value=k;
document.getElementById('am').classList.add('on');
}
function cM(){document.getElementById('am').classList.remove('on');}
async function confApprove(){
var id=document.getElementById('amid').value;
var plan=document.getElementById('amp').value;
var r=await fetch('/admin/approve',{
method:'POST',headers:{'Content-Type':'application/json','X-Admin-Token':tok},
body:JSON.stringify({user_id:id,plan})});
var d=await r.json();
if(r.ok){toast('βœ“ Approved! Key: '+d.api_key,'ok',6000);cM();loadA();}
else toast(d.detail,'er');
}
async function rej(id){
if(!confirm('Reject this request?'))return;
var r=await fetch('/admin/reject',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Token':tok},body:JSON.stringify({user_id:id})});
if(r.ok){toast('Request rejected','wn');loadA();}else toast('Error','er');
}
async function rev(id){
if(!confirm('Revoke this key? User loses access immediately.'))return;
var r=await fetch('/admin/revoke',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Token':tok},body:JSON.stringify({user_id:id})});
if(r.ok){toast('Key revoked','wn');loadA();}else toast('Error','er');
}
async function react(id){
var r=await fetch('/admin/reactivate',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Token':tok},body:JSON.stringify({user_id:id})});
if(r.ok){toast('Key restored');loadA();}else toast('Error','er');
}
// Plan change modal
function openPlan(id,cur){
document.getElementById('pmid').value=id;
document.getElementById('pmp').value=cur;
document.getElementById('pm').classList.add('on');
}
function cM2(){document.getElementById('pm').classList.remove('on');}
async function confPlan(){
var id=document.getElementById('pmid').value;
var plan=document.getElementById('pmp').value;
var r=await fetch('/admin/change-plan',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Token':tok},body:JSON.stringify({user_id:id,plan})});
if(r.ok){toast('Plan updated');cM2();loadA();}else toast('Error','er');
}
</script>
</body></html>"""
# ════════════════════════════════════════════════════════════════════════════
# FASTAPI APP
# ════════════════════════════════════════════════════════════════════════════
@asynccontextmanager
async def lifespan(app: FastAPI):
global ort_session
log.info("=== Startup ===")
_pull_from_repo()
ensure_model()
try:
ort_session = load_ort()
except Exception as e:
log.error("ORT load failed: %s", e)
yield
if ort_session:
del ort_session
gc.collect()
app = FastAPI(title="Logo Extractor", version="2.0.0",
lifespan=lifespan, docs_url="/api/docs", redoc_url=None)
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
# ─── UI & Health ─────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def root(): return HTML
@app.get("/health")
async def health():
return {"status": "ok" if ort_session else "degraded",
"model_loaded": ort_session is not None, "version": "2.0.0"}
# ─── Public: register & status ───────────────────────────────────────────────
@app.post("/api/register")
async def register(request: Request):
b = await request.json()
name = b.get("name", "").strip()
email = b.get("email", "").strip().lower()
plan = b.get("plan", "free")
msg = b.get("message", "").strip()
if not name or not email:
raise HTTPException(400, "Name and email required")
if "@" not in email:
raise HTTPException(400, "Valid email required")
db = _load_users()
all_emails = ([u["email"] for u in db["pending"]] +
[u["email"] for u in db["active"]] +
[u["email"] for u in db["revoked"]])
if email in all_emails:
raise HTTPException(409, "A request with this email already exists")
db["pending"].append({
"id": str(uuid.uuid4()), "email": email, "name": name,
"plan": plan, "message": msg,
"requested_at": datetime.now(timezone.utc).isoformat(),
})
_save_users(db)
log.info("New request: %s (%s)", email, plan)
return {"message": f"Request received for {email}. You'll be notified when approved."}
@app.get("/api/status")
async def status_check(email: str):
email = email.strip().lower()
db = _load_users()
for u in db.get("active", []):
if u["email"] == email:
return {"status": "active", "plan": u["plan"], "api_key": u["api_key"]}
for u in db.get("pending", []):
if u["email"] == email:
return {"status": "pending"}
for u in db.get("revoked", []):
if u["email"] == email:
return {"status": "revoked"}
return {"status": "not_found"}
@app.post("/api/verify-key")
async def verify_key_route(request: Request):
b = await request.json()
key = b.get("key", "")
db = _load_users()
is_master = bool(MASTER_API_KEY and key == MASTER_API_KEY)
is_user = any(u.get("api_key") == key for u in db.get("active", []))
return {"valid": is_master or is_user,
"type": "master" if is_master else ("user" if is_user else "invalid")}
# ─── Admin ────────────────────────────────────────────────────────────────────
@app.post("/admin/login")
async def admin_login(request: Request):
b = await request.json()
if b.get("password") != ADMIN_PASSWORD:
raise HTTPException(401, "Wrong password")
token = secrets.token_hex(24)
_admin_tokens.add(token)
return {"token": token}
@app.get("/admin/data")
async def admin_data(x_admin_token: str = Header(...)):
_check_admin(x_admin_token)
db = _load_users()
inp = out = "N/A"
if ort_session:
inp = str(ort_session.get_inputs()[0].shape)
out = str(ort_session.get_outputs()[0].shape)
return {
"stats": _stats,
"pending": db.get("pending", []),
"active": db.get("active", []),
"revoked": db.get("revoked", []),
"model": {"loaded": ort_session is not None,
"input_shape": inp, "output_shape": out},
}
@app.post("/admin/approve")
async def admin_approve(request: Request,
x_admin_token: str = Header(...)):
_check_admin(x_admin_token)
b = await request.json()
uid = b.get("user_id", "")
plan = b.get("plan", "free")
db = _load_users()
user = next((u for u in db["pending"] if u["id"] == uid), None)
if not user:
raise HTTPException(404, "Pending user not found")
api_key = _gen_key()
db["pending"] = [u for u in db["pending"] if u["id"] != uid]
user.update({
"api_key": api_key,
"plan": plan,
"approved_at": datetime.now(timezone.utc).isoformat(),
"usage": 0,
"last_used": None,
})
db["active"].append(user)
_save_users(db)
log.info("Approved %s β†’ %s…", user["email"], api_key[:16])
return {"message": f"Approved {user['email']}",
"api_key": api_key, "email": user["email"]}
@app.post("/admin/reject")
async def admin_reject(request: Request,
x_admin_token: str = Header(...)):
_check_admin(x_admin_token)
uid = (await request.json()).get("user_id", "")
db = _load_users()
db["pending"] = [u for u in db["pending"] if u["id"] != uid]
_save_users(db)
return {"message": "Rejected"}
@app.post("/admin/revoke")
async def admin_revoke(request: Request,
x_admin_token: str = Header(...)):
_check_admin(x_admin_token)
uid = (await request.json()).get("user_id", "")
db = _load_users()
user = next((u for u in db["active"] if u["id"] == uid), None)
if not user:
raise HTTPException(404, "User not found")
db["active"] = [u for u in db["active"] if u["id"] != uid]
db["revoked"].append(user)
_save_users(db)
log.info("Revoked key for %s", user["email"])
return {"message": f"Revoked {user['email']}"}
@app.post("/admin/reactivate")
async def admin_reactivate(request: Request,
x_admin_token: str = Header(...)):
_check_admin(x_admin_token)
uid = (await request.json()).get("user_id", "")
db = _load_users()
user = next((u for u in db["revoked"] if u["id"] == uid), None)
if not user:
raise HTTPException(404, "Revoked user not found")
db["revoked"] = [u for u in db["revoked"] if u["id"] != uid]
db["active"].append(user)
_save_users(db)
return {"message": f"Restored {user['email']}"}
@app.post("/admin/change-plan")
async def admin_change_plan(request: Request,
x_admin_token: str = Header(...)):
_check_admin(x_admin_token)
b = await request.json()
uid = b.get("user_id", "")
plan = b.get("plan", "free")
if plan not in ("free", "starter", "pro"):
raise HTTPException(400, "Plan must be free/starter/pro")
db = _load_users()
for u in db["active"]:
if u["id"] == uid:
u["plan"] = plan
_save_users(db)
return {"message": f"Plan updated to {plan}"}
raise HTTPException(404, "User not found")
# ─── Main extract endpoint ────────────────────────────────────────────────────
@app.post("/extract-logo")
async def extract_logo(
image: UploadFile = File(...),
output_format: str = "PNG",
max_logos: int = 20,
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
x_custom_secret: Optional[str] = Header(None, alias="X-Custom-Secret"),
):
t0 = time.perf_counter()
# ── Auth ──────────────────────────────────────────────────────────────
user = None
if x_api_key:
user = verify_api_key(x_api_key) # raises 401 if invalid
elif x_custom_secret:
verify_backend_secret(x_custom_secret)
else:
raise HTTPException(401,
"Provide X-API-Key (user key or master key) or X-Custom-Secret")
if ort_session is None:
_stats["errors"] += 1
raise HTTPException(503, "Model not loaded β€” check Space logs")
# ── Read image ────────────────────────────────────────────────────────
raw = await image.read()
if len(raw) > 15 * 1024 * 1024:
raise HTTPException(413, "Image too large β€” max 15 MB")
nparr = np.frombuffer(raw, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
raise HTTPException(400, "Cannot decode image")
oh, ow = img.shape[:2]
if max(ow, oh) > MAX_IMAGE_SIZE:
r = MAX_IMAGE_SIZE / max(ow, oh)
img = cv2.resize(img, None, fx=r, fy=r)
oh, ow = img.shape[:2]
# ── Inference ─────────────────────────────────────────────────────────
tensor, scale, px, py = preprocess(img)
inp_name = ort_session.get_inputs()[0].name
raw_out = ort_session.run(None, {inp_name: tensor})
dets = postprocess(raw_out[0], ow, oh, scale, px, py)[:max_logos]
# ── Crop & encode ─────────────────────────────────────────────────────
fmt = output_format.upper()
result_logos = []
for det in dets:
b64, cw, ch = crop_b64(img, det, fmt)
mime = "image/jpeg" if fmt == "JPEG" else "image/png"
result_logos.append({
"base64": b64, "mime_type": mime,
"width": cw, "height": ch,
"confidence": det["confidence"],
"bbox": {k: det[k] for k in ("x1", "y1", "x2", "y2")},
})
# ── Update usage counter (background, non-blocking) ───────────────────
if x_api_key and user is not None: # user=None means master key
api_key_snap = x_api_key
def _update_usage():
try:
db = _load_users()
for u in db["active"]:
if u.get("api_key") == api_key_snap:
u["usage"] = u.get("usage", 0) + 1
u["last_used"] = datetime.now(timezone.utc).isoformat()
break
# Save locally only β€” don't push to HF on every request
with open(USERS_FILE, "w") as f:
json.dump(db, f, indent=2)
except Exception:
pass
threading.Thread(target=_update_usage, daemon=True).start()
del img, tensor, raw_out, nparr, raw
gc.collect()
elapsed = round((time.perf_counter() - t0) * 1000, 1)
_stats["total_requests"] += 1
_stats["total_logos"] += len(result_logos)
return JSONResponse({
"success": True, "count": len(result_logos),
"elapsed_ms": elapsed, "logos": result_logos,
})
if __name__ == "__main__":
import uvicorn
uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)