Ryz3n758 commited on
Commit
bc9708d
·
verified ·
1 Parent(s): 84a497d

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
2
+ FROM ${BASE_IMAGE} AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Ensure git is available (required for installing dependencies from VCS)
7
+ RUN apt-get update && \
8
+ apt-get install -y --no-install-recommends git && \
9
+ rm -rf /var/lib/apt/lists/*
10
+
11
+ # Build argument to control whether we're building standalone or in-repo
12
+ ARG BUILD_MODE=in-repo
13
+ ARG ENV_NAME=layoutenv
14
+
15
+ # Copy environment code (always at root of build context)
16
+ COPY . /app/env
17
+
18
+ # For in-repo builds, openenv is already vendored in the build context
19
+ # For standalone builds, openenv will be installed via pyproject.toml
20
+ WORKDIR /app/env
21
+
22
+ # Ensure stats directory exists and copy precomputed dataset stats when present.
23
+ # Supports both common build-context layouts:
24
+ # - context at repo root -> /app/env/dataset/...
25
+ # - context at layoutenv/ -> optional /app/dataset/... fallback
26
+ RUN mkdir -p /app/env/dataset && \
27
+ if [ -f /app/env/dataset/genposter_5000_images_stats.npy ]; then \
28
+ echo "Using stats at /app/env/dataset/genposter_5000_images_stats.npy"; \
29
+ elif [ -f /app/dataset/genposter_5000_images_stats.npy ]; then \
30
+ cp /app/dataset/genposter_5000_images_stats.npy /app/env/dataset/genposter_5000_images_stats.npy; \
31
+ echo "Copied stats into /app/env/dataset/genposter_5000_images_stats.npy"; \
32
+ else \
33
+ echo "Warning: genposter_5000_images_stats.npy not found in build context."; \
34
+ fi
35
+
36
+ # Ensure uv is available (for local builds where base image lacks it)
37
+ RUN if ! command -v uv >/dev/null 2>&1; then \
38
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
39
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
40
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
41
+ fi
42
+
43
+ # Install dependencies using uv sync
44
+ # If uv.lock exists, use it; otherwise resolve on the fly
45
+ RUN --mount=type=cache,target=/root/.cache/uv \
46
+ if [ -f uv.lock ]; then \
47
+ uv sync --frozen --no-install-project --no-editable; \
48
+ else \
49
+ uv sync --no-install-project --no-editable; \
50
+ fi
51
+
52
+ RUN --mount=type=cache,target=/root/.cache/uv \
53
+ if [ -f uv.lock ]; then \
54
+ uv sync --frozen --no-editable; \
55
+ else \
56
+ uv sync --no-editable; \
57
+ fi
58
+
59
+ # Final runtime stage
60
+ FROM ${BASE_IMAGE}
61
+
62
+ WORKDIR /app
63
+
64
+ RUN apt-get update && \
65
+ apt-get install -y --no-install-recommends curl && \
66
+ rm -rf /var/lib/apt/lists/*
67
+
68
+ # Copy the virtual environment from builder
69
+ COPY --from=builder /app/env/.venv /app/env/.venv
70
+
71
+ # Copy the environment code
72
+ COPY --from=builder /app/env /app/env
73
+
74
+ # Set PATH to use the virtual environment
75
+ ENV PATH="/app/env/.venv/bin:$PATH"
76
+
77
+ # Set PYTHONPATH so imports work correctly
78
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
79
+ ENV PYTHONUNBUFFERED=1
80
+ # Gradio Web UI at /web (same pattern as snake_env server)
81
+ ENV ENABLE_WEB_INTERFACE=true
82
+
83
+ EXPOSE 8000
84
+
85
+ # Health check
86
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
87
+ CMD curl -fsS http://localhost:8000/health || exit 1
88
+
89
+ # Run the FastAPI server
90
+ # The module path is constructed to work with the /app/env structure
91
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md CHANGED
@@ -1,12 +1,231 @@
1
  ---
2
- title: Layoutenv
3
- emoji: 🐠
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: docker
7
  pinned: false
8
- license: apache-2.0
9
- short_description: OpenEnv environment for poster layout refinement via RL
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: LayoutEnv Environment Server
3
+ emoji: 🎭
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
  ---
13
 
14
+ # LayoutEnv: Poster Layout Refinement Environment
15
+
16
+ `layoutenv` is a practical OpenEnv benchmark for iterative poster/layout cleanup.
17
+ An agent receives a noisy layout and improves it step-by-step using discrete edit actions:
18
+ `MOVE`, `RESIZE`, `ALIGN`, `SNAP`, and `NO_OP`.
19
+
20
+ The task is designed for:
21
+ - spatial reasoning across multiple elements
22
+ - optimization with shaped rewards
23
+ - LLM and VLM agent evaluation on iterative improvement loops
24
+
25
+ ## Task Overview
26
+
27
+ Each episode starts from a perturbed sample with normalized geometry.
28
+ At every step, the agent picks:
29
+ - target element (`element_id`)
30
+ - action type (`MOVE`, `RESIZE`, `ALIGN`, `SNAP`, `NO_OP`)
31
+ - action parameter (`UP`, `LEFT`, `CENTER_X`, `GRID`, etc.)
32
+ - optional magnitude for `MOVE`/`RESIZE` (`SMALL`, `MEDIUM`, `LARGE`)
33
+
34
+ The episode ends when:
35
+ - max step budget is reached, or
36
+ - agent emits `NO_OP` (treat as stop)
37
+
38
+ ## Quick Start
39
+
40
+ The simplest way to use the environment is through the `LayoutEnv` client:
41
+
42
+ ```python
43
+ from layoutenv import LayoutAction, LayoutEnv
44
+
45
+ async def run_example() -> None:
46
+ env = await LayoutEnv.from_docker_image("layoutenv:latest")
47
+ try:
48
+ result = await env.reset(mode="llm")
49
+ print("Initial Q:", result.observation.quality_score)
50
+ result = await env.step(LayoutAction(
51
+ element_id=0,
52
+ action="ALIGN",
53
+ param="CENTER_X",
54
+ magnitude="MEDIUM",
55
+ ))
56
+ print("Reward:", result.reward, "Done:", result.done)
57
+ finally:
58
+ await env.close()
59
+
60
+ import asyncio
61
+ asyncio.run(run_example())
62
+ ```
63
+
64
+ If you prefer a sync client flow, instantiate with `LayoutEnv(base_url=...)`
65
+ and call the synchronous methods in your own wrapper.
66
+
67
+ `LayoutEnv.from_docker_image(...)` handles:
68
+ - starting the container
69
+ - waiting for readiness
70
+ - connecting the client
71
+ - container cleanup on `close()`
72
+
73
+ ## Build the Docker Image
74
+
75
+ From repo root:
76
+
77
+ ```bash
78
+ docker build -t layoutenv:latest -f layoutenv/server/Dockerfile .
79
+ ```
80
+
81
+ From `layoutenv/` directory:
82
+
83
+ ```bash
84
+ docker build -t layoutenv:latest -f server/Dockerfile .
85
+ ```
86
+
87
+ ## Run the Server (Volume-Mounted Dataset)
88
+
89
+ The current runtime expects dataset assets at `/app/env/dataset` in-container.
90
+ Recommended run command from repo root:
91
+
92
+ ```bash
93
+ docker run --rm -d \
94
+ --name layoutenv-server \
95
+ -p 8000:8000 \
96
+ -v "$(pwd)/dataset:/app/env/dataset" \
97
+ layoutenv:latest
98
+ ```
99
+
100
+ Verify endpoints:
101
+
102
+ ```bash
103
+ curl -s http://localhost:8000/health
104
+ curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:8000/reset
105
+ ```
106
+
107
+ Stop:
108
+
109
+ ```bash
110
+ docker stop layoutenv-server
111
+ ```
112
+
113
+ ## Usage
114
+
115
+ Submission baseline script is root `inference.py` (required location).
116
+ It uses `LayoutEnv.from_docker_image()` and emits evaluator-friendly stdout:
117
+ - `[START] ...`
118
+ - `[STEP] ...`
119
+ - `[END] ...`
120
+
121
+ ### LLM run
122
+
123
+ ```bash
124
+ API_BASE_URL=... MODEL_NAME=... HF_TOKEN=... \
125
+ IMAGE_NAME=layoutenv:latest python inference.py --seed 42
126
+ ```
127
+
128
+ ### VLM easy-task smoke
129
+
130
+ ```bash
131
+ API_BASE_URL=... MODEL_NAME=... HF_TOKEN=... \
132
+ IMAGE_NAME=layoutenv:latest python inference.py --mode vlm --task easy --max-steps 5 --seed 42
133
+ ```
134
+
135
+ ## Environment Details
136
+
137
+ ### Action (`LayoutAction`)
138
+
139
+ Fields:
140
+ - `element_id` (int): target element index
141
+ - `action` (str): `MOVE` | `RESIZE` | `ALIGN` | `SNAP` | `NO_OP`
142
+ - `param` (str):
143
+ - `MOVE`: `UP`, `DOWN`, `LEFT`, `RIGHT`
144
+ - `RESIZE`: `WIDER`, `NARROWER`, `TALLER`, `SHORTER`
145
+ - `ALIGN`: `LEFT`, `CENTER_X`, `RIGHT`, `TOP`, `CENTER_Y`, `BOTTOM`
146
+ - `SNAP`: `GRID`
147
+ - `NO_OP`: `NONE`
148
+ - `magnitude` (str): `SMALL`, `MEDIUM`, `LARGE` (used for `MOVE`/`RESIZE`)
149
+
150
+ ### Observation (`LayoutObservation`)
151
+
152
+ Per-step payload includes:
153
+ - `canvas`: normalized canvas (`width=1.0`, `height=1.0`)
154
+ - `elements`: list of `{id, type, cx, cy, w, h, font_size}`
155
+ - `metrics`: layout metrics:
156
+ - `overlap` (lower better)
157
+ - `boundary` (lower better)
158
+ - `alignment` (higher better)
159
+ - `spacing` (higher better)
160
+ - `plausibility` (higher better)
161
+ - `quality_score`: composite quality value `Q`
162
+ - `initial_quality_score`: `Q` at reset
163
+ - `step`, `max_steps`
164
+ - optional VLM fields (`image_path`, `rendered_image_base64`)
165
+ - optional `text_feedback`
166
+
167
+ ### State (`LayoutState`)
168
+
169
+ Server state tracks:
170
+ - `episode_id`, `step_count`
171
+ - current `elements`
172
+ - `previous_quality`, `initial_quality`
173
+ - VLM context (`current_image_rel`, `dataset_json_path`)
174
+
175
+ ### Reward
176
+
177
+ Step reward is shaped by quality improvements:
178
+ - `reward = REWARD_SCALE * (Q_t - Q_{t-1}) + STEP_PENALTY`
179
+ - invalid actions incur a penalty
180
+ - terminal shaping applies at episode end
181
+
182
+ This gives dense training/evaluation signal, not only terminal success.
183
+
184
+ ## Task Grading
185
+
186
+ Deterministic grading logic is implemented in `layoutenv/grader.py`:
187
+ - `q_delta = final_q - initial_q`
188
+ - `score = clamp((q_delta + 2.0) / 4.0, 0, 1)`
189
+ - task-specific success thresholds:
190
+ - `easy >= 0.05`
191
+ - `medium >= 0.10`
192
+ - `hard >= 0.15`
193
+
194
+ Note: score is intentionally clamped to `[0, 1]` for stable reporting.
195
+
196
+ ## Deploy to Hugging Face Spaces
197
+
198
+ From `layoutenv/`:
199
+
200
+ ```bash
201
+ openenv push
202
+ ```
203
+
204
+ Or specify repo:
205
+
206
+ ```bash
207
+ openenv push --repo-id <namespace>/<space-name>
208
+ ```
209
+
210
+ After deploy, verify:
211
+ - `POST /reset` returns 200
212
+ - `/docs` is reachable
213
+ - `/health` is healthy
214
+
215
+ ## Project Structure
216
+
217
+ ```text
218
+ layoutenv/
219
+ ├── __init__.py
220
+ ├── client.py
221
+ ├── grader.py
222
+ ├── models.py
223
+ ├── openenv.yaml
224
+ ├── pyproject.toml
225
+ ├── README.md
226
+ └── server/
227
+ ├── app.py
228
+ ├── layout_environment.py
229
+ ├── metrics.py
230
+ └── Dockerfile
231
+ ```
__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .client import LayoutEnv
2
+ from .grader import TaskGrade, grade_episode
3
+ from .models import LayoutAction, LayoutObservation, LayoutState
4
+
5
+ __all__ = [
6
+ "LayoutAction",
7
+ "LayoutObservation",
8
+ "LayoutState",
9
+ "LayoutEnv",
10
+ "TaskGrade",
11
+ "grade_episode",
12
+ ]
client.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Layout Environment Client.
3
+
4
+ HTTP / WebSocket client for interacting with a remote LayoutEnvironment server.
5
+ """
6
+
7
+ from typing import Dict
8
+
9
+
10
+ from openenv.core.client_types import StepResult # noqa: F401
11
+ from openenv.core.env_client import EnvClient
12
+ from .models import LayoutAction, LayoutObservation, LayoutState
13
+
14
+
15
+ class LayoutEnv(EnvClient[LayoutAction, LayoutObservation, LayoutState]):
16
+ """
17
+ Client for the Layout Environment.
18
+
19
+ Example:
20
+ >>> with LayoutEnv(base_url="http://localhost:8000") as client:
21
+ ... result = client.reset()
22
+ ... print(result.observation.quality_score)
23
+ ... result = client.step(LayoutAction(
24
+ ... element_id=0, action="MOVE", param="UP", magnitude="LARGE",
25
+ ... ))
26
+ """
27
+
28
+ def _step_payload(self, action: LayoutAction) -> Dict:
29
+ return action.to_dict()
30
+
31
+ def _parse_result(self, payload: Dict) -> StepResult[LayoutObservation]:
32
+ if "observation" not in payload:
33
+ raise ValueError(f"Invalid response: {payload}")
34
+
35
+ obs_data = payload["observation"]
36
+
37
+ observation = LayoutObservation(
38
+ canvas=obs_data.get("canvas", {"width": 1.0, "height": 1.0}),
39
+ elements=obs_data.get("elements", []),
40
+ metrics=obs_data.get("metrics", {}),
41
+ step=obs_data.get("step", 0),
42
+ max_steps=obs_data.get("max_steps", 20),
43
+ quality_score=obs_data.get("quality_score", 0.0),
44
+ initial_quality_score=obs_data.get("initial_quality_score", 0.0),
45
+ text_feedback=obs_data.get("text_feedback"),
46
+ done=payload.get("done", False),
47
+ reward=payload.get("reward", 0.0),
48
+ metadata=obs_data.get("metadata", {}),
49
+ image_path=obs_data.get("image_path"),
50
+ rendered_image_base64=obs_data.get("rendered_image_base64"),
51
+ )
52
+
53
+ return StepResult(
54
+ observation=observation,
55
+ reward=payload.get("reward", 0.0),
56
+ done=payload.get("done", False),
57
+ )
58
+
59
+ def _parse_state(self, payload: Dict) -> LayoutState:
60
+ return LayoutState(
61
+ episode_id=payload.get("episode_id"),
62
+ step_count=payload.get("step_count", 0),
63
+ )
grader.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic task graders for layoutenv benchmark tasks.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ TASK_SUCCESS_Q_DELTA = {
9
+ "easy": 0.05,
10
+ "medium": 0.1,
11
+ "hard": 0.15,
12
+ }
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TaskGrade:
17
+ task_id: str
18
+ score: float
19
+ success: bool
20
+ q_delta: float
21
+
22
+
23
+ def _clamp01(x: float) -> float:
24
+ return min(max(x, 0.0), 1.0)
25
+
26
+
27
+ def score_from_q_delta(q_delta: float) -> float:
28
+ """
29
+ Map quality delta to [0, 1] score.
30
+
31
+ The linear map is intentionally clamped so large outliers do not
32
+ destabilize reported leaderboard-compatible scores.
33
+ """
34
+ return _clamp01((q_delta + 2.0) / 4.0)
35
+
36
+
37
+ def success_from_q_delta(task_id: str, q_delta: float, default_threshold: float) -> bool:
38
+ """
39
+ Determine success using task-specific threshold if available.
40
+ """
41
+ threshold = TASK_SUCCESS_Q_DELTA.get(task_id, default_threshold)
42
+ return q_delta >= threshold
43
+
44
+
45
+ def grade_episode(
46
+ task_id: str,
47
+ initial_quality: float,
48
+ final_quality: float,
49
+ success_q_delta: float = 0.1,
50
+ ) -> TaskGrade:
51
+ q_delta = final_quality - initial_quality
52
+ return TaskGrade(
53
+ task_id=task_id,
54
+ score=score_from_q_delta(q_delta),
55
+ success=success_from_q_delta(task_id, q_delta, success_q_delta),
56
+ q_delta=q_delta,
57
+ )
models.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the Layout Environment.
3
+
4
+ The layout environment is an RL environment for training language models
5
+ to iteratively improve UI poster layouts via discrete actions.
6
+ """
7
+
8
+ from typing import Dict, List, Optional
9
+
10
+ from pydantic import Field
11
+ from openenv.core.env_server.types import Action, Observation, State
12
+
13
+
14
+ ACTIONS = {
15
+ "MOVE": ["UP", "DOWN", "LEFT", "RIGHT"],
16
+ "RESIZE": ["WIDER", "NARROWER", "TALLER", "SHORTER"],
17
+ "ALIGN": ["LEFT", "CENTER_X", "RIGHT", "TOP", "CENTER_Y", "BOTTOM"],
18
+ "SNAP": ["GRID"],
19
+ "NO_OP": ["NONE"],
20
+ }
21
+
22
+ MAGNITUDES = {
23
+ "SMALL": 0.01,
24
+ "MEDIUM": 0.05,
25
+ "LARGE": 0.1,
26
+ }
27
+
28
+ ALL_VALID_PARAMS = {param for params in ACTIONS.values() for param in params}
29
+
30
+
31
+ class LayoutAction(Action):
32
+ """
33
+ Action for the Layout environment. The agent selects one element and applies one operation per step.
34
+
35
+ Attributes:
36
+ element_id: Index of the target element in the layout.
37
+ action: One of "MOVE", "RESIZE", "ALIGN", "SNAP", "NO_OP".
38
+ param: Parameter for the action (e.g. "UP", "WIDER", "CENTER_X").
39
+ magnitude: Step size for MOVE/RESIZE — "SMALL" (0.01), "MEDIUM" (0.05),
40
+ "LARGE" (0.10). Ignored for other action types.
41
+ """
42
+
43
+ element_id: int = Field(default=0, description="Target element index")
44
+ action: str = Field(default="NO_OP", description="Action type")
45
+ param: str = Field(default="NONE", description="Action parameter")
46
+ magnitude: str = Field(default="MEDIUM", description="Step size for MOVE/RESIZE")
47
+
48
+ def is_valid(self, num_elements: int) -> bool:
49
+ # Validate action type
50
+ if self.action not in ACTIONS:
51
+ return False
52
+
53
+ # Validate param for the given action
54
+ valid_params = ACTIONS[self.action]
55
+ if self.param not in valid_params:
56
+ return False
57
+
58
+ # Validate element index (except NO_OP)
59
+ if self.action != "NO_OP":
60
+ if not isinstance(self.element_id, int):
61
+ return False
62
+ if not (0 <= self.element_id < num_elements):
63
+ return False
64
+
65
+ if self.action in ["MOVE", "RESIZE"] and self.magnitude not in MAGNITUDES:
66
+ return False
67
+
68
+ return True
69
+
70
+ def to_dict(self) -> Dict:
71
+ return {
72
+ "element_id": self.element_id,
73
+ "action": self.action,
74
+ "param": self.param,
75
+ "magnitude": self.magnitude,
76
+ }
77
+
78
+
79
+ class LayoutObservation(Observation):
80
+ """
81
+ Observation from the Layout environment.
82
+
83
+ All float coordinates are in normalised [0, 1] space and rounded to 3 decimal places to minimise LM token count.
84
+
85
+ Attributes:
86
+ canvas: Canvas dimensions (always {"width": 1.0, "height": 1.0}).
87
+ elements: Current element list with id, type, cx, cy, w, h, font_size.
88
+ metrics: Per-metric scores (overlap, boundary, alignment, spacing, plausibility).
89
+ step: Current step within the episode.
90
+ max_steps: Maximum steps allowed in this episode.
91
+ quality_score: Composite Q(state) — higher is better.
92
+ initial_quality_score: Q(state_0) at the start of this episode.
93
+ image_path: In VLM mode, path relative to the dataset JSON to the background (e.g. ``images/id_bg.png``).
94
+ rendered_image_base64: Optional in VLM mode. PNG of the background with
95
+ layout boxes and labels, base64-encoded, when server-side rendering
96
+ is enabled for the episode.
97
+ """
98
+
99
+ canvas: Dict = Field(default_factory=lambda: {"width": 1.0, "height": 1.0})
100
+ elements: List[Dict] = Field(default_factory=list)
101
+ metrics: Dict = Field(default_factory=dict)
102
+
103
+ step: int = 0
104
+ max_steps: int = 500
105
+
106
+ quality_score: float = 0.0
107
+ initial_quality_score: float = 0.0
108
+
109
+ text_feedback: Optional[str] = None
110
+
111
+ image_path: Optional[str] = None # background image path
112
+ rendered_image_base64: Optional[str] = None # layout rendered on background (visual prompt)
113
+
114
+ class LayoutState(State):
115
+ """
116
+ State of the Layout environment.
117
+ """
118
+ # Base State provides: episode_id, step_count
119
+ elements: List[Dict] = Field(default_factory=list)
120
+
121
+ # Quality tracking (for delta calculation)
122
+ previous_quality: float = 0.0 # Q(t-1)
123
+ initial_quality: float = 0.0 # Q(0)
124
+
125
+ current_image_rel: Optional[str] = None
126
+ dataset_json_path: Optional[str] = None
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: layoutenv
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
openenv_layoutenv.egg-info/PKG-INFO ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-layoutenv
3
+ Version: 0.1.0
4
+ Summary: Layout refinement RL environment for OpenEnv
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: openenv-core[core]>=0.2.1
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: pydantic>=2.0.0
9
+ Requires-Dist: uvicorn[standard]>=0.24.0
10
+ Requires-Dist: numpy>=1.24.0
11
+ Requires-Dist: pillow>=10.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
openenv_layoutenv.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ ./__init__.py
4
+ ./client.py
5
+ ./models.py
6
+ openenv_layoutenv.egg-info/PKG-INFO
7
+ openenv_layoutenv.egg-info/SOURCES.txt
8
+ openenv_layoutenv.egg-info/dependency_links.txt
9
+ openenv_layoutenv.egg-info/entry_points.txt
10
+ openenv_layoutenv.egg-info/requires.txt
11
+ openenv_layoutenv.egg-info/top_level.txt
12
+ server/__init__.py
13
+ server/app.py
14
+ server/layout_environment.py
15
+ server/metrics.py
openenv_layoutenv.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
openenv_layoutenv.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ server = layoutenv.server.app:main
openenv_layoutenv.egg-info/requires.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.1
2
+ fastapi>=0.115.0
3
+ pydantic>=2.0.0
4
+ uvicorn[standard]>=0.24.0
5
+ numpy>=1.24.0
6
+ pillow>=10.0.0
7
+
8
+ [dev]
9
+ pytest>=8.0.0
10
+ pytest-cov>=4.0.0
openenv_layoutenv.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ layoutenv
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-layoutenv"
7
+ version = "0.1.0"
8
+ description = "Layout refinement RL environment for OpenEnv"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openenv-core[core]>=0.2.1",
12
+ "fastapi>=0.115.0",
13
+ "pydantic>=2.0.0",
14
+ "uvicorn[standard]>=0.24.0",
15
+ "numpy>=1.24.0",
16
+ "pillow>=10.0.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0.0",
22
+ "pytest-cov>=4.0.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ server = "layoutenv.server.app:main"
27
+
28
+ [tool.setuptools]
29
+ include-package-data = true
30
+ packages = ["layoutenv", "layoutenv.server"]
31
+ package-dir = { "layoutenv" = ".", "layoutenv.server" = "server" }
server/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .layout_environment import LayoutEnvironment
2
+
3
+ __all__ = ["LayoutEnvironment"]
server/app.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for the Layout Environment.
3
+
4
+ Endpoints:
5
+ - POST /reset: Reset the environment
6
+ - POST /step: Execute an action
7
+ - GET /state: Get current environment state
8
+ - GET /schema: Get action/observation schemas
9
+ - WS /ws: WebSocket endpoint for persistent sessions
10
+
11
+ Usage:
12
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
13
+
14
+ The mode (llm/vlm) and text_feedback flag are set per-episode via reset().
15
+ """
16
+
17
+ try:
18
+ from openenv.core.env_server.http_server import create_app
19
+ except Exception as e:
20
+ raise ImportError(
21
+ "openenv is required for the web interface. "
22
+ "Install dependencies with 'uv sync'"
23
+ ) from e
24
+
25
+ try:
26
+ from ..models import LayoutAction, LayoutObservation
27
+ from .layout_environment import LayoutEnvironment
28
+ except ImportError:
29
+ from models import LayoutAction, LayoutObservation
30
+ from server.layout_environment import LayoutEnvironment
31
+
32
+ app = create_app(
33
+ LayoutEnvironment,
34
+ LayoutAction,
35
+ LayoutObservation,
36
+ env_name="layoutenv",
37
+ max_concurrent_envs=1,
38
+ )
39
+
40
+
41
+ def main() -> None:
42
+ """Entry point for direct execution via ``uv run --project . server``."""
43
+ import argparse
44
+ import uvicorn
45
+
46
+ parser = argparse.ArgumentParser()
47
+ parser.add_argument("--host", type=str, default="0.0.0.0")
48
+ parser.add_argument("--port", type=int, default=8000)
49
+ args = parser.parse_args()
50
+ uvicorn.run(app, host=args.host, port=args.port)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
server/layout_environment.py ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Layout Environment Implementation.
3
+
4
+ An RL environment for iteratively refining UI poster layouts.
5
+ The agent receives a layout and must improve it using discrete actions
6
+ (MOVE, RESIZE, ALIGN, SNAP, NO_OP).
7
+
8
+ Perturbations are the responsibility of the caller (e.g. inference.py);
9
+ this environment is agnostic to how the initial layout was produced.
10
+ """
11
+ from __future__ import annotations
12
+ import base64
13
+ import io
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Literal, Optional
16
+ from uuid import uuid4
17
+
18
+ import numpy as np
19
+ from PIL import Image, ImageDraw, ImageFont
20
+
21
+ from openenv.core.env_server.interfaces import Environment
22
+
23
+ try:
24
+ from ..models import ACTIONS, MAGNITUDES, LayoutAction, LayoutObservation, LayoutState
25
+ except (ImportError, ModuleNotFoundError):
26
+ from models import ACTIONS, MAGNITUDES, LayoutAction, LayoutObservation, LayoutState
27
+
28
+ from .metrics import (
29
+ _axis_value,
30
+ _to_ltrb,
31
+ compute_all_metrics,
32
+ quality_score,
33
+ )
34
+
35
+
36
+ # Single baked-in training-free layout (normalised bboxes).
37
+ # Training code should load the full dataset and pass ``sample=`` into ``reset``.
38
+ DEFAULT_LAYOUT_SAMPLE: Dict[str, Any] = {
39
+ "id": 0,
40
+ "canvas_size": [3556, 2000],
41
+ "elements": [
42
+ {
43
+ "type": "Title",
44
+ "text": "Demo",
45
+ "bbox": [0.2, 0.15, 0.8, 0.25],
46
+ "font_size": 120.0,
47
+ },
48
+ {
49
+ "type": "Bodytext",
50
+ "text": "Stateless default episode",
51
+ "bbox": [0.15, 0.4, 0.85, 0.55],
52
+ "font_size": 90.0,
53
+ },
54
+ {
55
+ "type": "Website",
56
+ "text": "example.com",
57
+ "bbox": [0.35, 0.85, 0.65, 0.92],
58
+ "font_size": 48.0,
59
+ },
60
+ ],
61
+ }
62
+
63
+
64
+ def _bbox_to_centre(bbox: List[float]) -> Dict[str, float]:
65
+ x1, y1, x2, y2 = bbox
66
+ return {
67
+ "cx": (x1 + x2) / 2,
68
+ "cy": (y1 + y2) / 2,
69
+ "w": x2 - x1,
70
+ "h": y2 - y1,
71
+ }
72
+
73
+
74
+ def _default_stats_from_sample(sample: Dict[str, Any]) -> Dict[str, Any]:
75
+ """
76
+ Per-element-type Gaussian plausibility priors matching sample's ground truth.
77
+ Shared isotropic covariance (loose prior) so perturbed layouts still score smoothly.
78
+ """
79
+ inv_cov = np.linalg.inv((0.1**2) * np.eye(5) + 1e-6 * np.eye(5))
80
+ out: Dict[str, Any] = {}
81
+ for elem in sample.get("elements", []):
82
+ etype = elem.get("type")
83
+ if not etype or etype in out:
84
+ continue
85
+ centre = _bbox_to_centre(elem["bbox"])
86
+ canvas_h = float(sample["canvas_size"][1])
87
+ fs_raw = float(elem.get("font_size", 0.0) or 0.0)
88
+ fs_norm = fs_raw / canvas_h if canvas_h > 0 else 0.0
89
+ mu = np.array(
90
+ [centre["cx"], centre["cy"], centre["w"], centre["h"], fs_norm],
91
+ dtype=np.float64,
92
+ )
93
+ out[etype] = {"mu": mu, "cov_inv": inv_cov}
94
+ return out
95
+
96
+
97
+ DEFAULT_STATS: Dict[str, Any] = _default_stats_from_sample(DEFAULT_LAYOUT_SAMPLE)
98
+
99
+
100
+ def _sample_to_elements(sample: Dict) -> List[Dict]:
101
+ """Convert a dataset sample to the internal element list."""
102
+ canvas_w, canvas_h = sample["canvas_size"]
103
+ elements = []
104
+ for i, elem in enumerate(sample.get("elements", [])):
105
+ centre = _bbox_to_centre(elem["bbox"])
106
+ fs_raw = float(elem.get("font_size", 0.0) or 0.0)
107
+ fs_norm = fs_raw / canvas_h if canvas_h > 0 else 0.0
108
+ elements.append({
109
+ "id": i,
110
+ "type": elem.get("type", "unknown"),
111
+ "text": elem.get("text", ""),
112
+ "cx": centre["cx"],
113
+ "cy": centre["cy"],
114
+ "w": centre["w"],
115
+ "h": centre["h"],
116
+ "font_size": fs_norm,
117
+ })
118
+ return elements
119
+
120
+
121
+ # Action application
122
+ def _apply_action(
123
+ elements: List[Dict],
124
+ action: LayoutAction,
125
+ ) -> None:
126
+ """Mutate elements in-place according to action"""
127
+ eid = action.element_id
128
+ act = action.action
129
+ param = action.param
130
+ delta = MAGNITUDES.get(action.magnitude, MAGNITUDES["MEDIUM"])
131
+ elem = elements[eid]
132
+
133
+ if act == "MOVE":
134
+ if param == "UP":
135
+ elem["cy"] -= delta
136
+ elif param == "DOWN":
137
+ elem["cy"] += delta
138
+ elif param == "LEFT":
139
+ elem["cx"] -= delta
140
+ elif param == "RIGHT":
141
+ elem["cx"] += delta
142
+
143
+ elif act == "RESIZE":
144
+ if param == "WIDER":
145
+ elem["w"] += delta
146
+ elif param == "NARROWER":
147
+ elem["w"] -= delta
148
+ elif param == "TALLER":
149
+ elem["h"] += delta
150
+ elif param == "SHORTER":
151
+ elem["h"] -= delta
152
+ # Keep geometry valid for downstream metric computations.
153
+ elem["w"] = max(0.01, min(1.0, elem["w"]))
154
+ elem["h"] = max(0.01, min(1.0, elem["h"]))
155
+
156
+ elif act == "ALIGN":
157
+ _apply_align(elements, eid, param)
158
+
159
+ elif act == "SNAP":
160
+ grid = 0.05
161
+ elem["cx"] = round(elem["cx"] / grid) * grid
162
+ elem["cy"] = round(elem["cy"] / grid) * grid
163
+
164
+
165
+ _PARAM_TO_AXIS = {
166
+ "LEFT": "left", "RIGHT": "right", "CENTER_X": "cx",
167
+ "TOP": "top", "BOTTOM": "bottom", "CENTER_Y": "cy",
168
+ }
169
+
170
+
171
+ def _apply_align(
172
+ elements: List[Dict],
173
+ eid: int,
174
+ param: str,
175
+ threshold: float = 0.15,
176
+ ) -> None:
177
+ """Nearest-neighbour inter-element alignment with canvas fallback."""
178
+ target = elements[eid]
179
+ others = [e for e in elements if e["id"] != target["id"]]
180
+ axis = _PARAM_TO_AXIS.get(param, param.lower())
181
+
182
+ target_val = _axis_value(target, axis)
183
+ best_val: Optional[float] = None
184
+ best_dist = float("inf")
185
+
186
+ for other in others:
187
+ other_val = _axis_value(other, axis)
188
+ dist = abs(target_val - other_val)
189
+ if dist < best_dist:
190
+ best_dist = dist
191
+ best_val = other_val
192
+
193
+ if best_val is not None and best_dist < threshold:
194
+ snap_to = best_val
195
+ else:
196
+ canvas_anchors = {
197
+ "left": 0.0, "right": 1.0, "cx": 0.5,
198
+ "top": 0.0, "bottom": 1.0, "cy": 0.5,
199
+ }
200
+ snap_to = canvas_anchors[axis]
201
+
202
+ _set_axis_value(target, axis, snap_to)
203
+
204
+
205
+ def _set_axis_value(e: Dict, axis: str, val: float) -> None:
206
+ hw, hh = e["w"] / 2, e["h"] / 2
207
+ if axis == "left":
208
+ e["cx"] = val + hw
209
+ elif axis == "right":
210
+ e["cx"] = val - hw
211
+ elif axis == "cx":
212
+ e["cx"] = val
213
+ elif axis == "top":
214
+ e["cy"] = val + hh
215
+ elif axis == "bottom":
216
+ e["cy"] = val - hh
217
+ elif axis == "cy":
218
+ e["cy"] = val
219
+
220
+
221
+
222
+ # Round helpers
223
+ def _round_elements(elements: List[Dict], dp: int = 3) -> List[Dict]:
224
+ """Return a copy with floats rounded for observation output."""
225
+ out = []
226
+ for e in elements:
227
+ out.append({
228
+ "id": e["id"],
229
+ "type": e["type"],
230
+ "cx": round(e["cx"], dp),
231
+ "cy": round(e["cy"], dp),
232
+ "w": round(e["w"], dp),
233
+ "h": round(e["h"], dp),
234
+ "font_size": round(e["font_size"], dp),
235
+ })
236
+ return out
237
+
238
+
239
+ def _resolve_media_path(dataset_json_path: str, relative_path: str) -> Path:
240
+ """
241
+ Resolve e.g. images/0_bg.png relative to the dataset JSON directory.
242
+ This supports volume-mounted datasets when the server container can access
243
+ the dataset path.
244
+ """
245
+ return Path(dataset_json_path).resolve().parent / relative_path
246
+
247
+
248
+ def _render_layout_on_background(
249
+ bg_path: str | Path | None,
250
+ elements: List[Dict],
251
+ bg_pil: Image.Image | None = None,
252
+ ) -> Image.Image:
253
+ """
254
+ Draw normalized layout (cx, cy, w, h in [0, 1]) on top of the background.
255
+ Filled rectangles use distinct colors; label = type and truncated text.
256
+ If bg_path is None or missing, a neutral placeholder canvas is used.
257
+ bg_pil allows passing an already-loaded PIL image (e.g. decoded from
258
+ base64) so the environment can work without filesystem access.
259
+ """
260
+ if bg_pil is not None:
261
+ base = bg_pil.convert("RGBA")
262
+ w_px, h_px = base.size
263
+ elif bg_path is None:
264
+ w_px, h_px = 1024, 1024
265
+ base = Image.new("RGBA", (w_px, h_px), (245, 245, 245, 255))
266
+ else:
267
+ path = Path(bg_path)
268
+ if not path.is_file():
269
+ w_px, h_px = 1024, 1024
270
+ base = Image.new("RGBA", (w_px, h_px), (245, 245, 245, 255))
271
+ else:
272
+ with Image.open(path) as img:
273
+ base = img.convert("RGBA")
274
+ w_px, h_px = base.size
275
+
276
+ overlay = Image.new("RGBA", (w_px, h_px), (0, 0, 0, 0))
277
+ draw = ImageDraw.Draw(overlay)
278
+
279
+ palette = [
280
+ (255, 99, 71, 90),
281
+ (60, 179, 113, 90),
282
+ (65, 105, 225, 90),
283
+ (238, 130, 238, 90),
284
+ (255, 215, 0, 90),
285
+ (0, 206, 209, 90),
286
+ (255, 140, 0, 90),
287
+ (147, 112, 219, 90),
288
+ ]
289
+ line_w = max(1, min(w_px, h_px) // 100)
290
+
291
+ for i, e in enumerate(elements):
292
+ cx, cy, ew, eh = (
293
+ float(e["cx"]),
294
+ float(e["cy"]),
295
+ float(e["w"]),
296
+ float(e["h"]),
297
+ )
298
+ x1 = int((cx - ew / 2) * w_px)
299
+ y1 = int((cy - eh / 2) * h_px)
300
+ x2 = int((cx + ew / 2) * w_px)
301
+ y2 = int((cy + eh / 2) * h_px)
302
+ x1 = max(0, min(x1, w_px - 1))
303
+ y1 = max(0, min(y1, h_px - 1))
304
+ x2 = max(0, min(x2, w_px - 1))
305
+ y2 = max(0, min(y2, h_px - 1))
306
+ if x2 <= x1:
307
+ x2 = min(w_px - 1, x1 + 1)
308
+ if y2 <= y1:
309
+ y2 = min(h_px - 1, y1 + 1)
310
+
311
+ fill = palette[i % len(palette)]
312
+ outline = (*fill[:3], 255)
313
+ draw.rectangle([x1, y1, x2, y2], fill=fill, outline=outline, width=line_w)
314
+
315
+ composed = Image.alpha_composite(base, overlay)
316
+ d2 = ImageDraw.Draw(composed)
317
+
318
+ font_size = max(8, min(w_px, h_px) // 18)
319
+ font: ImageFont.FreeTypeFont | ImageFont.ImageFont
320
+ try:
321
+ font = ImageFont.truetype(
322
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size
323
+ )
324
+ except OSError:
325
+ try:
326
+ font = ImageFont.truetype("DejaVuSans.ttf", font_size)
327
+ except OSError:
328
+ font = ImageFont.load_default()
329
+
330
+ for i, e in enumerate(elements):
331
+ cx, cy, ew, eh = (
332
+ float(e["cx"]),
333
+ float(e["cy"]),
334
+ float(e["w"]),
335
+ float(e["h"]),
336
+ )
337
+ x1 = int((cx - ew / 2) * w_px)
338
+ y1 = int((cy - eh / 2) * h_px)
339
+ x2 = int((cx + ew / 2) * w_px)
340
+ y2 = int((cy + eh / 2) * h_px)
341
+ x1 = max(0, min(x1, w_px - 1))
342
+ y1 = max(0, min(y1, h_px - 1))
343
+ x2 = max(0, min(x2, w_px - 1))
344
+ y2 = max(0, min(y2, h_px - 1))
345
+ if x2 <= x1:
346
+ x2 = min(w_px - 1, x1 + 1)
347
+ if y2 <= y1:
348
+ y2 = min(h_px - 1, y1 + 1)
349
+
350
+ raw_text = str(e.get("text", "") or "").strip()
351
+ label = str(e.get("type", "unknown") or "unknown")
352
+ if raw_text:
353
+ label = f"{label}: {raw_text}"
354
+ if len(label) > 48:
355
+ label = label[:45] + "..."
356
+
357
+ tb = d2.textbbox((0, 0), label, font=font)
358
+ tw, th = tb[2] - tb[0], tb[3] - tb[1]
359
+ tx = x1 + max(2, (x2 - x1 - tw) // 2)
360
+ ty = y1 + max(2, (y2 - y1 - th) // 2)
361
+
362
+ d2.text(
363
+ (tx, ty),
364
+ label,
365
+ font=font,
366
+ fill=(255, 255, 255, 255),
367
+ stroke_width=max(1, line_w // 2),
368
+ stroke_fill=(0, 0, 0, 255),
369
+ )
370
+
371
+ if composed.mode == "RGBA":
372
+ rgb = Image.new("RGB", composed.size, (255, 255, 255))
373
+ rgb.paste(composed, mask=composed.split()[3])
374
+ return rgb
375
+ return composed.convert("RGB")
376
+
377
+
378
+ def _pil_to_png_base64(img: Image.Image) -> str:
379
+ buf = io.BytesIO()
380
+ img.save(buf, format="PNG", optimize=True)
381
+ return base64.b64encode(buf.getvalue()).decode("ascii")
382
+
383
+
384
+ def _generate_text_feedback(
385
+ delta_q: float,
386
+ metrics: Dict[str, float],
387
+ elements: List[Dict],
388
+ ) -> str:
389
+ """
390
+ Produce a concise, actionable text hint from the current metrics.
391
+
392
+ The feedback tells the model (a) whether it improved, and (b) which
393
+ metric to target next with a concrete suggestion.
394
+ """
395
+ parts: List[str] = []
396
+
397
+ if delta_q > 0.01:
398
+ parts.append(f"Quality improved by +{delta_q:.3f}. Keep going.")
399
+ elif delta_q < -0.01:
400
+ parts.append(f"Quality dropped by {delta_q:.3f}. Undo or try a different action.")
401
+ else:
402
+ parts.append("Negligible change. Try a different element or direction.")
403
+
404
+ overlap = metrics.get("overlap", 0.0)
405
+ boundary = metrics.get("boundary", 0.0)
406
+ alignment = metrics.get("alignment", 1.0)
407
+ spacing = metrics.get("spacing", 1.0)
408
+
409
+ penalties = {"overlap": overlap, "boundary": boundary}
410
+ worst_penalty_name = max(penalties, key=penalties.get) # type: ignore[arg-type]
411
+ worst_penalty_val = penalties[worst_penalty_name]
412
+
413
+ rewards = {"alignment": alignment, "spacing": spacing}
414
+ worst_reward_name = min(rewards, key=rewards.get) # type: ignore[arg-type]
415
+ worst_reward_val = rewards[worst_reward_name]
416
+
417
+ if worst_penalty_val > 0.05:
418
+ if worst_penalty_name == "overlap":
419
+ parts.append(
420
+ f"Overlap is high ({overlap:.3f}). "
421
+ "MOVE overlapping elements apart or RESIZE them smaller."
422
+ )
423
+ else:
424
+ oob = [
425
+ e["id"] for e in elements
426
+ if _is_out_of_bounds(e)
427
+ ]
428
+ if oob:
429
+ parts.append(
430
+ f"Boundary violation ({boundary:.3f}) on element(s) {oob}. "
431
+ "MOVE them inward or RESIZE them smaller."
432
+ )
433
+ else:
434
+ parts.append(
435
+ f"Boundary penalty ({boundary:.3f}). "
436
+ "Some elements may be near the edge; MOVE inward."
437
+ )
438
+ elif worst_reward_val < 0.5:
439
+ if worst_reward_name == "alignment":
440
+ parts.append(
441
+ f"Alignment is low ({alignment:.3f}). "
442
+ "Use ALIGN (CENTER_X, LEFT, etc.) to snap edges together."
443
+ )
444
+ else:
445
+ parts.append(
446
+ f"Spacing is uneven ({spacing:.3f}). "
447
+ "MOVE elements to equalise vertical/horizontal gaps."
448
+ )
449
+
450
+ return " ".join(parts)
451
+
452
+
453
+ def _is_out_of_bounds(e: Dict) -> bool:
454
+ hw, hh = e["w"] / 2, e["h"] / 2
455
+ l, t, r, b = e["cx"] - hw, e["cy"] - hh, e["cx"] + hw, e["cy"] + hh
456
+ return l < 0 or t < 0 or r > 1 or b > 1
457
+
458
+
459
+ # Environment
460
+ INVALID_ACTION_PENALTY = -0.5
461
+ STEP_PENALTY = -0.05
462
+ REWARD_SCALE = 10.0
463
+ TERMINAL_BONUS_SCALE = 5.0
464
+ TERMINAL_PENALTY = -1.0
465
+ # Align terminal shaping with the easiest grader delta threshold.
466
+ Q_DELTA_THRESHOLD = 0.05
467
+
468
+
469
+ class LayoutEnvironment(Environment):
470
+ """
471
+ An RL environment for layout refinement.
472
+
473
+ The caller is responsible for producing the initial layout (e.g. by
474
+ perturbing a ground-truth sample) and passing it via reset(sample=...).
475
+
476
+ Args:
477
+ max_steps: Maximum actions per episode.
478
+ weights: Optional metric weight overrides for Q.
479
+ stats: Plausibility metric config (e.g. loaded from *_stats.npy);
480
+ immutable for the lifetime of this env instance. If omitted,
481
+ DEFAULT_STATS (derived from DEFAULT_LAYOUT_SAMPLE) is used.
482
+ """
483
+
484
+ # This environment stores episode-specific fields on the instance.
485
+ # Do not advertise shared-instance concurrent session safety.
486
+ SUPPORTS_CONCURRENT_SESSIONS: bool = False
487
+
488
+ def __init__(
489
+ self,
490
+ max_steps: int = 500,
491
+ weights: Optional[Dict[str, float]] = None,
492
+ stats: Optional[Dict[str, Any]] = None,
493
+ ):
494
+ super().__init__()
495
+ self._state = LayoutState(episode_id=str(uuid4()), step_count=0)
496
+
497
+ self._max_steps = max_steps
498
+ self._weights = weights
499
+ self._stats: Dict[str, Any] = (
500
+ DEFAULT_STATS if stats is None else stats
501
+ )
502
+ self._mode: Literal["llm", "vlm"] = "llm"
503
+ self._text_feedback: bool = True
504
+ self._render_image_in_observation: bool = True
505
+
506
+ def _build_observation(
507
+ self,
508
+ step_num: int,
509
+ done: bool,
510
+ reward: float | int,
511
+ metrics: Dict,
512
+ q: float,
513
+ ) -> LayoutObservation:
514
+ image_path: Optional[str] = None
515
+ rendered_b64: Optional[str] = None
516
+ if self._mode == "vlm":
517
+ image_path = self._state.current_image_rel
518
+ if self._mode == "vlm" and self._render_image_in_observation:
519
+ resolved_bg_path: Optional[Path] = None
520
+ bg_img: Image.Image | None = None
521
+
522
+ inline_b64 = getattr(self._state, "_bg_image_base64", None)
523
+ if inline_b64:
524
+ with Image.open(io.BytesIO(base64.b64decode(inline_b64))) as decoded:
525
+ bg_img = decoded.convert("RGBA")
526
+ elif self._state.current_image_rel and self._state.dataset_json_path:
527
+ resolved_bg_path = _resolve_media_path(
528
+ self._state.dataset_json_path, self._state.current_image_rel
529
+ )
530
+ if resolved_bg_path.is_file():
531
+ with Image.open(resolved_bg_path) as loaded:
532
+ bg_img = loaded.convert("RGBA")
533
+
534
+ rendered = _render_layout_on_background(
535
+ resolved_bg_path, self._state.elements, bg_pil=bg_img
536
+ )
537
+ rendered_b64 = _pil_to_png_base64(rendered)
538
+
539
+ prev_q = self._state.previous_quality
540
+ delta_q = q - prev_q
541
+
542
+ feedback: Optional[str] = None
543
+ if self._text_feedback:
544
+ feedback = _generate_text_feedback(delta_q, metrics, self._state.elements)
545
+
546
+ obs = LayoutObservation(
547
+ canvas={"width": 1.0, "height": 1.0},
548
+ elements=_round_elements(self._state.elements),
549
+ metrics=metrics,
550
+ step=step_num,
551
+ max_steps=self._max_steps,
552
+ quality_score=q,
553
+ initial_quality_score=self._state.initial_quality,
554
+ text_feedback=feedback,
555
+ reward=reward,
556
+ done=done,
557
+ image_path=image_path,
558
+ rendered_image_base64=rendered_b64,
559
+ )
560
+ return obs
561
+
562
+ def reset(
563
+ self,
564
+ seed: Optional[int] = None,
565
+ episode_id: Optional[str] = None,
566
+ *,
567
+ sample: Optional[Dict[str, Any]] = None,
568
+ dataset_json_path: Optional[str] = None,
569
+ background_image_base64: Optional[str] = None,
570
+ mode: Optional[Literal["llm", "vlm"]] = None,
571
+ text_feedback: Optional[bool] = None,
572
+ render_image_in_observation: Optional[bool] = None,
573
+ **kwargs: Any,
574
+ ) -> LayoutObservation:
575
+ # Intentionally avoid touching module-global RNG state here.
576
+ # Seeding happens client-side for perturbation reproducibility.
577
+
578
+ if mode is not None:
579
+ self._mode = mode
580
+ if text_feedback is not None:
581
+ self._text_feedback = text_feedback
582
+ if render_image_in_observation is not None:
583
+ self._render_image_in_observation = render_image_in_observation
584
+
585
+ chosen = sample if sample is not None else DEFAULT_LAYOUT_SAMPLE
586
+
587
+ if self._mode == "vlm" and not chosen.get("image_path") and not background_image_base64:
588
+ raise ValueError(
589
+ "VLM mode requires sample['image_path'] or background_image_base64. "
590
+ "Pass a sample from your dataset on reset."
591
+ )
592
+
593
+ current_image_rel = (
594
+ chosen.get("image_path") if self._mode == "vlm" else None
595
+ )
596
+
597
+ elements = _sample_to_elements(chosen)
598
+
599
+ self._state = LayoutState(
600
+ episode_id=episode_id if episode_id is not None else str(uuid4()),
601
+ step_count=0,
602
+ elements=elements,
603
+ previous_quality=0.0,
604
+ initial_quality=0.0,
605
+ current_image_rel=current_image_rel,
606
+ dataset_json_path=dataset_json_path,
607
+ )
608
+
609
+ if background_image_base64:
610
+ self._state._bg_image_base64 = background_image_base64
611
+
612
+ metrics = compute_all_metrics(self._state.elements, self._stats)
613
+ q = quality_score(metrics, self._weights)
614
+ self._state.previous_quality = q
615
+ self._state.initial_quality = q
616
+
617
+ return self._build_observation(0, False, 0.0, metrics, q)
618
+
619
+ def step(self, action: LayoutAction) -> LayoutObservation: # type: ignore[override]
620
+ self._state.step_count += 1
621
+ step_num = self._state.step_count
622
+
623
+ valid = action.is_valid(len(self._state.elements))
624
+
625
+ if not valid:
626
+ metrics = compute_all_metrics(self._state.elements, self._stats)
627
+ q = quality_score(metrics, self._weights)
628
+ done = step_num >= self._max_steps
629
+ reward = INVALID_ACTION_PENALTY + STEP_PENALTY
630
+ if done:
631
+ q_delta = q - self._state.initial_quality
632
+ reward += (
633
+ TERMINAL_BONUS_SCALE if q_delta >= Q_DELTA_THRESHOLD else TERMINAL_PENALTY
634
+ )
635
+ return self._build_observation(
636
+ step_num, done, round(reward, 4), metrics, q
637
+ )
638
+
639
+ is_noop = action.action == "NO_OP"
640
+
641
+ if not is_noop:
642
+ _apply_action(self._state.elements, action)
643
+ else:
644
+ pass
645
+
646
+ metrics = compute_all_metrics(self._state.elements, self._stats)
647
+ q = quality_score(metrics, self._weights)
648
+ delta_q = q - self._state.previous_quality
649
+ self._state.previous_quality = q
650
+
651
+ done = is_noop or step_num >= self._max_steps
652
+
653
+ reward = REWARD_SCALE * delta_q + STEP_PENALTY
654
+ if done:
655
+ q_delta = q - self._state.initial_quality
656
+ reward += (
657
+ TERMINAL_BONUS_SCALE if q_delta >= Q_DELTA_THRESHOLD else TERMINAL_PENALTY
658
+ )
659
+
660
+ return self._build_observation(
661
+ step_num, done, round(reward, 4), metrics, q
662
+ )
663
+
664
+ @property
665
+ def state(self) -> LayoutState:
666
+ return self._state
server/metrics.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Layout quality metrics for the RL reward signal.
3
+
4
+ Every metric operates on a list of element dicts in normalised [0,1] space:
5
+ {"cx": float, "cy": float, "w": float, "h": float, "type": str, "font_size": float}
6
+
7
+ Penalties (lower is better, target 0): overlap, boundary.
8
+ Rewards (higher is better): alignment, spacing, plausibility.
9
+ """
10
+ from itertools import combinations
11
+ from typing import Dict, List, Optional
12
+ import numpy as np
13
+
14
+
15
+ # Helpers
16
+ def _to_ltrb(e: Dict) -> tuple[float, float, float, float]:
17
+ """cxywh to ltrb"""
18
+ hw, hh = e["w"] / 2, e["h"] / 2
19
+ return (e["cx"] - hw, e["cy"] - hh, e["cx"] + hw, e["cy"] + hh)
20
+
21
+
22
+ def _area(e: Dict) -> float:
23
+ return max(e["w"], 0) * max(e["h"], 0)
24
+
25
+
26
+ def _axis_value(e: Dict, axis: str) -> float:
27
+ l, t, r, b = _to_ltrb(e)
28
+ return {"left": l, "right": r, "cx": e["cx"],
29
+ "top": t, "bottom": b, "cy": e["cy"]}[axis]
30
+
31
+
32
+
33
+ # Individual metrics
34
+ def overlap_score(elements: List[Dict]) -> float:
35
+ """Sum of pairwise intersection / min-area. 0 = no overlap."""
36
+ if len(elements) < 2:
37
+ return 0.0
38
+ total = 0.0
39
+ for a, b in combinations(elements, 2):
40
+ la, ta, ra, ba_ = _to_ltrb(a)
41
+ lb, tb, rb, bb_ = _to_ltrb(b)
42
+ ix = max(0.0, min(ra, rb) - max(la, lb))
43
+ iy = max(0.0, min(ba_, bb_) - max(ta, tb))
44
+ inter = ix * iy
45
+ if inter > 0:
46
+ min_area = min(_area(a), _area(b))
47
+ total += inter / (min_area + 1e-8)
48
+ n_pairs = len(elements) * (len(elements) - 1) / 2
49
+ return total / n_pairs
50
+
51
+
52
+ def boundary_score(elements: List[Dict]) -> float:
53
+ """Fraction of area outside [0,1]^2 per element, averaged. 0 = all inside."""
54
+ if not elements:
55
+ return 0.0
56
+ total = 0.0
57
+ for e in elements:
58
+ l, t, r, b = _to_ltrb(e)
59
+ full_area = _area(e)
60
+ if full_area <= 0:
61
+ continue
62
+ cl = max(l, 0.0)
63
+ ct = max(t, 0.0)
64
+ cr = min(r, 1.0)
65
+ cb = min(b, 1.0)
66
+ clipped_w = max(cr - cl, 0.0)
67
+ clipped_h = max(cb - ct, 0.0)
68
+ clipped_area = clipped_w * clipped_h
69
+ total += 1.0 - (clipped_area / (full_area + 1e-8))
70
+ return total / len(elements)
71
+
72
+
73
+ def alignment_score(elements: List[Dict], eps: float = 0.02) -> float:
74
+ """Fraction of element-pairs that share an aligned edge/centre. 1 = perfect."""
75
+ if len(elements) < 2:
76
+ return 1.0
77
+ axes = ["left", "right", "cx", "top", "bottom", "cy"]
78
+ aligned = 0
79
+ total_pairs = 0
80
+ for axis in axes:
81
+ values = [_axis_value(e, axis) for e in elements]
82
+ for i, j in combinations(range(len(values)), 2):
83
+ total_pairs += 1
84
+ if abs(values[i] - values[j]) < eps:
85
+ aligned += 1
86
+ return aligned / total_pairs if total_pairs > 0 else 0.0
87
+
88
+
89
+ def spacing_score(elements: List[Dict]) -> float:
90
+ """Consistency of vertical and horizontal gaps. 1 = perfectly uniform."""
91
+ if len(elements) < 2:
92
+ return 1.0
93
+
94
+ def _gap_consistency(sorted_elems: List[Dict], vertical: bool) -> float:
95
+ gaps = []
96
+ for i in range(len(sorted_elems) - 1):
97
+ a, b = sorted_elems[i], sorted_elems[i + 1]
98
+ if vertical:
99
+ _, _, _, ba = _to_ltrb(a)
100
+ _, tb, _, _ = _to_ltrb(b)
101
+ gaps.append(tb - ba)
102
+ else:
103
+ _, _, ra, _ = _to_ltrb(a)
104
+ lb, _, _, _ = _to_ltrb(b)
105
+ gaps.append(lb - ra)
106
+ if len(gaps) < 2:
107
+ return 1.0
108
+ arr = np.array(gaps)
109
+ mean = np.mean(arr)
110
+ if abs(mean) < 1e-8:
111
+ return 1.0
112
+ cv = np.std(arr) / (abs(mean) + 1e-8)
113
+ return float(np.clip(1.0 - cv, 0.0, 1.0))
114
+
115
+ by_cy = sorted(elements, key=lambda e: e["cy"])
116
+ by_cx = sorted(elements, key=lambda e: e["cx"])
117
+ v_score = _gap_consistency(by_cy, vertical=True)
118
+ h_score = _gap_consistency(by_cx, vertical=False)
119
+ return (v_score + h_score) / 2.0
120
+
121
+
122
+ def plausibility_score(
123
+ elements: List[Dict],
124
+ stats: Optional[Dict] = None,
125
+ ) -> float:
126
+ """Gaussian plausibility per element type. 1 = perfect match to data distribution."""
127
+ if not elements or stats is None:
128
+ return 0.0
129
+ total = 0.0
130
+ counted = 0
131
+ for e in elements:
132
+ etype = e.get("type")
133
+ if etype not in stats:
134
+ continue
135
+ mu = stats[etype]["mu"]
136
+ cov_inv = stats[etype]["cov_inv"]
137
+ x = np.array([e["cx"], e["cy"], e["w"], e["h"], e.get("font_size", 0.0)])
138
+ x = np.clip(x, 0.0, 1.0)
139
+ diff = x - mu
140
+ mahal = float(np.sqrt(np.clip(diff @ cov_inv @ diff, 0.0, None)))
141
+ total += float(np.exp(-0.5 * mahal))
142
+ counted += 1
143
+ return total / counted if counted > 0 else 0.0
144
+
145
+
146
+
147
+ # Composite quality function
148
+ DEFAULT_WEIGHTS = {
149
+ "overlap": 2.0,
150
+ "boundary": 3.0,
151
+ "alignment": 1.0,
152
+ "spacing": 0.5,
153
+ "plausibility": 1.0,
154
+ }
155
+
156
+
157
+ def compute_all_metrics(
158
+ elements: List[Dict],
159
+ stats: Optional[Dict] = None,
160
+ ) -> Dict[str, float]:
161
+ """Return a dict of all individual metric scores."""
162
+ return {
163
+ "overlap": round(overlap_score(elements), 4),
164
+ "boundary": round(boundary_score(elements), 4),
165
+ "alignment": round(alignment_score(elements), 4),
166
+ "spacing": round(spacing_score(elements), 4),
167
+ "plausibility": round(plausibility_score(elements, stats), 4),
168
+ }
169
+
170
+
171
+ def quality_score(
172
+ metrics: Dict[str, float],
173
+ weights: Optional[Dict[str, float]] = None,
174
+ ) -> float:
175
+ """Composite Q(state). Higher is better."""
176
+ w = weights or DEFAULT_WEIGHTS
177
+ q = (
178
+ -w["overlap"] * metrics["overlap"]
179
+ - w["boundary"] * metrics["boundary"]
180
+ + w["alignment"] * metrics["alignment"]
181
+ + w["spacing"] * metrics["spacing"]
182
+ + w["plausibility"] * metrics["plausibility"]
183
+ )
184
+ return round(q, 4)
server/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+ numpy>=1.24.0
5
+ pillow>=10.0.0
uv.lock ADDED
The diff for this file is too large to render. See raw diff