DesignGym / core /crello.py
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
10.4 kB
"""Crello row -> ground-truth document + the assets a model gets to work with.
What survives the conversion, and why:
- Image, SVG and SVG-mask layers become `image` elements over pre-rendered
assets. Their colours are baked into the PNG, exactly as a stock asset would
arrive in a real editor.
- A `ColoredBackground` that is one flat colour becomes the document
background; a textured or gradient one stays an asset.
- Text layers become real `text` elements. Crello's pre-rendered text PNGs are
withheld from the assets, otherwise the cheapest policy is to paste pictures
of the words instead of typing them.
- Per-character colour, bold and italic become runs. Crello's explicit
`text_line` indices become `\\n` breaks, and the raw newlines it says to
ignore are dropped.
- Identical assets are merged, so a design that uses one divider twice asks for
the same asset to be placed twice rather than rewarding a coin-flip over two
indistinguishable ids.
"""
from __future__ import annotations
import hashlib
import io
import random
from collections import Counter
from dataclasses import dataclass, field
import numpy as np
from PIL import Image
from .colors import parse_color
from .doc import DesignDoc, Element, Run
from .fonts import FontRegistry
from .text import fit_box
_QUANTISED = ("x", "y", "width", "height", "angle", "opacity", "font_size", "line_height",
"letter_spacing")
ASSET_KIND = {
"ImageElement": "photo",
"SvgMaskElement": "photo",
"SvgElement": "graphic",
"ColoredBackground": "background",
}
@dataclass
class ConvertedDesign:
crello_id: str
doc: DesignDoc
assets: dict[str, Image.Image]
asset_meta: dict[str, dict]
meta: dict
preview: Image.Image | None
warnings: list[str] = field(default_factory=list)
def _label(features, key: str, value):
"""ClassLabel int (or list of ints) -> name, tolerant of already-decoded rows."""
feat = features[key]
inner = getattr(feat, "feature", feat)
names = getattr(inner, "names", None)
if names is None:
return value
if isinstance(value, list):
return [names[v] if isinstance(v, int) else v for v in value]
return names[value] if isinstance(value, int) else value
def _to_pil(value) -> Image.Image | None:
if value is None:
return None
if isinstance(value, Image.Image):
return value
if isinstance(value, dict) and value.get("bytes"):
return Image.open(io.BytesIO(value["bytes"]))
return None
def _flat_color(img: Image.Image) -> str | None:
arr = np.asarray(img.convert("RGBA").resize((32, 32)), dtype=np.float32)
opaque = arr[:, :, 3] > 250
if opaque.mean() < 0.98:
return None
rgb = arr[:, :, :3][opaque]
if rgb.std(axis=0).max() > 2.5:
return None
r, g, b = (int(round(v)) for v in rgb.mean(axis=0))
return f"#{r:02x}{g:02x}{b:02x}"
def _text_runs(text: str, colors, bolds, italics, lines) -> tuple[str, list[dict]]:
"""Rebuild text with explicit line breaks and per-character style runs."""
def at(seq, i, default):
try:
return seq[i]
except (IndexError, TypeError):
return default
out_chars: list[tuple[str, str, bool, bool]] = []
prev_line = None
for i, ch in enumerate(text):
if ch == "\n":
continue
line = at(lines, i, prev_line or 0)
style = (
parse_color(at(colors, i, "#000000") or "#000000"),
bool(at(bolds, i, False)),
bool(at(italics, i, False)),
)
if prev_line is not None and line != prev_line:
out_chars.append(("\n", *out_chars[-1][1:]) if out_chars else ("\n", *style))
out_chars.append((ch, *style))
prev_line = line
runs: list[dict] = []
for ch, color, bold, italic in out_chars:
if runs and (runs[-1]["color"], runs[-1]["bold"], runs[-1]["italic"]) == (
color,
bold,
italic,
):
runs[-1]["text"] += ch
else:
runs.append({"text": ch, "color": color, "bold": bold, "italic": italic})
return "".join(r["text"] for r in runs), runs
def convert_row(row: dict, features, fonts: FontRegistry) -> ConvertedDesign:
crello_id = str(row["id"])
width, height = int(row["canvas_width"]), int(row["canvas_height"])
types = _label(features, "type", list(row["type"]))
font_names = _label(features, "font", list(row["font"]))
aligns = _label(features, "text_align", list(row["text_align"]))
doc = DesignDoc(width=width, height=height, background="#ffffff")
warnings: list[str] = []
raw_assets: list[tuple[str, Image.Image, str]] = [] # (element slot, image, kind)
pending: list[tuple[int, dict]] = [] # element kwargs, with asset slot names
bg_set = False
for i in range(int(row["length"])):
etype = types[i]
x, y = float(row["left"][i]), float(row["top"][i])
w, h = float(row["width"][i]), float(row["height"][i])
angle = float(row["angle"][i] or 0.0)
opacity = float(row["opacity"][i]) if row["opacity"][i] is not None else 1.0
if w < 1 or h < 1 or opacity <= 0.01:
warnings.append(f"dropped degenerate element {i} ({etype})")
continue
common = dict(x=x, y=y, width=w, height=h, angle=angle, opacity=min(1.0, opacity))
if etype == "TextElement":
text = str(row["text"][i] or "")
if not text.strip():
continue
family_raw = font_names[i] or ""
if not fonts.knows(family_raw):
warnings.append(f"font fallback: {family_raw!r}")
family = fonts.resolve(family_raw)
joined, runs = _text_runs(
text,
row["text_color"][i],
row["font_bold"][i],
row["font_italic"][i],
row["text_line"][i],
)
counts = Counter((r["color"], r["bold"], r["italic"]) for r in runs for _ in r["text"])
(color, bold, italic), _ = counts.most_common(1)[0]
uniform = len({(r["color"], r["bold"], r["italic"]) for r in runs}) == 1
el_runs = None
if not uniform:
el_runs = [
Run(
text=r["text"],
color=None if r["color"] == color else r["color"],
bold=None if r["bold"] == bold else r["bold"],
italic=None if r["italic"] == italic else r["italic"],
)
for r in runs
]
align = aligns[i] if aligns[i] in ("left", "center", "right") else "left"
kwargs = dict(
kind="text",
text=joined,
font_family=family,
font_size=float(row["font_size"][i] or 24.0),
color=color,
bold=bold,
italic=italic,
align=align,
line_height=float(row["line_height"][i] or 1.0),
letter_spacing=float(row["letter_spacing"][i] or 0.0),
uppercase=bool(row["capitalize"][i]),
runs=el_runs,
**common,
)
pending.append((i, kwargs))
continue
img = _to_pil(row["image"][i])
if img is None:
warnings.append(f"element {i} ({etype}) has no image")
continue
if etype == "ColoredBackground" and not bg_set:
flat = _flat_color(img)
colors = row["color"][i] or []
if flat is not None:
doc.background = parse_color(colors[0]) if colors else flat
bg_set = True
continue
slot = f"s{i}"
raw_assets.append((slot, img.convert("RGBA"), ASSET_KIND.get(etype, "graphic")))
pending.append((i, dict(kind="image", asset_id=slot, **common)))
# Merge identical assets, then hand out ids in a shuffled order so the id
# sequence does not leak the stacking order of the ground truth.
digest_to_slot: dict[str, str] = {}
slot_alias: dict[str, str] = {}
unique: list[tuple[str, Image.Image, str]] = []
for slot, img, kind in raw_assets:
digest = hashlib.sha1(img.tobytes() + str(img.size).encode()).hexdigest()
if digest in digest_to_slot:
slot_alias[slot] = digest_to_slot[digest]
continue
digest_to_slot[digest] = slot
slot_alias[slot] = slot
unique.append((slot, img, kind))
order = list(range(len(unique)))
random.Random(int(hashlib.sha1(crello_id.encode()).hexdigest()[:8], 16)).shuffle(order)
slot_to_id = {unique[k][0]: f"a{n + 1}" for n, k in enumerate(order)}
assets: dict[str, Image.Image] = {}
asset_meta: dict[str, dict] = {}
for slot, img, kind in unique:
aid = slot_to_id[slot]
assets[aid] = img
asset_meta[aid] = {
"id": aid,
"kind": kind,
"aspect": round(img.width / img.height, 4),
}
for _, kwargs in pending:
if kwargs["kind"] == "image":
kwargs["asset_id"] = slot_to_id[slot_alias[kwargs["asset_id"]]]
# Crello stores float32. Quantise to what a tool call can express, so
# the expert trajectory reproduces the ground truth exactly.
for key in _QUANTISED:
if key in kwargs and isinstance(kwargs[key], float):
kwargs[key] = round(kwargs[key], 2)
el = Element(id=doc.new_id(), **kwargs)
if el.kind == "text":
_, el.height = fit_box(el, fonts)
doc.elements.append(el)
meta = {
"crello_id": crello_id,
"title": str(row.get("title") or ""),
"format": _label(features, "format", row["format"]),
"category": _label(features, "category", row["category"]),
"keywords": list(row.get("keywords") or []),
"industries": _label(features, "industries", list(row.get("industries") or [])),
"canvas": {"width": width, "height": height},
}
return ConvertedDesign(
crello_id=crello_id,
doc=doc,
assets=assets,
asset_meta=asset_meta,
meta=meta,
preview=_to_pil(row.get("preview")),
warnings=warnings,
)