Spaces:
Sleeping
Sleeping
Deploy doodle-decoder
Browse files- Dockerfile +9 -0
- README.md +19 -3
- app.py +19 -0
- live.py +49 -0
- requirements.txt +3 -0
- rune.py +120 -0
- static/client.js +28 -0
- static/embed.js +37 -0
- static/index.html +202 -0
Dockerfile
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
RUN useradd -m -u 1000 user
|
| 3 |
+
WORKDIR /home/user/app
|
| 4 |
+
COPY --chown=user requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
COPY --chown=user . .
|
| 7 |
+
USER user
|
| 8 |
+
EXPOSE 7860
|
| 9 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--no-proxy-headers"]
|
README.md
CHANGED
|
@@ -1,10 +1,26 @@
|
|
| 1 |
---
|
| 2 |
title: Doodle Decoder
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: yellow
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Doodle Decoder
|
| 3 |
+
emoji: ✏️
|
| 4 |
colorFrom: yellow
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
short_description: You draw, Rune guesses from the pixels, live.
|
| 11 |
+
models:
|
| 12 |
+
- surogate/rune-26b-a4b-GGUF
|
| 13 |
+
tags:
|
| 14 |
+
- surogate
|
| 15 |
+
- rune
|
| 16 |
+
- decision-model
|
| 17 |
+
- vision
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# Doodle Decoder
|
| 21 |
+
|
| 22 |
+
Draw the word you're given. Every time you lift the pen, [Surogate Rune](https://huggingface.co/surogate/rune-26b-a4b-GGUF),
|
| 23 |
+
an open decision model, looks at the canvas and gives a probability for each of 50 words, so you watch it change its
|
| 24 |
+
mind stroke by stroke.
|
| 25 |
+
|
| 26 |
+
Nothing you draw is stored.
|
app.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import live
|
| 4 |
+
|
| 5 |
+
HERE = Path(__file__).parent
|
| 6 |
+
WORDS = ["cat", "dog", "fish", "bird", "house", "tree", "flower", "sun", "moon", "star", "car", "bicycle",
|
| 7 |
+
"boat", "airplane", "train", "rocket", "cup", "chair", "table", "lamp", "clock", "key", "umbrella",
|
| 8 |
+
"hat", "shoe", "glasses", "guitar", "pizza", "apple", "banana", "ice cream", "cake", "heart",
|
| 9 |
+
"smiley face", "snake", "spider", "butterfly", "mountain", "cloud", "lightning", "snowman",
|
| 10 |
+
"ghost", "crown", "sword", "light bulb", "phone", "envelope", "scissors", "pencil", "eye"]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def questions(context):
|
| 14 |
+
return ("A sketch drawn by a person in a guessing game.", {"guess": {
|
| 15 |
+
"type": "choice", "instructions": "What is this sketch meant to be?",
|
| 16 |
+
"criteria": {w: w for w in WORDS}}})
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
app = live.make_app(questions, HERE / "static")
|
live.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app for the live Rune Labs demos: a static page plus /decide.
|
| 2 |
+
|
| 3 |
+
deploy.py copies this file into the Docker Spaces. Edit it here, never in a Space.
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
import rune
|
| 11 |
+
|
| 12 |
+
MAX_BODY = rune.MAX_DATA_URL + 64 * 1024
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Ask(BaseModel):
|
| 16 |
+
image: str
|
| 17 |
+
context: dict = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def make_app(build_questions, static_dir):
|
| 21 |
+
"""build_questions(context) -> (state, questions) for one decision about the posted image."""
|
| 22 |
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
| 23 |
+
|
| 24 |
+
@app.middleware("http")
|
| 25 |
+
async def refuse_oversized(request: Request, call_next):
|
| 26 |
+
# Checked before FastAPI reads and parses the body; a data URL plus the JSON around it.
|
| 27 |
+
size = request.headers.get("content-length")
|
| 28 |
+
if request.method == "POST" and (size is None or not size.isdigit() or int(size) > MAX_BODY):
|
| 29 |
+
return JSONResponse({"detail": "Request too large."}, status_code=413 if size else 411)
|
| 30 |
+
return await call_next(request)
|
| 31 |
+
|
| 32 |
+
@app.post("/decide")
|
| 33 |
+
def ask(a: Ask):
|
| 34 |
+
try:
|
| 35 |
+
img = rune.from_data_url(a.image)
|
| 36 |
+
except ValueError as e:
|
| 37 |
+
raise HTTPException(400, str(e))
|
| 38 |
+
state, questions = build_questions(a.context)
|
| 39 |
+
body = rune.request_body(state, questions, [rune.image_data_url(img)])
|
| 40 |
+
try:
|
| 41 |
+
answers = rune.decide(body)
|
| 42 |
+
except rune.Busy as e:
|
| 43 |
+
raise HTTPException(503, str(e))
|
| 44 |
+
except rune.RuneError as e:
|
| 45 |
+
raise HTTPException(502, str(e))
|
| 46 |
+
return {"labels": {k: rune.as_label(v) for k, v in answers.items()}}
|
| 47 |
+
|
| 48 |
+
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
| 49 |
+
return app
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.141.1
|
| 2 |
+
uvicorn==0.54.0
|
| 3 |
+
pillow==12.3.0
|
rune.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared by every Rune Labs Space: the Rune client, its concurrency cap, and image handling.
|
| 2 |
+
|
| 3 |
+
deploy.py copies this file into each Space. Edit it here, never in a Space.
|
| 4 |
+
"""
|
| 5 |
+
import base64
|
| 6 |
+
import binascii
|
| 7 |
+
import http.client
|
| 8 |
+
import io
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
import urllib.error
|
| 15 |
+
import urllib.request
|
| 16 |
+
|
| 17 |
+
from PIL import Image
|
| 18 |
+
|
| 19 |
+
MODEL = "rune-v3"
|
| 20 |
+
MAX_SIDE = 1024
|
| 21 |
+
MAX_TEXT = 4000
|
| 22 |
+
MAX_DATA_URL = 6 * 1024 * 1024
|
| 23 |
+
MAX_PIXELS = 25_000_000 # checked from the header, before any pixel is decoded
|
| 24 |
+
BUSY = "Rune is busy right now. Try again in a few seconds."
|
| 25 |
+
FAILED = "Rune could not answer that one. Try again in a moment."
|
| 26 |
+
|
| 27 |
+
log = logging.getLogger("rune")
|
| 28 |
+
|
| 29 |
+
# One cap for every caller in the process, Gradio handler or FastAPI route alike.
|
| 30 |
+
_slots = threading.BoundedSemaphore(2)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class RuneError(Exception):
|
| 34 |
+
"""Rune did not answer. str() is safe to show to a user."""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Busy(RuneError):
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def request_body(state, questions, images=None):
|
| 42 |
+
if isinstance(state, str):
|
| 43 |
+
state = state[:MAX_TEXT]
|
| 44 |
+
body = {"model": MODEL, "state": state, "questions": questions}
|
| 45 |
+
if images:
|
| 46 |
+
body["images"] = images
|
| 47 |
+
return body
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def decide(body, *, opener=urllib.request.urlopen, sleep=time.sleep):
|
| 51 |
+
req = urllib.request.Request(
|
| 52 |
+
os.environ["RUNE_URL"], json.dumps(body).encode(),
|
| 53 |
+
{"Authorization": "Bearer " + os.environ["RUNE_API_KEY"], "Content-Type": "application/json"})
|
| 54 |
+
with _slots:
|
| 55 |
+
for attempt in (0, 1):
|
| 56 |
+
try:
|
| 57 |
+
with opener(req, timeout=60) as r:
|
| 58 |
+
reply = json.load(r)
|
| 59 |
+
if not isinstance(reply, dict) or not isinstance(reply.get("answers"), dict):
|
| 60 |
+
raise ValueError("reply has no answers")
|
| 61 |
+
return reply["answers"]
|
| 62 |
+
except urllib.error.HTTPError as e:
|
| 63 |
+
e.close()
|
| 64 |
+
if e.code not in (429, 503):
|
| 65 |
+
log.warning("rune call failed: HTTP %s", e.code)
|
| 66 |
+
raise RuneError(FAILED) from None
|
| 67 |
+
if attempt == 0:
|
| 68 |
+
sleep(1.5)
|
| 69 |
+
except (OSError, http.client.HTTPException, ValueError) as e:
|
| 70 |
+
# Logged by type only: the request carries user content, the log never does.
|
| 71 |
+
log.warning("rune call failed: %s", type(e).__name__)
|
| 72 |
+
raise RuneError(FAILED) from None
|
| 73 |
+
log.warning("rune busy after one retry")
|
| 74 |
+
raise Busy(BUSY)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def image_data_url(img):
|
| 78 |
+
img = img.copy()
|
| 79 |
+
img.thumbnail((MAX_SIDE, MAX_SIDE))
|
| 80 |
+
png = img.mode in ("RGBA", "LA", "P")
|
| 81 |
+
buf = io.BytesIO()
|
| 82 |
+
if png:
|
| 83 |
+
img.save(buf, "PNG")
|
| 84 |
+
else:
|
| 85 |
+
img.convert("RGB").save(buf, "JPEG", quality=90)
|
| 86 |
+
return f"data:image/{'png' if png else 'jpeg'};base64," + base64.b64encode(buf.getvalue()).decode()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def from_data_url(url):
|
| 90 |
+
"""Decode an untrusted data URL from a browser into a resized image. Raises ValueError."""
|
| 91 |
+
if len(url) > MAX_DATA_URL or not url.startswith(("data:image/png;base64,", "data:image/jpeg;base64,")):
|
| 92 |
+
raise ValueError("expected a PNG or JPEG data URL under 6 MB")
|
| 93 |
+
try:
|
| 94 |
+
img = Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1], validate=True)))
|
| 95 |
+
if img.width * img.height > MAX_PIXELS:
|
| 96 |
+
raise ValueError("image too large")
|
| 97 |
+
img.load()
|
| 98 |
+
except (binascii.Error, OSError, Image.DecompressionBombError) as e:
|
| 99 |
+
raise ValueError("not a readable image") from e
|
| 100 |
+
img.thumbnail((MAX_SIDE, MAX_SIDE))
|
| 101 |
+
return img
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def as_label(answer, names=None):
|
| 105 |
+
"""Rune answer -> {label: probability}, the shape gr.Label and the live pages draw.
|
| 106 |
+
|
| 107 |
+
names maps a choice's option keys to what to show instead, usually its criteria.
|
| 108 |
+
"""
|
| 109 |
+
if "noul" in answer:
|
| 110 |
+
return {"yes": answer["noul"], "no": 1 - answer["noul"]}
|
| 111 |
+
names = names or answer.get("legend") or {}
|
| 112 |
+
return {names.get(k, k): p for k, p in answer["probabilities"].items()}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def curl(body):
|
| 116 |
+
shown = dict(body)
|
| 117 |
+
if "images" in shown:
|
| 118 |
+
shown["images"] = [u[:40] + "…" for u in shown["images"]]
|
| 119 |
+
return (f"curl {os.environ.get('RUNE_URL', '')} \\\n -H 'Authorization: Bearer $RUNE_API_KEY' \\\n"
|
| 120 |
+
f" -H 'Content-Type: application/json' \\\n -d '{json.dumps(shown, ensure_ascii=False)}'")
|
static/client.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Rune Labs: the two helpers both static Spaces use. Plain globals, loaded before the page's own script. */
|
| 2 |
+
|
| 3 |
+
/* POST JSON to the Space and return the reply, or throw with the server's message. */
|
| 4 |
+
async function post(path, body) {
|
| 5 |
+
const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
|
| 6 |
+
const data = await r.json().catch(() => ({}));
|
| 7 |
+
if (!r.ok) throw new Error(data.detail || "Something went wrong. Try again in a moment.");
|
| 8 |
+
return data;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/* One probability row: label, bar, percentage. Built with textContent, never HTML. */
|
| 12 |
+
function bar(label, p, top) {
|
| 13 |
+
const row = document.createElement("div");
|
| 14 |
+
row.className = top ? "bar top" : "bar";
|
| 15 |
+
const name = document.createElement("span");
|
| 16 |
+
name.textContent = label;
|
| 17 |
+
const track = document.createElement("div");
|
| 18 |
+
track.className = "track";
|
| 19 |
+
const fill = document.createElement("div");
|
| 20 |
+
fill.className = "fill";
|
| 21 |
+
fill.style.width = `${(p * 100).toFixed(1)}%`;
|
| 22 |
+
track.append(fill);
|
| 23 |
+
const pct = document.createElement("span");
|
| 24 |
+
pct.className = "pct";
|
| 25 |
+
pct.textContent = `${Math.round(p * 100)}%`;
|
| 26 |
+
row.append(name, track, pct);
|
| 27 |
+
return row;
|
| 28 |
+
}
|
static/embed.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Rune Labs: inside surogate.ai a demo drops its own header (the page row already names it) and
|
| 2 |
+
reports its height, so the page sizes the frame to fit and nothing scrolls inside it.
|
| 3 |
+
|
| 4 |
+
The height is the bottom of main's children, not of main itself: Gradio stretches <main> to fill
|
| 5 |
+
the frame, so measuring it would grow the frame, which grows main, forever.
|
| 6 |
+
|
| 7 |
+
surogate.ai asks for this with ?embed=surogate. Being in a frame is not enough: huggingface.co
|
| 8 |
+
shows every Space in one too, and there the header and footer should stay. */
|
| 9 |
+
(() => {
|
| 10 |
+
if (window.self === window.top || !new URLSearchParams(location.search).has("embed")) return;
|
| 11 |
+
document.documentElement.classList.add("rune-embedded");
|
| 12 |
+
let last = 0;
|
| 13 |
+
const contentBottom = (main) => {
|
| 14 |
+
let bottom = 0;
|
| 15 |
+
for (const child of main.children) bottom = Math.max(bottom, child.getBoundingClientRect().bottom);
|
| 16 |
+
return bottom + window.scrollY + parseFloat(getComputedStyle(main).paddingBottom || "0");
|
| 17 |
+
};
|
| 18 |
+
const send = () => {
|
| 19 |
+
const main = document.querySelector("main");
|
| 20 |
+
if (!main) return;
|
| 21 |
+
const height = Math.ceil(contentBottom(main) + 28);
|
| 22 |
+
if (Math.abs(height - last) < 2) return;
|
| 23 |
+
last = height;
|
| 24 |
+
window.parent.postMessage({ type: "rune-labs:height", height }, "*");
|
| 25 |
+
};
|
| 26 |
+
const start = () => {
|
| 27 |
+
const main = document.querySelector("main"); // Gradio mounts it after the script runs
|
| 28 |
+
if (!main || !main.children.length) return setTimeout(start, 150);
|
| 29 |
+
const watch = new ResizeObserver(send);
|
| 30 |
+
for (const child of main.children) watch.observe(child);
|
| 31 |
+
new MutationObserver(() => { for (const child of main.children) watch.observe(child); send(); })
|
| 32 |
+
.observe(main, { childList: true });
|
| 33 |
+
send();
|
| 34 |
+
};
|
| 35 |
+
if (document.readyState === "loading") addEventListener("DOMContentLoaded", start);
|
| 36 |
+
else start();
|
| 37 |
+
})();
|
static/index.html
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Doodle Decoder</title>
|
| 7 |
+
<meta name="description" content="Draw the word you're given. Surogate Rune looks at your pixels and guesses, live.">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
<style>
|
| 11 |
+
:root {
|
| 12 |
+
--ink: #2a102d; --ink-2: #52504c; --ink-3: #8c847a; --paper: #ffffff; --card: #ffffff;
|
| 13 |
+
--amber: #ffaf10; --amber-soft: rgba(255, 175, 16, 0.14); --line: #ece6da; --good: #22c55e;
|
| 14 |
+
}
|
| 15 |
+
* { box-sizing: border-box; }
|
| 16 |
+
body { margin: 0; font: 16px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 17 |
+
background: var(--paper); color: var(--ink); -webkit-font-smoothing: antialiased; }
|
| 18 |
+
main { max-width: 1040px; margin: 0 auto; padding: 24px 20px 40px; display: grid; gap: 24px;
|
| 19 |
+
grid-template-columns: minmax(0, 1fr) 320px; }
|
| 20 |
+
@media (max-width: 800px) { main { grid-template-columns: minmax(0, 1fr); } }
|
| 21 |
+
header { grid-column: 1 / -1; }
|
| 22 |
+
h1 { margin: 0; font-family: Fraunces, Georgia, serif; font-weight: 600; font-size: clamp(30px, 5vw, 44px); letter-spacing: -0.02em; line-height: 1.05; }
|
| 23 |
+
.rune-embedded header, .rune-embedded footer { display: none; }
|
| 24 |
+
.rune-embedded main { padding-top: 8px; }
|
| 25 |
+
.sub { margin: 6px 0 0; color: var(--ink-2); }
|
| 26 |
+
.prompt { display: flex; align-items: baseline; gap: 10px; margin: 0 0 12px; font-size: 20px; color: var(--ink-2); }
|
| 27 |
+
.prompt b { font-size: 30px; color: var(--ink); background: var(--amber-soft); padding: 0 10px; border-radius: 8px; }
|
| 28 |
+
canvas { display: block; width: 100%; aspect-ratio: 1; background: #fff; border: 1px solid var(--line);
|
| 29 |
+
border-radius: 16px; touch-action: none; cursor: crosshair; box-shadow: 0 24px 50px -30px rgba(42, 16, 45, 0.35); }
|
| 30 |
+
.tools { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
| 31 |
+
button { font: inherit; font-weight: 600; padding: 9px 16px; border-radius: 10px; border: 2px solid var(--ink);
|
| 32 |
+
background: var(--card); color: var(--ink); cursor: pointer; }
|
| 33 |
+
button.primary { background: var(--amber); border-color: var(--amber); }
|
| 34 |
+
button:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
|
| 35 |
+
aside { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px 18px 14px;
|
| 36 |
+
align-self: start; position: sticky; top: 16px; }
|
| 37 |
+
.head { margin: 0 0 12px; font-family: Fraunces, Georgia, serif; font-weight: 600; font-size: 26px; min-height: 1.4em; }
|
| 38 |
+
.bar { display: grid; grid-template-columns: 96px 1fr 42px; gap: 8px; align-items: center; margin: 7px 0; font-size: 15px; }
|
| 39 |
+
.bar .track { height: 12px; background: var(--line); border-radius: 6px; overflow: hidden; }
|
| 40 |
+
.bar .fill { height: 100%; background: var(--amber); border-radius: 6px; transition: width 0.3s ease; }
|
| 41 |
+
.bar.top .fill { background: var(--ink); }
|
| 42 |
+
.bar .pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2); }
|
| 43 |
+
.status { color: var(--ink-3); font-size: 14px; min-height: 1.5em; margin: 10px 0 0; }
|
| 44 |
+
.win { display: none; margin-top: 14px; padding: 14px; border-radius: 12px; background: var(--ink); color: #fff; }
|
| 45 |
+
.win.show { display: block; animation: pop 0.35s cubic-bezier(.2, 1.4, .4, 1); }
|
| 46 |
+
.win p { margin: 0 0 10px; }
|
| 47 |
+
.win button { border-color: var(--amber); background: var(--amber); color: var(--ink); }
|
| 48 |
+
@keyframes pop { from { transform: scale(0.92); opacity: 0; } to { transform: none; opacity: 1; } }
|
| 49 |
+
footer { grid-column: 1 / -1; color: var(--ink-3); font-size: 14px; }
|
| 50 |
+
footer a { color: var(--ink); }
|
| 51 |
+
@media (prefers-reduced-motion: reduce) { .bar .fill { transition: none; } .win.show { animation: none; } }
|
| 52 |
+
</style>
|
| 53 |
+
<script src="embed.js"></script>
|
| 54 |
+
<script src="client.js"></script>
|
| 55 |
+
</head>
|
| 56 |
+
<body>
|
| 57 |
+
<main>
|
| 58 |
+
<header>
|
| 59 |
+
<h1>Doodle Decoder</h1>
|
| 60 |
+
<p class="sub">Draw the word. Every time you lift the pen, Rune looks at your pixels and guesses.</p>
|
| 61 |
+
</header>
|
| 62 |
+
|
| 63 |
+
<section>
|
| 64 |
+
<p class="prompt">Draw <b id="word"></b></p>
|
| 65 |
+
<canvas id="canvas" width="512" height="512" aria-label="Drawing canvas"></canvas>
|
| 66 |
+
<div class="tools">
|
| 67 |
+
<button id="clear" type="button">Clear</button>
|
| 68 |
+
<button id="next" type="button" class="primary">New word</button>
|
| 69 |
+
</div>
|
| 70 |
+
</section>
|
| 71 |
+
|
| 72 |
+
<aside aria-live="polite">
|
| 73 |
+
<h2 class="head" id="head">Rune is watching…</h2>
|
| 74 |
+
<div id="bars"></div>
|
| 75 |
+
<p class="status" id="status">Start drawing.</p>
|
| 76 |
+
<div class="win" id="win">
|
| 77 |
+
<p id="win-text"></p>
|
| 78 |
+
<button id="share" type="button">Share</button>
|
| 79 |
+
</div>
|
| 80 |
+
</aside>
|
| 81 |
+
|
| 82 |
+
<footer>
|
| 83 |
+
Powered by <a href="https://huggingface.co/surogate/rune-26b-a4b-GGUF" target="_blank" rel="noopener">Surogate Rune</a>,
|
| 84 |
+
an open decision model. <a href="https://surogate.ai/labs/rune-examples/" target="_blank" rel="noopener">More demos</a>.
|
| 85 |
+
Nothing you draw is stored.
|
| 86 |
+
</footer>
|
| 87 |
+
</main>
|
| 88 |
+
|
| 89 |
+
<script>
|
| 90 |
+
const WORDS = ["cat", "dog", "fish", "bird", "house", "tree", "flower", "sun", "moon", "star", "car", "bicycle",
|
| 91 |
+
"boat", "airplane", "train", "rocket", "cup", "chair", "table", "lamp", "clock", "key", "umbrella",
|
| 92 |
+
"hat", "shoe", "glasses", "guitar", "pizza", "apple", "banana", "ice cream", "cake", "heart",
|
| 93 |
+
"smiley face", "snake", "spider", "butterfly", "mountain", "cloud", "lightning", "snowman",
|
| 94 |
+
"ghost", "crown", "sword", "light bulb", "phone", "envelope", "scissors", "pencil", "eye"];
|
| 95 |
+
|
| 96 |
+
const canvas = document.getElementById("canvas");
|
| 97 |
+
const ctx = canvas.getContext("2d");
|
| 98 |
+
ctx.lineWidth = 8; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#111";
|
| 99 |
+
const $ = (id) => document.getElementById(id);
|
| 100 |
+
let strokes = 0, drawing = false, busy = false, pending = false, target = "", won = false;
|
| 101 |
+
let round = 0;
|
| 102 |
+
let last = null; // the previous pointer position; each move draws only the new segment // bumped by Clear and New word; an answer from an earlier round is dropped
|
| 103 |
+
|
| 104 |
+
function clearCanvas() {
|
| 105 |
+
ctx.fillStyle = "#fff";
|
| 106 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 107 |
+
strokes = 0; won = false; pending = false; round++;
|
| 108 |
+
$("bars").replaceChildren();
|
| 109 |
+
$("win").classList.remove("show");
|
| 110 |
+
$("head").textContent = "Rune is watching…";
|
| 111 |
+
$("status").textContent = "Start drawing.";
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function newWord(asked) {
|
| 115 |
+
let next = WORDS.includes(asked) ? asked : null;
|
| 116 |
+
while (!next || next === target) next = WORDS[Math.floor(Math.random() * WORDS.length)];
|
| 117 |
+
target = next;
|
| 118 |
+
$("word").textContent = target;
|
| 119 |
+
clearCanvas();
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function point(e) {
|
| 123 |
+
const r = canvas.getBoundingClientRect();
|
| 124 |
+
return [(e.clientX - r.left) * canvas.width / r.width, (e.clientY - r.top) * canvas.height / r.height];
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
canvas.addEventListener("pointerdown", (e) => {
|
| 128 |
+
drawing = true;
|
| 129 |
+
canvas.setPointerCapture(e.pointerId);
|
| 130 |
+
last = point(e);
|
| 131 |
+
});
|
| 132 |
+
canvas.addEventListener("pointermove", (e) => {
|
| 133 |
+
if (!drawing) return;
|
| 134 |
+
const p = point(e);
|
| 135 |
+
ctx.beginPath();
|
| 136 |
+
ctx.moveTo(...last);
|
| 137 |
+
ctx.lineTo(...p);
|
| 138 |
+
ctx.stroke();
|
| 139 |
+
last = p;
|
| 140 |
+
});
|
| 141 |
+
function endStroke() {
|
| 142 |
+
if (!drawing) return;
|
| 143 |
+
drawing = false;
|
| 144 |
+
strokes++;
|
| 145 |
+
guess();
|
| 146 |
+
}
|
| 147 |
+
canvas.addEventListener("pointerup", endStroke);
|
| 148 |
+
canvas.addEventListener("pointercancel", endStroke);
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
function setBusy(on) {
|
| 153 |
+
busy = on;
|
| 154 |
+
if (on) document.body.dataset.busy = "1"; else delete document.body.dataset.busy;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function guess() {
|
| 158 |
+
if (won) return;
|
| 159 |
+
if (busy) { pending = true; return; } // one request at a time; the latest drawing is sent next
|
| 160 |
+
setBusy(true);
|
| 161 |
+
const mine = round;
|
| 162 |
+
try {
|
| 163 |
+
const t = performance.now();
|
| 164 |
+
const out = await post("decide", { image: canvas.toDataURL("image/png"), context: {} });
|
| 165 |
+
if (mine !== round) return;
|
| 166 |
+
const probs = Object.entries(out.labels.guess).sort((a, b) => b[1] - a[1]);
|
| 167 |
+
const [top, p] = probs[0];
|
| 168 |
+
$("head").textContent = p < 0.5 ? `Hmm… a ${top}?` : `It's a ${top}!`;
|
| 169 |
+
$("bars").replaceChildren(...probs.slice(0, 5).map(([w, q], i) => bar(w, q, i === 0)));
|
| 170 |
+
$("status").textContent = `${Math.round(performance.now() - t)} ms · stroke ${strokes}`;
|
| 171 |
+
if (top === target && p >= 0.5) {
|
| 172 |
+
won = true;
|
| 173 |
+
$("win-text").textContent = `Rune got it in ${strokes} stroke${strokes === 1 ? "" : "s"}, ${Math.round(p * 100)}% sure it's a ${target}.`;
|
| 174 |
+
$("win").classList.add("show");
|
| 175 |
+
}
|
| 176 |
+
} catch (err) {
|
| 177 |
+
if (mine === round) $("status").textContent = err.message;
|
| 178 |
+
} finally {
|
| 179 |
+
setBusy(false);
|
| 180 |
+
if (pending && !won) { pending = false; guess(); }
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
$("share").addEventListener("click", async () => {
|
| 185 |
+
const url = new URL(location.href);
|
| 186 |
+
url.searchParams.set("word", target); // the link challenges a friend to draw the same word
|
| 187 |
+
const text = `${$("win-text").textContent} Can you beat it? ${url}`;
|
| 188 |
+
const copy = async () => { await navigator.clipboard.writeText(text); $("share").textContent = "Copied!"; };
|
| 189 |
+
try {
|
| 190 |
+
if (navigator.share) await navigator.share({ text }); else await copy();
|
| 191 |
+
} catch (err) {
|
| 192 |
+
// AbortError: the visitor closed the share sheet. Anything else (a frame without web-share): copy instead.
|
| 193 |
+
if (err.name !== "AbortError") await copy().catch(() => { $("share").textContent = "Copy the address bar to share"; });
|
| 194 |
+
}
|
| 195 |
+
});
|
| 196 |
+
$("clear").addEventListener("click", clearCanvas);
|
| 197 |
+
$("next").addEventListener("click", () => newWord());
|
| 198 |
+
const asked = new URLSearchParams(location.search).get("word");
|
| 199 |
+
newWord(asked);
|
| 200 |
+
</script>
|
| 201 |
+
</body>
|
| 202 |
+
</html>
|