File size: 12,984 Bytes
4dc3312 23df34a df84e45 4dc3312 df84e45 4dc3312 df84e45 4dc3312 23df34a df84e45 b92691e df84e45 23df34a df84e45 23df34a cd5c830 df84e45 | 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 | ---
title: DesignGym
emoji: 🎨
colorFrom: indigo
colorTo: pink
sdk: docker
app_port: 8000
base_path: /web
pinned: false
tags: [openenv, mcp, computer-use, graphic-design, crello]
short_description: Recreate real graphic designs via MCP, HTML or a real editor
---
# DesignGym
An [OpenEnv](https://github.com/openenv/OpenEnv) environment for rebuilding real graphic designs from
their parts. Each task is a [Crello](https://huggingface.co/datasets/cyberagent/crello) template taken apart
into its pre-rendered layers and its typed copy. The agent puts it back together on a canvas and is scored
against a render of the original.
The package, the CLI, the OpenEnv name and the `DESIGN_CANVAS_*` settings keep the id `design_canvas`
(`design-canvas`). Clients, datasets and traces already use it.
- **Two tasks.** `mode="reference"`: here is the target image, recreate it. `mode="description"`: here is a
written brief naming every asset and line of copy, build it.
- **Three surfaces over one document.** `surface="tools"`: structured MCP edits. `surface="html"`: one page of
restricted HTML. `surface="gui"`: mouse and keyboard on a real browser editor, seeing only screenshots.
- **One renderer, one reward.** Every surface edits the same document, and every score comes from the same
skia renderer, so a GUI episode and a tool-call episode are directly comparable.
## Run it
```bash
design-canvas prepare # build the task pack from Crello (~1.4 GB download, 30 s to convert)
design-canvas serve --port 8007 # OpenEnv server + editor + playground
```
- Editor: `http://127.0.0.1:8007/editor/` · Playground: `/web/` · MCP: `/mcp` · Task API: `/design_canvas/...`
- Or `docker build -t design-canvas . && docker run -p 8000:8000 design-canvas` (builds the pack into the image).
The pack is a small offline sample. To serve **the whole corpus** (every playable Crello design) from the
[FineEnvs/crello-bucket](https://huggingface.co/buckets/FineEnvs/crello-bucket) bucket, point the server at the
corpus manifest:
```bash
DESIGN_CANVAS_CORPUS=corpus-manifest.json design-canvas serve --port 8007
```
The catalog (every task's ground truth, brief and difficulty) is one SQLite file copied from the bucket at
startup, so listing tasks and `reset` never download images. A design's assets are fetched the first time an
episode uses it: its 100-design row group is read (over HTTP locally, from the read-only `/corpus` mount on the
Space), checked against the indexed record, and cached on disk under a byte budget (`DESIGN_CANVAS_CACHE`).
Set `DESIGN_CANVAS_SOURCE_ROOT` to a mounted or downloaded copy of the bucket to read it as local files.
```bash
design-canvas corpus build --out artifacts/corpus-index --workers 12 # convert + index every design
design-canvas corpus publish --out artifacts/corpus-index # upload the catalog to the bucket
design-canvas evalset build --out ../../evalsets/crello-test-300.json # freeze the evaluation set
design-canvas evalset export ../../evalsets/crello-test-300.json --out artifacts/evalpack # offline copy
```
Computer-use data: record verified GUI trajectories, then write them as a Hub-ready `cua-v1` dataset
(`cua_format.py`: OpenAI-style messages with pixel tool calls, embedded screenshots, per-message `trainable`;
`windows()` derives SFT rows with the same screenshot window the Tinker RL adapter uses):
```bash
design-canvas export-cua --split train --out artifacts/cua --limit 200 # record (webp screenshots)
design-canvas export-hf --cua artifacts/cua/cua.jsonl --out artifacts/hf/cua # cua-v1 Parquet + dataset card
```
Check a running server end to end (HTTP routes, target gating, expert replays scoring 1.0 on tools and HTML in both
modes, an empty submission scoring 0, concurrent episodes, and with `--gui` two verified recorder episodes; the client
must load the same task source as the server):
```bash
DESIGN_CANVAS_CORPUS=artifacts/corpus-index .venv/bin/python -m design_canvas.verify_server --server http://127.0.0.1:8007 --gui
```
Watch Claude work, live:
```bash
claude mcp add design-canvas -- design-canvas mcp
```
then ask it to "start a design task and recreate it", and open the `editor_url` that `start_task` returns.
For more throughput, `design-canvas serve --workers N` (or `DESIGN_CANVAS_WORKERS=N`) runs N server processes behind
one URL ([`server/router.py`](server/router.py)). Don't use uvicorn's own `--workers`: OpenEnv's client creates a
session over HTTP and then attaches a WebSocket to it, and the built-in editor's page calls back for its episode,
so both must reach the process that holds the state. The router keeps every session and episode on its worker.
Measured locally (`scripts/loadtest.py`, 4 workers, no errors):
- tools: about 2,900 episodes per minute, 3× one process;
- built-in GUI editor: 128 concurrent episodes at about 190 actions per second, 2× one process.
The router itself saturates one core at about 300–450 tool calls per second, so 4–6 workers is the useful range.
On the private Space [FineEnvs/DesignGym](https://huggingface.co/spaces/FineEnvs/DesignGym) (`scripts/deploy_space.py`: cpu-upgrade, 8 vCPU / 32 GB, 4 workers × 48 sessions, the full
corpus read over HTTP), tested from India, where the network round trip is about 0.22 s:
| Load | Result | Throughput | Step p50 / p95 |
|---|---|---|---|
| Tools, 192 concurrent | 575/576 ok | 643 episodes/min, 110 calls/s | 0.26 / 0.8 s |
| Built-in GUI editor, 128 concurrent | 256/256 ok | 75 actions/s | 0.46 / 1.3 s |
- The one failure arrived at the capacity edge, just before a closing session was freed.
- At capacity, cold resets take 7–10 s; each new design is fetched once.
- More than 192 sessions are refused cleanly ("Server at capacity") after every worker has been tried.
- OpenEnv's client has no option for auth headers. `scripts/loadtest.py --hf-token-env HF_TOKEN` shows the two
wrappers a client needs to reach a private Space.
## Talk to it
```python
from openenv.core.mcp_client import MCPToolClient
from openenv.core.env_server.mcp_types import CallToolAction
with MCPToolClient(base_url="http://127.0.0.1:8007").sync() as env:
obs = env.reset(split="test", index=3, mode="reference", surface="tools")
print(obs.observation.metadata["instructions"]) # the task, as the model should see it
tools = env.list_tools() # depends on the surface
env.step(CallToolAction(tool_name="add_image", arguments={"asset_id": "a2", "x": 0, "y": 0, "width": 1080}))
out = env.step(CallToolAction(tool_name="submit", arguments={}))
print(out.reward, out.observation.metadata["grade"])
```
`reset` arguments: `split`, `index` or `task_id`, `mode` (`reference` | `description`), `surface`
(`tools` | `html` | `gui`), `skin` (`figma` | `canva` | `photoshop` | `slides` | `powerpoint` | `inkscape` editor layouts; random per `seed`;
or a real app, below),
`theme`, `file_dialog` (`mac` | `windows` | `linux`: the look of the file window images are opened from; random per `seed`),
`coordinates` (`pixels`, or `normalized` for a 0-1000 grid), `max_steps`,
`reward_mode` (`final`, or `dense` for per-edit score deltas that sum to the final score).
The Task API lists tasks without giving them away: `GET /design_canvas/splits`,
`POST /design_canvas/num_tasks`, `/task`, `/task_range`.
## Tools
| Surface | Tools |
|---|---|
| all | `get_task`, `view_reference`, `submit`, `start_task` (for MCP clients with no `reset`) |
| tools, html | `view_canvas`, `view_assets` (a labelled contact sheet), `list_fonts` |
| tools | `add_image`, `add_text`, `add_shape`, `update_element`, `delete_element`, `duplicate_element`, `reorder_element`, `align_elements`, `set_background`, `list_elements`, `undo`, `redo` |
| html | `write_html`, `read_html` |
| gui | `screenshot`, `left_click`, `double_click`, `triple_click`, `right_click`, `mouse_move`, `left_click_drag`, `scroll`, `type`, `key`, `wait` |
Images come back as MCP image content blocks, so a vision model sees pixels, not base64.
## Real apps
The GUI surface can also put the model in a real design app instead of the built-in editor: `skin="penpot"`
(Penpot in the env's browser), `"impress"` (LibreOffice Impress), `"inkscape-desktop"` or `"gimp"` (on a Linux
desktop). The tools, the prompt's task and the reward are the same. At `reset` the env sets the app up with a
blank canvas at the task's size and the task's assets loaded. On `submit` it reads the app's own document back
(Penpot's file, the slide, the SVG, the layers) as a design document and scores it like any other.
The client always talks to one env server, and that server runs the apps:
- **Locally.** Run Penpot with `realapps/penpot/docker-compose.yaml` and the desktop image from
`realapps/desktop/`. The server finds them at `DESIGN_CANVAS_PENPOT_URL` and `DESIGN_CANVAS_DESKTOP_URLS`.
- **On Daytona.** Set `DESIGN_CANVAS_APPS=daytona` and `DAYTONA_API_KEY`. The server keeps its own pool of
private sandboxes (`realapps/daytona.py`): one per running desktop episode (`DESIGN_CANVAS_DAYTONA_DESKTOPS`,
default 2, built from a snapshot of the desktop image) and one Penpot sandbox shared by all Penpot episodes.
It reaches them through their preview URLs with the access token in a header, and the browser may load that
app and nothing else. Sandboxes are reused across server restarts and stop when idle.
`python -m design_canvas.realapps.daytona up | status | down` starts, lists or deletes them.
A real-app episode's `reset` observation carries `app` (`{"skin", "name", "real": true}`), and its `editor_url` is
the app's live screen (`/api/episodes/{id}/screen.png`). `GET /api/apps` lists the apps and where they run
(`?check=1` also asks each endpoint or sandbox whether it is up). In the `/web` playground, pick a real app under
**Editor**: the Editor tab shows its screen, and a click on the screen is a click in the app. The Expert tab
writes the target into the app, and **Read the document from the app** shows what Submit will score.
Expert round trips (the ground truth written into the app and read back) score 1.000 in Penpot and Inkscape,
0.975 to 0.999 in Impress, and 0.953 to 0.991 in GIMP, which has no mixed-colour text runs.
## Reward
`0.60·structure + 0.30·visual + 0.05·order + 0.05·background` in reference mode. Structure matches elements
one-to-one (Hungarian) on content, geometry and typography; visual is multi-scale SSIM and colour layout against
`render(ground truth)`, normalised so an empty canvas scores 0. Description mode loosens geometry and adds
coverage. Replaying the ground truth scores exactly 1.000 on every task. Details:
[`core/score.py`](core/score.py) and the project README.
`DESIGN_CANVAS_REWARD=3` (or `grade(..., version=3)`) selects reward v3 for reference mode, calibrated against
a pairwise visual judge (Claude Opus 5.5, both orders) on frontier and Qwen attempts across all three surfaces:
- **Coarse-to-fine geometry.** Half the tight kernel, half a loose one (15% of the diagonal), scaled by √position,
so a roughly placed element in the right region earns credit, but 1.0 still needs exact placement.
- **Typography that multiplies.** Font and colour scale the rest of the style match, and style scales placement.
- **Copy counts triple in structure.** Every unmatched submitted element counts against the score.
- **Gated pixel terms.** Visual, order and background count in proportion to min(1, structure/0.3), so colour
patches and scattered dumps (~0.25 under v1) score ~0.
Results:
- **Validation:** agrees with the judge on 89.9% of 556 pairs vs 85.4% for v1 (+33/−8, p<0.001), and on 91.4%
of Qwen pairs.
- **Held-out test tasks:** 88.4% vs 84.2% (p=0.005), including 271 GUI pairs.
- **Description mode:** unchanged.
## Layout
```
core/ document model, ops, fonts, skia renderer, reward, HTML profile, briefs, expert, Crello conversion
server/ OpenEnv environment, editor API + live sync, GUI driver (Playwright), Gradio playground, stdio MCP
realapps/ real apps behind the GUI surface: Penpot, the desktop agent (Impress, Inkscape, GIMP), Daytona pool
web/ the editor: vanilla JS, six layouts (Studio / Template / Pixel / Deck / Present / Vector), no build step
scripts/ parity.py (browser vs renderer vs Crello), gui_expert.py (scripted computer-use agent)
tests/ core, reward invariants over every task, OpenEnv contract, browser E2E, GUI agent
rollout.py real models on every surface (Anthropic, OpenAI, Hugging Face router)
```
Data: Crello is CDLA-Permissive-2.0; the templates belong to VistaCreate. The pack is built locally from the
dataset and not redistributed here.
## Served corpus
22,688 Crello designs (train 18,954, validation 1,817, test 1,917), read on demand from the [FineEnvs/crello-bucket](https://huggingface.co/buckets/FineEnvs/crello-bucket) bucket mounted at `/corpus`. Snapshot `aafdb0e86816e6af`.
|