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`.