File size: 11,404 Bytes
855e1f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
"""Shared helpers for Ideogram-4 structured JSON captions.

This is the single source of truth for the caption schema so the captioner, the
prompt upsampler, the dataloader, and the model encoder all agree. It encodes the
official Ideogram-4 rules and, crucially, MIGRATES the old caption format we used
before those rules were published into the new one ("digest" old, emit new).

Official schema (summary):
- three top-level keys: high_level_description (optional), style_description
  (optional), compositional_deconstruction (required).
- style_description holds EXACTLY ONE of `photo` (photographs) or `art_style`
  (illustration/painting/3D/graphic design), never both. Key order is strict and
  branch-dependent:
    photo branch:     aesthetics, lighting, photo, medium, color_palette
    non-photo branch: aesthetics, lighting, medium, art_style, color_palette
- medium is one of: photograph, illustration, 3d_render, painting, graphic_design
- color_palette: UPPERCASE #RRGGBB only, up to 16 per image / 5 per element.
- elements, strict key order:
    obj:  type, bbox, desc, color_palette
    text: type, bbox, text, desc, color_palette
  bbox is optional, normalized 0-1000, [y_min, x_min, y_max, x_max], top-left.
- serialize compact: separators=(",", ":"), ensure_ascii=False (no \\uXXXX).

The OLD format we previously emitted differed by: always using `photo` (even for
non-photo media), putting `color_palette` before `desc`/`text`, title-cased medium
with a trailing period ("Illustration."), and lowercase / 3-digit hex. Every
function here accepts the old shape and returns the new one.
"""

import json
import re
from collections import OrderedDict

MAX_IMAGE_PALETTE = 16  # style_description.color_palette
MAX_ELEMENT_PALETTE = 5  # per-element color_palette

# Canonical medium tokens (official set).
MEDIUM_OPTIONS = [
    "photograph",
    "illustration",
    "3d_render",
    "painting",
    "graphic_design",
]

# Map common variants (including our old "Title." style) to the canonical token.
# Anything not listed is treated as a custom medium and preserved verbatim.
_MEDIUM_ALIASES = {
    "photograph": "photograph",
    "photo": "photograph",
    "illustration": "illustration",
    "3d render": "3d_render",
    "3d_render": "3d_render",
    "3d-render": "3d_render",
    "3drender": "3d_render",
    "render": "3d_render",
    "3d": "3d_render",
    "painting": "painting",
    "graphic design": "graphic_design",
    "graphic_design": "graphic_design",
    "graphic-design": "graphic_design",
    "graphic": "graphic_design",
}

_HEX6_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
_HEX3_RE = re.compile(r"^#[0-9a-fA-F]{3}$")


def canon_medium(medium):
    """Canonicalize a medium string to an official token when recognized,
    otherwise return it stripped (custom mediums are allowed, preserved as-is)."""
    if not isinstance(medium, str):
        return medium
    key = medium.strip().rstrip(".").strip().lower()
    if key in _MEDIUM_ALIASES:
        return _MEDIUM_ALIASES[key]
    return medium.strip()


def is_photo_medium(medium):
    """True for the photograph branch (uses `photo`), False for the art_style branch."""
    return canon_medium(medium) == "photograph"


def normalize_hex(color):
    """Return an UPPERCASE #RRGGBB string, expanding #RGB -> #RRGGBB. None if invalid."""
    if not isinstance(color, str):
        return None
    s = color.strip()
    if _HEX6_RE.match(s):
        return "#" + s[1:].upper()
    if _HEX3_RE.match(s):
        return "#" + "".join(ch * 2 for ch in s[1:]).upper()
    return None


def sanitize_palette(palette, max_len):
    """Keep unique, valid, UPPERCASE hex colors in order, capped to max_len.
    Returns the cleaned list, or None if nothing valid remains (drop the key)."""
    if not isinstance(palette, (list, tuple)):
        return None
    seen = set()
    out = []
    for c in palette:
        h = normalize_hex(c)
        if h is None or h in seen:
            continue
        seen.add(h)
        out.append(h)
        if len(out) >= max_len:
            break
    return out or None


def normalize_style(style):
    """Reorder/clean style_description into the correct branch (photo vs art_style)
    with the strict key order, canonical medium, and uppercase palette. Accepts the
    old shape (always `photo`) and migrates it based on the medium."""
    if not isinstance(style, dict):
        return style

    raw_medium = style.get("medium")
    medium = canon_medium(raw_medium) if raw_medium is not None else None
    has_photo = bool(style.get("photo"))
    has_art = bool(style.get("art_style"))

    # Decide the branch. A recognized medium is authoritative; otherwise infer from
    # whichever render key the (old) data already had, defaulting to photo.
    if medium in MEDIUM_OPTIONS:
        photo_branch = medium == "photograph"
    elif has_art and not has_photo:
        photo_branch = False
    else:
        photo_branch = True

    photo_val = style.get("photo") if has_photo else None
    art_val = style.get("art_style") if has_art else None

    out = OrderedDict()
    if "aesthetics" in style:
        out["aesthetics"] = style["aesthetics"]
    if "lighting" in style:
        out["lighting"] = style["lighting"]

    if photo_branch:
        # aesthetics, lighting, photo, medium, color_palette
        val = photo_val if photo_val is not None else art_val
        if val is not None:
            out["photo"] = val
        if medium is not None:
            out["medium"] = medium
    else:
        # aesthetics, lighting, medium, art_style, color_palette
        if medium is not None:
            out["medium"] = medium
        val = art_val if art_val is not None else photo_val
        if val is not None:
            out["art_style"] = val

    pal = sanitize_palette(style.get("color_palette"), MAX_IMAGE_PALETTE)
    if pal is not None:
        out["color_palette"] = pal

    # Preserve any unexpected extra keys at the end rather than dropping them.
    for k, v in style.items():
        if k not in (
            "aesthetics",
            "lighting",
            "photo",
            "art_style",
            "medium",
            "color_palette",
        ):
            out[k] = v
    return out


