Spaces:
Paused
Paused
| """ | |
| 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 & 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 Β· v2.0 Β· 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 & 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def root(): return HTML | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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."} | |
| 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"} | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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} | |
| 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}, | |
| } | |
| 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"]} | |
| 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"} | |
| 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']}"} | |
| 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']}"} | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) |