Upload folder using huggingface_hub
Browse files- Dockerfile +91 -0
- README.md +226 -7
- __init__.py +12 -0
- client.py +63 -0
- grader.py +57 -0
- models.py +126 -0
- openenv.yaml +7 -0
- openenv_layoutenv.egg-info/PKG-INFO +14 -0
- openenv_layoutenv.egg-info/SOURCES.txt +15 -0
- openenv_layoutenv.egg-info/dependency_links.txt +1 -0
- openenv_layoutenv.egg-info/entry_points.txt +2 -0
- openenv_layoutenv.egg-info/requires.txt +10 -0
- openenv_layoutenv.egg-info/top_level.txt +1 -0
- pyproject.toml +31 -0
- server/__init__.py +3 -0
- server/app.py +54 -0
- server/layout_environment.py +666 -0
- server/metrics.py +184 -0
- server/requirements.txt +5 -0
- uv.lock +0 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|