def normalize_element(el):
    """Reorder an element's keys to the strict schema order and uppercase its
    palette. obj: type, bbox, desc, color_palette. text: type, bbox, text, desc,
    color_palette. bbox is kept verbatim (already [y1,x1,y2,x2] in stored form)."""
    if not isinstance(el, dict):
        return el
    etype = el.get("type", "obj")
    out = OrderedDict()
    out["type"] = etype
    if el.get("bbox") is not None:
        out["bbox"] = el["bbox"]
    if etype == "text":
        if "text" in el:
            out["text"] = el["text"]
        if "desc" in el:
            out["desc"] = el["desc"]
    else:
        if "desc" in el:
            out["desc"] = el["desc"]
    pal = sanitize_palette(el.get("color_palette"), MAX_ELEMENT_PALETTE)
    if pal is not None:
        out["color_palette"] = pal
    # Preserve any extras (e.g. future keys) at the end.
    for k, v in el.items():
        if k not in out and k != "color_palette":
            out[k] = v
    return out


def normalize_caption_dict(data):
    """Normalize a parsed caption dict in place-ish: drop input-only aspect_ratio,
    enforce top-level key order, normalize style (photo/art_style branch) and every
    element. Returns a new OrderedDict. Accepts old-format captions and emits new."""
    if not isinstance(data, dict):
        return data
    data.pop("aspect_ratio", None)  # input-only context, never part of output

    out = OrderedDict()
    if "high_level_description" in data:
        out["high_level_description"] = data["high_level_description"]
    if "style_description" in data:
        out["style_description"] = normalize_style(data["style_description"])

    decon = data.get("compositional_deconstruction")
    if isinstance(decon, dict):
        nd = OrderedDict()
        if "background" in decon:
            nd["background"] = decon["background"]
        els = decon.get("elements")
        if isinstance(els, list):
            nd["elements"] = [normalize_element(e) for e in els]
        for k, v in decon.items():
            if k not in ("background", "elements"):
                nd[k] = v
        out["compositional_deconstruction"] = nd
    elif decon is not None:
        out["compositional_deconstruction"] = decon

    for k, v in data.items():
        if k not in (
            "high_level_description",
            "style_description",
            "compositional_deconstruction",
            "aspect_ratio",
        ):
            out[k] = v
    return out


# --- bbox coordinate adaptation that does NOT require valid JSON -------------
# Captioners emit boxes as [x1,y1,x2,y2] but we store [y1,x1,y2,x2]. The
# structured normalizer can only swap per-element when the JSON parses; if the
# model returns malformed JSON, that path is skipped and the boxes stay in the
# wrong order. This regex rewrites every `"bbox":[...]` array in the raw text
# directly, so the swap still happens on un-parseable output.
_BBOX_TEXT_RE = re.compile(
    r'"bbox"\s*:\s*\[\s*'
    r"(-?\d+(?:\.\d+)?)\s*,\s*"
    r"(-?\d+(?:\.\d+)?)\s*,\s*"
    r"(-?\d+(?:\.\d+)?)\s*,\s*"
    r"(-?\d+(?:\.\d+)?)\s*\]"
)


def _clamp_1000(v):
    return max(0, min(1000, round(float(v))))


def swap_bbox_xy_in_text(text):
    """Swap every [x1,y1,x2,y2] bbox to the stored [y1,x1,y2,x2] order directly in
    the raw model output -- clamping each value to 0-1000 and ordering each axis
    pair. It never parses the surrounding JSON, so it works even when the output is
    malformed. Only `"bbox":[n,n,n,n]` arrays are touched; everything else is left
    byte-for-byte. Returns the rewritten text."""
    if not isinstance(text, str):
        return text

    def _repl(m):
        x1, y1, x2, y2 = m.groups()
        cx1, cx2 = sorted((_clamp_1000(x1), _clamp_1000(x2)))
        cy1, cy2 = sorted((_clamp_1000(y1), _clamp_1000(y2)))
        return f'"bbox":[{cy1},{cx1},{cy2},{cx2}]'

    return _BBOX_TEXT_RE.sub(_repl, text)


def is_ideogram_caption_str(text):
    """True if text parses as a JSON object with a compositional_deconstruction block."""
    t = (text or "").strip()
    if not t.startswith("{"):
        return False
    try:
        d = json.loads(t)
    except Exception:
        return False
    return isinstance(d, dict) and isinstance(
        d.get("compositional_deconstruction"), dict
    )


def to_model_string(data):
    """Serialize a caption dict to the compact, model-ready string the renderer wants."""
    return json.dumps(data, ensure_ascii=False, separators=(",", ":"))


def digest_caption_string(text):
    """Parse, normalize (migrating old format), and return the compact model-ready
    string. Returns the input unchanged if it is not an Ideogram structured caption
    (plain-text captions pass straight through)."""
    t = (text or "").strip()
    if not t.startswith("{"):
        return text
    try:
        data = json.loads(t, object_pairs_hook=OrderedDict)
    except Exception:
        return text
    if not (
        isinstance(data, dict)
        and isinstance(data.get("compositional_deconstruction"), dict)
    ):
        return text
    return to_model_string(normalize_caption_dict(data))