Spaces:
Running on L4
Running on L4
File size: 14,805 Bytes
ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 732426d ffc4ad0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 | #!/usr/bin/env python3
"""Camera games: the page sends webcam frames, and game states as text, the
model answers the game's questions about each in one pass, the page plays.
python apps/arcade/server.py # NVIDIA GPU, Apple silicon (mps) or CPU
python apps/arcade/server.py --compile # compiled forward (NVIDIA GPU, Apple silicon), ~1 min to start
python apps/arcade/server.py --model LiquidAI/d1-2B --compile # Boxes too: a model with typed answers
python apps/arcade/server.py --no-token --host 0.0.0.0 --port 7860 # inside the Space's container
The model runs on its own code from the Hub (`trust_remote_code`), as its card describes.
POST /decide {"image": <JPEG data URL> | "state": <text or object>,
"questions": [{"text": ..., "options": {label: description}}]}
-> {"probs": [[p, ...], ...], "ms": <model time>}
GET /ws a WebSocket carrying the same requests, each with an "id" its answer repeats;
several can be in flight, and no request pays HTTP's headers or a proxy's checks
A question without options is yes/no and answers [p(yes), p(no)]; one with
"levels" (a list, lowest first) is a score and answers one p per level. On a model with
typed answers (d1-2B on), one with "box": true answers as the model's API does, {"box",
"found", "confidence"}, and one with "boxes": true every instance, {"boxes", "count",
"presence"}, at most its "max" (16 without it). A frame's questions share one forward pass.
GET /info {"typed": <whether the model answers box questions>}: the page shows Boxes
only when it does
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import io
import json
import mimetypes
import secrets
import struct
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from PIL import Image
WEB = Path(__file__).parent / "web"
MAX_QUESTIONS = 32
MAX_BODY = 4 << 20 # a 512 px JPEG is ~60 KB
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455
def question(q: dict) -> dict:
"""The page's question as the model's API takes it."""
if q.get("boxes"): # `max`: the blocks read, the most instances answered (16 without it)
return {"type": "boxes", "instructions": q["text"], **({"max": int(q["max"])} if q.get("max") else {})}
if q.get("box"):
return {"type": "box", "instructions": q["text"]}
if q.get("options"):
return {"type": "choice", "instructions": q["text"], "criteria": dict(q["options"])}
if q.get("levels"):
return {"type": "score", "instructions": q["text"], "criteria": list(q["levels"])}
return {"type": "noul", "instructions": q["text"]}
def frame(data_url: str, side: int) -> Image.Image:
image = Image.open(io.BytesIO(base64.b64decode(data_url.split(",")[-1]))).convert("RGB")
image.thumbnail((side, side))
return image
def decide(model, questions: list, image=None, state=None) -> tuple[list[list[float]], float]:
t0 = time.perf_counter()
probs = model.probabilities(state, questions, () if image is None else [image]) if questions else []
probs = [model.answer(state, model.ask(q), p) if q["type"] in ("box", "boxes") else p
for q, p in zip(questions, probs)]
return probs, 1e3 * (time.perf_counter() - t0)
def parse(req: dict, side: int) -> tuple[list, dict]:
questions = [question(q) for q in req["questions"][:MAX_QUESTIONS]]
source = {"state": req["state"]} if "state" in req else {"image": frame(req["image"], side)}
return questions, source
class WebSocket:
"""The server side of RFC 6455, enough for the page: text messages, ping, close."""
def __init__(self, rfile, wfile):
self.rfile, self.wfile, self.lock = rfile, wfile, threading.Lock()
def _exact(self, n: int) -> bytes:
data = self.rfile.read(n)
if len(data) < n:
raise ConnectionError("closed")
return data
def _frame(self) -> tuple[int, bool, bytes]:
b0, b1 = self._exact(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", self._exact(2))[0]
elif n == 127:
n = struct.unpack(">Q", self._exact(8))[0]
if n > MAX_BODY:
raise ConnectionError("message too large")
mask = self._exact(4) if b1 & 0x80 else b""
data = self._exact(n)
if mask:
data = _unmask(data, mask)
return b0 & 0x0F, bool(b0 & 0x80), data
def receive(self) -> str | None:
"""The next text message, or None once the page closes the socket."""
parts: list[bytes] = []
while True:
op, fin, data = self._frame()
if op == 0x8:
self.send(data[:2], 0x8)
return None
if op == 0x9:
self.send(data, 0xA)
continue
if op in (0x1, 0x0):
parts.append(data)
if sum(map(len, parts)) > MAX_BODY:
raise ConnectionError("message too large")
if fin:
return b"".join(parts).decode()
def send(self, data: bytes | str, op: int = 0x1) -> None:
if isinstance(data, str):
data = data.encode()
n = len(data)
head = bytes([0x80 | op]) + (bytes([n]) if n < 126 else
bytes([126]) + struct.pack(">H", n) if n < 65536 else
bytes([127]) + struct.pack(">Q", n))
with self.lock:
self.wfile.write(head + data)
self.wfile.flush()
def _unmask(data: bytes, mask: bytes) -> bytes:
n = len(data)
key = int.from_bytes((mask * (n // 4 + 1))[:n], "big")
return (int.from_bytes(data, "big") ^ key).to_bytes(n, "big")
def make_handler(run, token: str | None, side: int, info: dict):
"""`run(questions, image=..., state=...)` answers on the model's own thread. With `token`
None every request is served: in a Space, its visibility decides who can reach it."""
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive: one connection for every frame
disable_nagle_algorithm = True # else headers and body wait ~40 ms for an ACK
def log_message(self, *args):
pass
def _send(self, code: int, body: bytes, kind: str) -> None:
try:
self.send_response(code)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass # the page closed or reloaded while its frame was answered
def do_GET(self):
if self.path.split("?")[0] == "/ws":
self._websocket()
return
if self.path.split("?")[0] == "/info":
self._send(200, json.dumps(info).encode(), "application/json")
return
name = self.path.split("?")[0].lstrip("/") or "index.html"
path = (WEB / name).resolve()
if WEB.resolve() not in path.parents or not path.is_file():
self._send(404, b"not found", "text/plain")
return
kind = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
if kind in ("text/javascript", "application/javascript"):
kind = "text/javascript; charset=utf-8"
self._send(200, path.read_bytes(), kind)
def do_POST(self):
size = int(self.headers.get("Content-Length", 0))
if self.path.split("?")[0] != "/decide" or (token and self.headers.get("X-Token") != token) or size > MAX_BODY:
self._send(403, b"forbidden", "text/plain")
self.close_connection = True
return
try:
questions, source = parse(json.loads(self.rfile.read(size)), side)
except Exception: # anything malformed in the request
self._send(400, b"bad request", "text/plain")
return
probs, ms = run(questions, **source)
self._send(200, json.dumps({"probs": probs, "ms": ms}).encode(), "application/json")
def _websocket(self):
query = dict(p.split("=", 1) for p in self.path.partition("?")[2].split("&") if "=" in p)
key = self.headers.get("Sec-WebSocket-Key")
if "websocket" not in self.headers.get("Upgrade", "").lower() or not key or (token and query.get("token") != token):
self._send(403, b"forbidden", "text/plain")
self.close_connection = True
return
self.send_response(101)
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept",
base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode())
self.end_headers()
self.close_connection = True
ws = WebSocket(self.rfile, self.wfile)
# requests are decoded off the reading thread, so the next frame can arrive while one is answered
pool = ThreadPoolExecutor(max_workers=4)
def answer(text: str) -> None:
req = None
try:
req = json.loads(text)
questions, source = parse(req, side)
except Exception: # anything malformed in the request
out = {"error": "bad request"}
else:
try:
probs, ms = run(questions, **source)
out = {"probs": probs, "ms": ms}
except Exception as e: # the page must hear back, or its frame stays in flight
out = {"error": type(e).__name__}
out["id"] = req.get("id") if isinstance(req, dict) else None
try:
ws.send(json.dumps(out))
except OSError:
pass # the page went away while its frame was answered
try:
while (text := ws.receive()) is not None:
pool.submit(answer, text)
except (ConnectionError, OSError):
pass
finally:
pool.shutdown(wait=False, cancel_futures=True)
return Handler
class Server(ThreadingHTTPServer):
daemon_threads = True
def handle_error(self, request, client_address):
# a page closed or reloaded mid-request is routine, not an error to print
if not isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
super().handle_error(request, client_address)
def load(model_id: str, compile: bool):
"""The model's System One engine, on the best device here."""
import torch
from transformers import AutoModel
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
model = AutoModel.from_pretrained(model_id, trust_remote_code=True, dtype=torch.bfloat16).to(device)
if compile:
model.compile()
print(f"{model_id} on {device}", flush=True)
return model.engine
def recompiles() -> None:
"""The games' picture sizes, question mixes and the typed path each recompile the forward: past dynamo's 8 it
falls back to eager for good (d1-2B on an M5 Pro: six questions 185 ms served against 125 compiled). Dynamo's
config is per thread: set on the one the model runs on."""
import torch
torch._dynamo.config.recompile_limit = 64
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--model", default="LiquidAI/d1-3B")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765)
p.add_argument("--side", type=int, default=512, help="frames are cut down to fit this square")
p.add_argument("--compile", action="store_true", help="compile the forward: CUDA graphs on NVIDIA where the model takes them")
p.add_argument("--no-token", action="store_true", help="serve without a token, for a Space that controls who can reach it")
args = p.parse_args()
model = load(args.model, args.compile)
# Every model call runs on this one thread: requests queue in order, and
# CUDA graphs are bound to the thread that captured them.
worker = ThreadPoolExecutor(max_workers=1, initializer=recompiles if args.compile else None)
def run(questions, **source):
return worker.submit(decide, model, questions, **source).result()
# The first calls of a shape pay kernel selection, or compilation with
# --compile: the games' frames (384 px, and the duel's half frames), two
# prompt lengths so that later ones reuse the dynamic graph.
print("warming up" + (" and compiling, about a minute" if args.compile else ""), flush=True)
short = question({"text": "Is there a person?"})
long_ = question({"text": "Is the person holding a cup in one hand?"})
for size in ((384, 216), (192, 216), (512, 288)):
warm = Image.new("RGB", size, (120, 120, 120))
for q in (short, long_):
run([q], image=warm)
run([short, long_], image=warm)
typed = bool(getattr(model, "serves_typed", False))
if typed:
# Boxes' prompts on its 512 px frames, one box (~235 tokens) to eight objects boxed in full (~2,000): off the
# flash kernels (Apple, CPU) a sliding layer's mask is built in 256-token blocks, a graph for each count
warm = Image.new("RGB", (512, 288), (120, 120, 120))
run([question({"text": "Where is the face?", "box": True})], image=warm)
every = [question({"text": f"Every {t}.", "boxes": True, "max": 8}) for t in
("person", "cup", "phone", "hand", "book", "bottle", "pen", "chair")]
for k in range(1, len(every) + 1):
run(every[:k], image=warm)
run([short, long_], state={"scene": "an empty room"})
token = None if args.no_token else secrets.token_urlsafe(12)
server = Server((args.host, args.port), make_handler(run, token, args.side, {"typed": typed}))
print(f"ready: open http://localhost:{args.port}/" + (f"?token={token}" if token else ""), flush=True)
server.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())
|