visheratin-bria commited on
Commit
0a38c5b
·
verified ·
1 Parent(s): c82eee4

Add the harness code: Transformers, vLLM and server clients, the schema and the visualizer

Browse files
caption_visualizer.py ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained HTML visualization of structured scene descriptions.
2
+
3
+ `CaptionVisualizer` renders an `SceneDescription` or a `SceneObjectList` beside its image:
4
+ the description fields as collapsible cards, and the geometry (bounding boxes, masks,
5
+ keypoints, text lines) as SVG overlays on the image. Overlays are toggled per object with
6
+ CSS only, so the output needs no JavaScript and no network access, and it works inside a
7
+ `gradio.HTML` component.
8
+
9
+ visualizer = CaptionVisualizer()
10
+ visualizer.save("description.html", image, description)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import html
17
+ import io
18
+ import json
19
+ import uuid
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from data_model import (
24
+ BaseObject,
25
+ BodyKeypoint,
26
+ BoundingBox,
27
+ Color,
28
+ GeneralDescription,
29
+ ObjectMask,
30
+ Person,
31
+ SceneDescription,
32
+ SceneObjectList,
33
+ Text,
34
+ _parse_color_token,
35
+ _parse_coord_token,
36
+ )
37
+ from PIL import Image
38
+
39
+ #: Overlay colors for objects without a color of their own.
40
+ PALETTE = ("#FF6B6B", "#F7B801", "#6BCB77", "#4D96FF", "#845EC2", "#FF9671", "#00C9A7", "#F9C74F")
41
+ FACE_COLOR = "#FF69B4"
42
+
43
+ #: Fields shown by dedicated renderers, or as nested objects, instead of as plain values.
44
+ _SPECIAL_FIELDS = frozenset(
45
+ {
46
+ "name",
47
+ "kind",
48
+ "description",
49
+ "details",
50
+ "artifacts",
51
+ "relationships",
52
+ "colors",
53
+ "skin_color",
54
+ "bounding_boxes",
55
+ "face_bbox",
56
+ "keypoints",
57
+ "mask",
58
+ "lines",
59
+ "children",
60
+ "clothing",
61
+ "footwear",
62
+ "accessories",
63
+ "eyes",
64
+ "hair",
65
+ "facial_hair",
66
+ }
67
+ )
68
+ _NESTED_LISTS = (
69
+ ("children", "Children"),
70
+ ("clothing", "Clothing"),
71
+ ("footwear", "Footwear"),
72
+ ("accessories", "Accessories"),
73
+ )
74
+ _NESTED_SINGLE = (("eyes", "Eyes"), ("hair", "Hair"), ("facial_hair", "Facial hair"))
75
+
76
+ _WEBP_QUALITY = 90
77
+
78
+ #: Scoped with `#ROOT`, which each render replaces with its own id, so several renders can
79
+ #: share one page.
80
+ _FRAGMENT_CSS = """
81
+ #ROOT { --cv-bg: #ffffff; --cv-card: #f6f7f9; --cv-card-head: #eceff4; --cv-text: #15181d; --cv-muted: #5b6270;
82
+ --cv-border: rgba(120, 130, 150, 0.35); --cv-chip: #e6e9ef; --cv-accent: #3b5bdb;
83
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
84
+ font-size: 14px; line-height: 1.5; color: var(--cv-text); }
85
+ @media (prefers-color-scheme: dark) {
86
+ :root:not([data-theme="light"]) #ROOT { --cv-bg: #16181d; --cv-card: #1e2127; --cv-card-head: #262a32;
87
+ --cv-text: #e7e9ee; --cv-muted: #9aa1ae; --cv-border: rgba(160, 170, 190, 0.25); --cv-chip: #2b3039;
88
+ --cv-accent: #8ea4ff; }
89
+ }
90
+ /* An explicit theme on the page (`data-theme`, or gradio's `.dark`) wins over the system one. */
91
+ [data-theme="dark"] #ROOT, .dark #ROOT { --cv-bg: #16181d; --cv-card: #1e2127; --cv-card-head: #262a32; --cv-text: #e7e9ee; --cv-muted: #9aa1ae;
92
+ --cv-border: rgba(160, 170, 190, 0.25); --cv-chip: #2b3039; --cv-accent: #8ea4ff; }
93
+ #ROOT, #ROOT * { box-sizing: border-box; }
94
+ #ROOT .cv-form { margin: 0; background: var(--cv-bg); border-radius: 12px; padding: 1rem; }
95
+ #ROOT .cv-toggle { display: none; }
96
+ #ROOT .cv-layout { display: flex; flex-direction: column; gap: 1.25rem; }
97
+ #ROOT .cv-image-panel { width: 100%; min-width: 0; }
98
+ #ROOT .cv-image-box { position: relative; border-radius: 10px; overflow: hidden; background: #0d0f13; }
99
+ /* The pixel grid the coordinates refer to, without EXIF rotation. */
100
+ #ROOT .cv-image { display: block; width: 100%; height: auto; image-orientation: none; }
101
+ #ROOT .cv-overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
102
+ #ROOT .cv-overlay g.cv-ov { display: none; }
103
+ /* Keypoint names only for one open person: with all overlays shown they cover the image. */
104
+ #ROOT .cv-overlay g.cv-name-label { display: none; }
105
+ #ROOT .cv-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
106
+ #ROOT .cv-button { font: inherit; font-size: 0.82rem; color: var(--cv-text); background: var(--cv-chip);
107
+ border: 1px solid var(--cv-border); border-radius: 999px; padding: 0.25rem 0.8rem; cursor: pointer;
108
+ user-select: none; }
109
+ #ROOT .cv-button:hover { border-color: var(--cv-accent); }
110
+ #ROOT .cv-button .cv-on { display: none; }
111
+ #ROOT .cv-tree { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
112
+ #ROOT .cv-section h3 { margin: 0 0 0.5rem; font-size: 1rem; }
113
+ #ROOT .cv-general { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 0.4rem; }
114
+ #ROOT .cv-object { border: 1px solid var(--cv-border); border-radius: 8px; background: var(--cv-card);
115
+ margin-bottom: 0.6rem; overflow: hidden; }
116
+ #ROOT .cv-object:last-child { margin-bottom: 0; }
117
+ #ROOT .cv-summary { display: flex; align-items: center; gap: 0.55rem; padding: 0.55rem 0.8rem; cursor: pointer;
118
+ background: var(--cv-card-head); }
119
+ #ROOT .cv-summary::-webkit-details-marker { display: none; }
120
+ #ROOT .cv-dot { flex: none; width: 11px; height: 11px; border-radius: 50%; box-shadow: 0 0 0 2px var(--cv-bg); }
121
+ #ROOT .cv-kind { flex: none; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
122
+ color: var(--cv-muted); }
123
+ #ROOT .cv-name { font-weight: 600; overflow-wrap: anywhere; }
124
+ #ROOT .cv-body { padding: 0.55rem 0.8rem 0.7rem; display: flex; flex-direction: column; gap: 0.45rem; }
125
+ #ROOT .cv-field { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.2rem 0.55rem; }
126
+ #ROOT .cv-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600;
127
+ color: var(--cv-muted); }
128
+ #ROOT .cv-value { flex: 1 1 12rem; min-width: 0; overflow-wrap: anywhere; }
129
+ #ROOT .cv-list { margin: 0; padding-left: 1.1rem; }
130
+ #ROOT .cv-list--plain { list-style: none; padding-left: 0; }
131
+ #ROOT .cv-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.3rem; }
132
+ #ROOT .cv-chip { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.15rem 0.5rem; border-radius: 999px;
133
+ background: var(--cv-chip); font-size: 0.8rem; }
134
+ #ROOT .cv-swatch { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--cv-border); }
135
+ #ROOT .cv-sub > h4 { margin: 0.2rem 0 0.35rem; font-size: 0.85rem; }
136
+ #ROOT .cv-empty { margin: 0; color: var(--cv-muted); font-style: italic; }
137
+ @media (min-width: 980px) {
138
+ #ROOT .cv-layout { flex-direction: row; align-items: flex-start; }
139
+ #ROOT .cv-image-panel { flex: 1 1 55%; position: sticky; top: 0.5rem; }
140
+ /* A tall image fits the screen height, so it stays in view beside a long description. */
141
+ #ROOT .cv-image-box { width: min(100%, calc((100vh - 5rem) * var(--cv-ar, 1))); margin: 0 auto; }
142
+ #ROOT .cv-tree { flex: 1 1 45%; }
143
+ }
144
+ """
145
+
146
+ _PAGE_CSS = """
147
+ :root { color-scheme: light dark; }
148
+ body { margin: 0; padding: 1.25rem 1rem 2.5rem; background: #eef0f4; color: #15181d;
149
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
150
+ @media (prefers-color-scheme: dark) { body { background: #0d0f13; color: #e7e9ee; } }
151
+ .cv-page { max-width: 1400px; margin: 0 auto; }
152
+ .cv-page h1 { margin: 0 0 1rem; font-size: 1.2rem; }
153
+ """
154
+
155
+
156
+ class CaptionVisualizer:
157
+ """Renders structured scene descriptions as HTML with image overlays."""
158
+
159
+ def render(
160
+ self,
161
+ image: Image.Image,
162
+ caption: SceneDescription | SceneObjectList | dict | str,
163
+ title: str | None = None,
164
+ ) -> str:
165
+ """A standalone HTML page for `caption` over `image`."""
166
+ heading = f"<h1>{html.escape(title)}</h1>" if title else ""
167
+ page_title = html.escape(title or "Structured scene description")
168
+ return (
169
+ "<!DOCTYPE html>\n"
170
+ '<html lang="en">\n<head>\n<meta charset="utf-8"/>\n'
171
+ '<meta name="viewport" content="width=device-width, initial-scale=1"/>\n'
172
+ f"<title>{page_title}</title>\n<style>{_PAGE_CSS}</style>\n</head>\n<body>\n"
173
+ f'<div class="cv-page">{heading}{self.render_fragment(image, caption)}</div>\n'
174
+ "</body>\n</html>\n"
175
+ )
176
+
177
+ def render_fragment(
178
+ self,
179
+ image: Image.Image,
180
+ caption: SceneDescription | SceneObjectList | dict | str,
181
+ title: str | None = None,
182
+ ) -> str:
183
+ """The visualization as an HTML fragment with its own scoped styles, for embedding."""
184
+ caption = self._parse(caption)
185
+ return _Render(image, caption, title).html()
186
+
187
+ def save(
188
+ self,
189
+ path: str | Path,
190
+ image: Image.Image,
191
+ caption: SceneDescription | SceneObjectList | dict | str,
192
+ title: str | None = None,
193
+ ) -> Path:
194
+ """Write the standalone page to `path` and return the path."""
195
+ path = Path(path)
196
+ path.write_text(self.render(image, caption, title), encoding="utf-8")
197
+ return path
198
+
199
+ @staticmethod
200
+ def _parse(caption: Any) -> SceneDescription | SceneObjectList:
201
+ if isinstance(caption, (SceneDescription, SceneObjectList)):
202
+ return caption
203
+ data = json.loads(caption) if isinstance(caption, (str, bytes)) else caption
204
+ if not isinstance(data, dict):
205
+ raise TypeError(f"unsupported caption type {type(caption).__name__}")
206
+ if "general" in data:
207
+ return SceneDescription.model_validate(data)
208
+ return SceneObjectList.model_validate(data)
209
+
210
+
211
+ def _coord(value: Any) -> float:
212
+ return _parse_coord_token(value)
213
+
214
+
215
+ def _rgb(color: Color) -> tuple[int, int, int]:
216
+ return _parse_color_token(color.red), _parse_color_token(color.green), _parse_color_token(color.blue)
217
+
218
+
219
+ def _box(bbox: BoundingBox) -> tuple[float, float, float, float]:
220
+ """(left, top, right, bottom) in percent, corners ordered."""
221
+ x0, y0 = _coord(bbox.top_left.x), _coord(bbox.top_left.y)
222
+ x1, y1 = _coord(bbox.bottom_right.x), _coord(bbox.bottom_right.y)
223
+ return min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)
224
+
225
+
226
+ def _number(value: float) -> str:
227
+ return f"{value:.1f}"
228
+
229
+
230
+ def _format_value(value: Any) -> str:
231
+ if isinstance(value, list):
232
+ if not value:
233
+ return ""
234
+ return '<ul class="cv-list">' + "".join(f"<li>{_format_value(item)}</li>" for item in value) + "</ul>"
235
+ if isinstance(value, dict):
236
+ items = "".join(
237
+ f'<li><span class="cv-label">{html.escape(str(key))}</span> {_format_value(item)}</li>'
238
+ for key, item in value.items()
239
+ )
240
+ return f'<ul class="cv-list cv-list--plain">{items}</ul>'
241
+ if isinstance(value, float):
242
+ return f"{value:.2f}".rstrip("0").rstrip(".")
243
+ return html.escape(str(value))
244
+
245
+
246
+ def _image_data_url(image: Image.Image) -> str:
247
+ buffer = io.BytesIO()
248
+ image.convert("RGB").save(buffer, format="WEBP", quality=_WEBP_QUALITY)
249
+ return "data:image/webp;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
250
+
251
+
252
+ class _Render:
253
+ """One render: collects the overlay groups while it walks the caption tree."""
254
+
255
+ def __init__(self, image: Image.Image, caption: SceneDescription | SceneObjectList, title: str | None):
256
+ self.image = image
257
+ self.caption = caption
258
+ self.title = title
259
+ self.root = f"cv-{uuid.uuid4().hex[:12]}"
260
+ self.width, self.height = image.size
261
+ self.unit = min(self.width, self.height) / 100.0
262
+ self.toggles: list[tuple[str, str, str]] = [] # (toggle id, overlay id, color)
263
+ self.overlays: list[str] = []
264
+ self.counter = 0
265
+
266
+ # -- page ------------------------------------------------------------------
267
+
268
+ def html(self) -> str:
269
+ if isinstance(self.caption, SceneDescription):
270
+ general = self._general(self.caption.general)
271
+ objects = self._section("Objects", self.caption.objects or [])
272
+ else:
273
+ general = ""
274
+ objects = self._section("Objects", self.caption.objects or [])
275
+ tree = general + objects or '<p class="cv-empty">The scene description has no content.</p>'
276
+
277
+ all_toggle = f"{self.root}-all"
278
+ inputs = f'<input type="checkbox" class="cv-toggle" id="{all_toggle}"/>' if self.toggles else ""
279
+ inputs += "".join(f'<input type="checkbox" class="cv-toggle" id="{toggle}"/>' for toggle, _, _ in self.toggles)
280
+ controls = ""
281
+ if self.toggles:
282
+ controls = (
283
+ '<div class="cv-controls">'
284
+ f'<label class="cv-button" for="{all_toggle}">'
285
+ '<span class="cv-off">Show all overlays</span><span class="cv-on">Hide all overlays</span></label>'
286
+ '<button type="reset" class="cv-button">Clear</button>'
287
+ "</div>"
288
+ )
289
+ overlay = (
290
+ f'<svg class="cv-overlay" viewBox="0 0 {self.width} {self.height}" preserveAspectRatio="none" '
291
+ f'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{"".join(self.overlays)}</svg>'
292
+ )
293
+ heading = f'<h3 class="cv-title">{html.escape(self.title)}</h3>' if self.title else ""
294
+ return (
295
+ f'<section id="{self.root}" class="cv-root">'
296
+ f"<style>{_FRAGMENT_CSS.replace('#ROOT', '#' + self.root)}{self._toggle_css(all_toggle)}</style>"
297
+ f'<form class="cv-form">{inputs}{heading}'
298
+ '<div class="cv-layout">'
299
+ f'<div class="cv-image-panel"><div class="cv-image-box" style="--cv-ar: {self.width / self.height:.4f}">'
300
+ f'<img class="cv-image" src="{_image_data_url(self.image)}" alt="described image"/>{overlay}'
301
+ f"</div>{controls}</div>"
302
+ f'<div class="cv-tree">{tree}</div>'
303
+ "</div></form></section>"
304
+ )
305
+
306
+ def _toggle_css(self, all_toggle: str) -> str:
307
+ root = f"#{self.root}"
308
+ rules = [
309
+ f"{root} input#{all_toggle}:checked ~ .cv-layout .cv-overlay g.cv-ov {{ display: inline; }}",
310
+ f'{root} input#{all_toggle}:checked ~ .cv-layout label[for="{all_toggle}"] .cv-off {{ display: none; }}',
311
+ f'{root} input#{all_toggle}:checked ~ .cv-layout label[for="{all_toggle}"] .cv-on {{ display: inline; }}',
312
+ ]
313
+ for toggle, overlay_id, color in self.toggles:
314
+ label = f'{root} input#{toggle}:checked ~ .cv-layout label[for="{toggle}"]'
315
+ group = f'{root} input#{toggle}:checked ~ .cv-layout .cv-overlay g[data-id="{overlay_id}"]'
316
+ rules += [
317
+ f"{group} {{ display: inline; }}",
318
+ f"{group} .cv-name-label {{ display: inline; }}",
319
+ f"{label} {{ border-color: {color}; box-shadow: inset 0 0 0 1px {color}; }}",
320
+ f"{label} .cv-off {{ display: none; }}",
321
+ f"{label} .cv-on {{ display: inline; }}",
322
+ ]
323
+ return "\n".join(rules)
324
+
325
+ # -- sections --------------------------------------------------------------
326
+
327
+ def _general(self, general: GeneralDescription | None) -> str:
328
+ if general is None:
329
+ return ""
330
+ data = general.model_dump(exclude_none=True)
331
+ items = []
332
+ if general.color_palette:
333
+ data.pop("color_palette", None)
334
+ items.append(self._field("Color palette", self._chips(general.color_palette), tag="li"))
335
+ for key, value in data.items():
336
+ items.append(self._field(key.replace("_", " ").capitalize(), _format_value(value), tag="li"))
337
+ if not items:
338
+ return ""
339
+ return f'<section class="cv-section"><h3>General</h3><ul class="cv-general">{"".join(items)}</ul></section>'
340
+
341
+ def _section(self, title: str, objects: list[BaseObject]) -> str:
342
+ if not objects:
343
+ return ""
344
+ body = "".join(self._object(obj, depth=0) for obj in objects)
345
+ return f'<section class="cv-section"><h3>{html.escape(title)} ({len(objects)})</h3>{body}</section>'
346
+
347
+ # -- objects ---------------------------------------------------------------
348
+
349
+ def _object(self, obj: BaseObject, depth: int) -> str:
350
+ self.counter += 1
351
+ obj_id = f"{self.root}-o{self.counter}"
352
+ color = self._object_color(obj)
353
+ line_text = getattr(obj, "text", None)
354
+ if line_text is None and getattr(obj, "lines", None):
355
+ # A text object has no name; its lines say more than its description.
356
+ line_text = " / ".join(line.text for line in obj.lines if line.text)
357
+ if len(line_text) > 64:
358
+ line_text = line_text[:61] + "..."
359
+ name = obj.name or ""
360
+ if not name and line_text:
361
+ name = f"“{line_text}”"
362
+ if not name and obj.description:
363
+ snippet = obj.description.strip()
364
+ name = snippet if len(snippet) <= 64 else snippet[:61] + "..."
365
+ name = name or obj.kind.replace("_", " ")
366
+
367
+ fields = []
368
+ if obj.description and name != obj.description.strip():
369
+ fields.append(self._field("Description", html.escape(obj.description)))
370
+ if line_text is not None and obj.name:
371
+ fields.append(self._field("Text", html.escape(line_text)))
372
+ for label, key in (("Details", "details"), ("Artifacts", "artifacts")):
373
+ if getattr(obj, key, None):
374
+ fields.append(self._field(label, _format_value(getattr(obj, key))))
375
+ if obj.relationships:
376
+ items = "".join(
377
+ f"<li><strong>{html.escape(rel.relationship)}</strong> → {html.escape(rel.to)}</li>"
378
+ for rel in obj.relationships
379
+ )
380
+ fields.append(self._field("Relationships", f'<ul class="cv-list">{items}</ul>'))
381
+ if obj.colors:
382
+ fields.append(self._field("Colors", self._chips(obj.colors)))
383
+ if isinstance(obj, Person) and obj.skin_color:
384
+ fields.append(self._field("Skin color", self._chips([obj.skin_color])))
385
+
386
+ data = obj.model_dump(exclude_none=True)
387
+ for key, value in data.items():
388
+ if key not in _SPECIAL_FIELDS:
389
+ fields.append(self._field(key.replace("_", " ").capitalize(), _format_value(value)))
390
+
391
+ fields += self._geometry_fields(obj, obj_id, color)
392
+
393
+ nested = []
394
+ for key, title in _NESTED_SINGLE:
395
+ item = getattr(obj, key, None)
396
+ if item is not None:
397
+ nested.append(self._subsection(title, self._object(item, depth + 1)))
398
+ for key, title in _NESTED_LISTS:
399
+ items = getattr(obj, key, None) or []
400
+ if items:
401
+ nested.append(self._subsection(title, "".join(self._object(item, depth + 1) for item in items)))
402
+ if isinstance(obj, Text) and obj.lines:
403
+ nested.append(self._subsection("Lines", "".join(self._object(line, depth + 1) for line in obj.lines)))
404
+
405
+ body = "".join(fields + nested) or '<p class="cv-empty">No other fields.</p>'
406
+ open_attr = " open" if depth == 0 else ""
407
+ return (
408
+ f'<details class="cv-object"{open_attr}>'
409
+ '<summary class="cv-summary">'
410
+ f'<span class="cv-dot" style="background:{color}"></span>'
411
+ f'<span class="cv-kind">{html.escape(obj.kind.replace("_", " "))}</span>'
412
+ f'<span class="cv-name">{html.escape(name)}</span>'
413
+ "</summary>"
414
+ f'<div class="cv-body">{body}</div>'
415
+ "</details>"
416
+ )
417
+
418
+ def _geometry_fields(self, obj: BaseObject, obj_id: str, color: str) -> list[str]:
419
+ fields = []
420
+ boxes = obj.bounding_boxes or []
421
+ if boxes:
422
+ shapes = "".join(
423
+ self._box_shape(bbox, color, index + 1 if len(boxes) > 1 else None) for index, bbox in enumerate(boxes)
424
+ )
425
+ items = "".join("<li>({}, {}) → ({}, {})</li>".format(*(_number(v) for v in _box(bbox))) for bbox in boxes)
426
+ title = "Bounding boxes" if len(boxes) > 1 else "Bounding box"
427
+ fields.append(
428
+ self._overlay_field(
429
+ title, f"{obj_id}-box", color, shapes, f'<ul class="cv-list cv-list--plain">{items}</ul>'
430
+ )
431
+ )
432
+ if isinstance(obj, Person) and obj.face_bbox is not None:
433
+ left, top, right, bottom = _box(obj.face_bbox)
434
+ fields.append(
435
+ self._overlay_field(
436
+ "Face",
437
+ f"{obj_id}-face",
438
+ FACE_COLOR,
439
+ self._box_shape(obj.face_bbox, FACE_COLOR, None),
440
+ f"({_number(left)}, {_number(top)}) → ({_number(right)}, {_number(bottom)})",
441
+ )
442
+ )
443
+ if isinstance(obj, Person) and obj.keypoints:
444
+ shapes = "".join(self._keypoint_shape(point, color) for point in obj.keypoints)
445
+ items = "".join(
446
+ f"<li>{html.escape(point.name)} ({_number(_coord(point.point.x))}, "
447
+ f"{_number(_coord(point.point.y))})</li>"
448
+ for point in obj.keypoints
449
+ )
450
+ fields.append(
451
+ self._overlay_field(
452
+ f"Keypoints ({len(obj.keypoints)})",
453
+ f"{obj_id}-kp",
454
+ color,
455
+ shapes,
456
+ f'<details><summary>list</summary><ul class="cv-list cv-list--plain">{items}</ul></details>',
457
+ )
458
+ )
459
+ if obj.mask is not None:
460
+ shape, summary = self._mask_shape(obj.mask, color)
461
+ if shape:
462
+ fields.append(self._overlay_field("Mask", f"{obj_id}-mask", color, shape, summary))
463
+ else:
464
+ fields.append(self._field("Mask", summary))
465
+ return fields
466
+
467
+ def _overlay_field(self, label: str, overlay_id: str, color: str, shapes: str, content: str) -> str:
468
+ toggle = f"{overlay_id}-t"
469
+ self.toggles.append((toggle, overlay_id, color))
470
+ self.overlays.append(f'<g class="cv-ov" data-id="{overlay_id}">{shapes}</g>')
471
+ button = (
472
+ f'<label class="cv-button" for="{toggle}">'
473
+ '<span class="cv-off">Show</span><span class="cv-on">Hide</span></label>'
474
+ )
475
+ return (
476
+ '<div class="cv-field">'
477
+ f'<span class="cv-label">{html.escape(label)}</span>{button}'
478
+ f'<div class="cv-value">{content}</div>'
479
+ "</div>"
480
+ )
481
+
482
+ # -- overlay shapes (viewBox is the image in pixels) -----------------------
483
+
484
+ def _px(self, x: float, y: float) -> tuple[float, float]:
485
+ return x / 100.0 * self.width, y / 100.0 * self.height
486
+
487
+ def _box_shape(self, bbox: BoundingBox, color: str, index: int | None) -> str:
488
+ left, top, right, bottom = _box(bbox)
489
+ x0, y0 = self._px(left, top)
490
+ x1, y1 = self._px(right, bottom)
491
+ width, height = max(x1 - x0, 1.0), max(y1 - y0, 1.0)
492
+ stroke = max(self.unit * 0.45, 1.5)
493
+ # A white casing under the colored line keeps boxes visible on pixels of the same color.
494
+ shape = (
495
+ f'<rect x="{x0:.1f}" y="{y0:.1f}" width="{width:.1f}" height="{height:.1f}" fill="none" '
496
+ f'stroke="#ffffff" stroke-opacity="0.9" stroke-width="{stroke * 2:.1f}"/>'
497
+ f'<rect x="{x0:.1f}" y="{y0:.1f}" width="{width:.1f}" height="{height:.1f}" fill="{color}" '
498
+ f'fill-opacity="0.08" stroke="{color}" stroke-width="{stroke:.1f}"/>'
499
+ )
500
+ if index is not None:
501
+ shape += self._label(x0 + stroke, y0 + stroke, str(index))
502
+ return shape
503
+
504
+ def _keypoint_shape(self, point: BodyKeypoint, color: str) -> str:
505
+ x, y = self._px(_coord(point.point.x), _coord(point.point.y))
506
+ radius = max(self.unit * 0.7, 2.5)
507
+ return (
508
+ f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{radius:.1f}" fill="{color}" stroke="#ffffff" '
509
+ f'stroke-width="{radius * 0.45:.1f}"><title>{html.escape(point.name)}</title></circle>'
510
+ '<g class="cv-name-label">'
511
+ + self._label(x + radius * 1.6, y - radius * 1.6, point.name, small=True)
512
+ + "</g>"
513
+ )
514
+
515
+ def _label(self, x: float, y: float, text: str, small: bool = False) -> str:
516
+ size = max(self.unit * (2.0 if small else 2.6), 9.0)
517
+ return (
518
+ f'<text x="{x:.1f}" y="{y + size:.1f}" font-size="{size:.1f}" font-family="system-ui, sans-serif" '
519
+ f'font-weight="600" fill="#111418" stroke="#ffffff" stroke-width="{size * 0.25:.1f}" '
520
+ f'paint-order="stroke">{html.escape(text)}</text>'
521
+ )
522
+
523
+ def _mask_shape(self, mask: ObjectMask, color: str) -> tuple[str, str]:
524
+ rings = []
525
+ for ring in mask.polygons or []:
526
+ values = [_coord(value) for value in ring]
527
+ points = [self._px(x, y) for x, y in zip(values[0::2], values[1::2], strict=False)]
528
+ if len(points) >= 3:
529
+ rings.append("M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in points) + " Z")
530
+ if not rings:
531
+ return "", "empty mask"
532
+ vertices = sum(len(ring) // 2 for ring in mask.polygons or [])
533
+ stroke = max(self.unit * 0.35, 1.2)
534
+ path = " ".join(rings)
535
+ shape = (
536
+ f'<path d="{path}" fill="none" stroke="#ffffff" stroke-opacity="0.9" stroke-width="{stroke * 2:.1f}"/>'
537
+ f'<path d="{path}" fill="{color}" fill-opacity="0.35" fill-rule="evenodd" stroke="{color}" '
538
+ f'stroke-width="{stroke:.1f}"/>'
539
+ )
540
+ return shape, f"polygon mask, {len(rings)} ring(s), {vertices} vertices"
541
+
542
+ # -- small parts -----------------------------------------------------------
543
+
544
+ @staticmethod
545
+ def _field(label: str, content: str, tag: str = "div") -> str:
546
+ return (
547
+ f'<{tag} class="cv-field"><span class="cv-label">{html.escape(label)}</span>'
548
+ f'<div class="cv-value">{content}</div></{tag}>'
549
+ )
550
+
551
+ @staticmethod
552
+ def _subsection(title: str, content: str) -> str:
553
+ return f'<div class="cv-sub"><h4>{html.escape(title)}</h4>{content}</div>'
554
+
555
+ @staticmethod
556
+ def _chips(colors: list[Color]) -> str:
557
+ chips = []
558
+ for color in colors:
559
+ red, green, blue = _rgb(color)
560
+ label = f"{html.escape(color.name)} · " if color.name else ""
561
+ chips.append(
562
+ f'<li class="cv-chip"><span class="cv-swatch" style="background:rgb({red},{green},{blue})"></span>'
563
+ f"{label}rgb({red}, {green}, {blue})</li>"
564
+ )
565
+ return f'<ul class="cv-chips">{"".join(chips)}</ul>'
566
+
567
+ def _object_color(self, obj: BaseObject) -> str:
568
+ primary = (obj.colors or [None])[0]
569
+ if primary is None and isinstance(obj, Person):
570
+ primary = obj.skin_color
571
+ if primary is not None:
572
+ red, green, blue = _rgb(primary)
573
+ return f"rgb({red},{green},{blue})"
574
+ return PALETTE[self.counter % len(PALETTE)]
data_model.py ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Iterator
2
+ from typing import Literal
3
+
4
+ import numpy as np
5
+ from PIL import Image, ImageDraw
6
+ from pydantic import BaseModel, Field
7
+
8
+ #: Attributes that can hold nested scene objects.
9
+ _NESTED_LIST_FIELDS = ("children", "clothing", "footwear", "accessories")
10
+ _NESTED_SINGLE_FIELDS = ("eyes", "hair", "facial_hair")
11
+
12
+
13
+ def _iter_nested(object, seen=None):
14
+ """Yield `object` and every scene object nested below it, parent first."""
15
+ if object is None:
16
+ return
17
+ if seen is None:
18
+ seen = set()
19
+ if id(object) in seen:
20
+ return
21
+ seen.add(id(object))
22
+ yield object
23
+ for field in _NESTED_LIST_FIELDS:
24
+ for item in getattr(object, field, None) or []:
25
+ yield from _iter_nested(item, seen)
26
+ for field in _NESTED_SINGLE_FIELDS:
27
+ yield from _iter_nested(getattr(object, field, None), seen)
28
+
29
+
30
+ def _format_coord_token(value: float) -> str:
31
+ return f"<coord_{round(float(value), 1):.1f}>"
32
+
33
+
34
+ def _parse_coord_token(value: float | str) -> float:
35
+ if isinstance(value, str) and value.startswith("<coord_") and value.endswith(">"):
36
+ return float(value[len("<coord_") : -1])
37
+ return float(value)
38
+
39
+
40
+ def _format_color_token(value: int) -> int | str:
41
+ if value < 0 or value > 255:
42
+ raise ValueError("Color channels must be between 0 and 255")
43
+ return f"<color_{value}>"
44
+
45
+
46
+ def _parse_color_token(value: int | str) -> int:
47
+ if isinstance(value, str) and value.startswith("<color_") and value.endswith(">"):
48
+ return int(value[len("<color_") : -1])
49
+ return int(value)
50
+
51
+
52
+ class Color(BaseModel):
53
+ name: str | None = Field(None)
54
+ red: int | str = Field(...)
55
+ green: int | str = Field(...)
56
+ blue: int | str = Field(...)
57
+
58
+ def post_process(self):
59
+ if isinstance(self.red, str):
60
+ self.red = _parse_color_token(self.red)
61
+ if isinstance(self.green, str):
62
+ self.green = _parse_color_token(self.green)
63
+ if isinstance(self.blue, str):
64
+ self.blue = _parse_color_token(self.blue)
65
+
66
+
67
+ class Point(BaseModel):
68
+ x: float | str = Field(...)
69
+ y: float | str = Field(...)
70
+
71
+ def post_process(self):
72
+ if isinstance(self.x, str):
73
+ self.x = _parse_coord_token(self.x)
74
+ if isinstance(self.y, str):
75
+ self.y = _parse_coord_token(self.y)
76
+
77
+
78
+ class BoundingBox(BaseModel):
79
+ top_left: Point = Field(...)
80
+ bottom_right: Point = Field(...)
81
+
82
+ def post_process(self):
83
+ self.top_left.post_process()
84
+ self.bottom_right.post_process()
85
+
86
+
87
+ class BodyKeypoint(BaseModel):
88
+ name: str = Field(..., description="The name of the keypoint")
89
+ point: Point = Field(...)
90
+
91
+ def post_process(self):
92
+ self.point.post_process()
93
+
94
+
95
+ class ObjectMask(BaseModel):
96
+ polygons: list[list[float | str]] | None = Field(None)
97
+
98
+ def post_process(self):
99
+ if self.polygons is not None:
100
+ self.polygons = [[_parse_coord_token(value) for value in ring] for ring in self.polygons]
101
+
102
+ def decode(self, image_width: int, image_height: int) -> np.ndarray:
103
+ if image_width <= 0 or image_height <= 0:
104
+ raise ValueError("Image width and height must be positive")
105
+
106
+ canvas = Image.new("L", (image_width, image_height), 0)
107
+ draw = ImageDraw.Draw(canvas)
108
+ for ring in self.polygons or []:
109
+ values = [_parse_coord_token(value) for value in ring]
110
+ points = [(x * image_width / 100.0, y * image_height / 100.0) for x, y in zip(values[0::2], values[1::2])]
111
+ if len(points) >= 3:
112
+ draw.polygon(points, fill=255)
113
+ return np.asarray(canvas) >= 128
114
+
115
+
116
+ class Relationship(BaseModel):
117
+ to: str = Field(...)
118
+ relationship: str = Field(...)
119
+
120
+
121
+ class BaseObject(BaseModel):
122
+ name: str = Field(
123
+ "",
124
+ description="Short description of the object",
125
+ )
126
+ kind: Literal["base_object", "object", "person", "animal", "text"] = "base_object"
127
+ description: str | None = Field(
128
+ None,
129
+ description="A more detailed description of the object",
130
+ )
131
+ location: str | None = Field(None)
132
+ position: Literal["foreground", "midground", "background"] | None = Field(None)
133
+ relative_size: Literal["small", "medium", "large"] | None = Field(None)
134
+ bounding_boxes: list[BoundingBox] | None = Field(None)
135
+ num_objects: int | None = Field(None)
136
+ relative_distance: float | None = Field(
137
+ None,
138
+ ge=0,
139
+ le=100,
140
+ description="The relative distance of the object from the camera, 0 is at the camera, 100 is at the far edge of the image, None if not applicable",
141
+ )
142
+ colors: list[Color] | None = Field(None)
143
+ blur: str | None = Field(
144
+ None,
145
+ description="Description of the blurring of the object, if any",
146
+ )
147
+ details: list[str] | None = Field(
148
+ None,
149
+ description="Additional details about the object",
150
+ )
151
+ relationships: list[Relationship] | None = Field(None)
152
+ mask: ObjectMask | None = Field(None)
153
+ artifacts: list[str] | None = Field(
154
+ None,
155
+ description="Visual issues with the object",
156
+ )
157
+
158
+ def make_full_mask(self):
159
+ self.location = None
160
+ self.position = None
161
+ self.relative_size = None
162
+
163
+ def make_full(self):
164
+ self.make_full_mask()
165
+ self.mask = None
166
+
167
+ def make_long(self):
168
+ self.make_full()
169
+ self.add_location()
170
+ self.add_position()
171
+ self.add_relative_size()
172
+ self.bounding_boxes = None
173
+ self.relative_distance = None
174
+ self.mask = None
175
+
176
+ def make_short(self):
177
+ self.make_long()
178
+ self.relationships = None
179
+ self.details = None
180
+ self.colors = None
181
+ self.blur = None
182
+ self.artifacts = None
183
+
184
+ def drop_masks(self):
185
+ """Clear `mask` on this object and every object nested below it."""
186
+ for object in _iter_nested(self):
187
+ object.mask = None
188
+
189
+ def _keep_only(self, fields_to_keep: set[str]) -> None:
190
+ for field_name in type(self).model_fields:
191
+ if field_name not in fields_to_keep:
192
+ setattr(self, field_name, None)
193
+
194
+ def make_for_detect(self):
195
+ self._keep_only({"name", "kind", "bounding_boxes"})
196
+
197
+ def make_for_mask(self):
198
+ self._keep_only({"name", "kind", "bounding_boxes", "mask"})
199
+
200
+ def make_for_keypoints(self):
201
+ self._keep_only({"name", "kind", "keypoints", "bounding_boxes"})
202
+
203
+ def _box_extents(self) -> list[tuple[float, float, float, float]]:
204
+ """(left, top, right, bottom) of each bounding box, in 0-100 coordinates."""
205
+ extents = []
206
+ for bounding_box in self.bounding_boxes or []:
207
+ left, right = sorted(
208
+ (_parse_coord_token(bounding_box.top_left.x), _parse_coord_token(bounding_box.bottom_right.x))
209
+ )
210
+ top, bottom = sorted(
211
+ (_parse_coord_token(bounding_box.top_left.y), _parse_coord_token(bounding_box.bottom_right.y))
212
+ )
213
+ extents.append((left, top, right, bottom))
214
+ return extents
215
+
216
+ def add_location(self):
217
+ """Set `location` from the joint extent of the bounding boxes, on a grid of thirds."""
218
+ extents = self._box_extents()
219
+ if not extents:
220
+ return self
221
+ left = min(extent[0] for extent in extents)
222
+ top = min(extent[1] for extent in extents)
223
+ right = max(extent[2] for extent in extents)
224
+ bottom = max(extent[3] for extent in extents)
225
+ if left <= 1 and top <= 1 and right >= 99 and bottom >= 99:
226
+ self.location = "full frame"
227
+ return self
228
+
229
+ third, two_thirds, edge = 100 / 3, 200 / 3, 1.0
230
+
231
+ def classify(low: float, high: float) -> str:
232
+ # "full" is an object that spans the axis, such as sky or ground.
233
+ if low < third and high > two_thirds and high - low > 50:
234
+ return "full"
235
+ center = (low + high) / 2
236
+ if center < third:
237
+ return "low"
238
+ if center > two_thirds:
239
+ return "high"
240
+ return "mid"
241
+
242
+ horizontal, vertical = classify(left, right), classify(top, bottom)
243
+ h_name = {"low": "left", "mid": "center", "high": "right"}.get(horizontal)
244
+ v_name = {"low": "top", "mid": "middle", "high": "bottom"}.get(vertical)
245
+ if horizontal == "full" and vertical == "full":
246
+ location = "center"
247
+ elif horizontal == "full":
248
+ location = {"low": "top", "high": "bottom", "mid": "center"}[vertical]
249
+ elif vertical == "full":
250
+ location = {"low": "left", "high": "right", "mid": "center"}[horizontal]
251
+ elif horizontal == "low" and vertical == "low" and left <= edge and top <= edge:
252
+ location = "top-left corner"
253
+ elif horizontal == "high" and vertical == "low" and right >= 100 - edge and top <= edge:
254
+ location = "top-right corner"
255
+ elif horizontal == "low" and vertical == "high" and left <= edge and bottom >= 100 - edge:
256
+ location = "bottom-left corner"
257
+ elif horizontal == "high" and vertical == "high" and right >= 100 - edge and bottom >= 100 - edge:
258
+ location = "bottom-right corner"
259
+ elif horizontal == "mid" and vertical == "mid":
260
+ location = "center"
261
+ elif vertical == "mid":
262
+ location = f"{h_name} of center"
263
+ elif horizontal == "mid":
264
+ location = f"{v_name} center"
265
+ else:
266
+ location = f"{v_name} {h_name}"
267
+ self.location = location
268
+ return self
269
+
270
+ def add_position(self):
271
+ """Set `position` from `relative_distance`, in thirds of the depth."""
272
+ if self.relative_distance is None:
273
+ return self
274
+ if self.relative_distance < 100 / 3:
275
+ self.position = "foreground"
276
+ elif self.relative_distance < 200 / 3:
277
+ self.position = "midground"
278
+ else:
279
+ self.position = "background"
280
+ return self
281
+
282
+ def add_relative_size(self):
283
+ """Set `relative_size` from the summed area of the bounding boxes."""
284
+ extents = self._box_extents()
285
+ if not extents:
286
+ return self
287
+ ratio = sum((right - left) * (bottom - top) for left, top, right, bottom in extents) / (100.0 * 100.0)
288
+ if ratio < 0.05:
289
+ self.relative_size = "small"
290
+ elif ratio < 0.25:
291
+ self.relative_size = "medium"
292
+ else:
293
+ self.relative_size = "large"
294
+ return self
295
+
296
+ def post_process(self):
297
+ if self.bounding_boxes is not None:
298
+ for bbox in self.bounding_boxes:
299
+ bbox.post_process()
300
+ if self.colors is not None:
301
+ for color in self.colors:
302
+ color.post_process()
303
+ if self.mask is not None:
304
+ self.mask.post_process()
305
+
306
+
307
+ class Object(BaseObject):
308
+ kind: Literal["object"] = "object"
309
+ children: list["SceneObject"] | None = Field(None)
310
+ material: str | None = Field(None)
311
+ texture: str | None = Field(None)
312
+ transparency: float | None = Field(
313
+ None,
314
+ ge=0,
315
+ le=100,
316
+ description="The transparency of the object, 0 is fully transparent, 100 is fully opaque",
317
+ )
318
+
319
+ def make_long(self):
320
+ super().make_long()
321
+ self.children = None
322
+
323
+ def make_short(self):
324
+ super().make_short()
325
+ self.material = None
326
+ self.texture = None
327
+ self.transparency = None
328
+
329
+ def post_process(self):
330
+ super().post_process()
331
+ if self.children is not None:
332
+ for child in self.children:
333
+ child.post_process()
334
+
335
+
336
+ class TextLine(BaseObject):
337
+ text: str = Field(...)
338
+
339
+
340
+ class Text(BaseObject):
341
+ kind: Literal["text"] = "text"
342
+ language: str | None = Field(None, pattern=r"^[A-Za-z]{2,3}(-[A-Za-z]{2})?$")
343
+ lines: list[TextLine] = Field(...)
344
+ font_family: str | None = Field(None)
345
+ font_description: str | None = Field(None)
346
+ font_weight: Literal["100", "200", "300", "400", "500", "600", "700", "800", "900"] | None = Field(None)
347
+ font_style: Literal["normal", "italic", "oblique"] | None = Field(None)
348
+ text_case: Literal["lowercase", "uppercase", "title", "sentence", "mixed"] | None = Field(None)
349
+ text_orientation: Literal["horizontal", "vertical"] | None = Field(None)
350
+ text_alignment: Literal["left", "center", "right", "justify"] | None = Field(None)
351
+ text_decoration: Literal["none", "underline", "line-through", "overline", "shadow"] | None = Field(None)
352
+ rotation: float | None = Field(None, ge=-180, le=180)
353
+
354
+ def make_long(self):
355
+ super().make_long()
356
+ for line in self.lines:
357
+ line.make_long()
358
+
359
+ def make_short(self):
360
+ super().make_short()
361
+ self.language = None
362
+ self.font_family = None
363
+ self.font_description = None
364
+ self.font_weight = None
365
+ self.font_style = None
366
+ self.text_case = None
367
+ self.text_orientation = None
368
+ self.text_alignment = None
369
+ self.text_decoration = None
370
+ self.rotation = None
371
+ for line in self.lines:
372
+ line.make_short()
373
+
374
+ def post_process(self):
375
+ super().post_process()
376
+ for line in self.lines:
377
+ line.post_process()
378
+
379
+
380
+ class Person(BaseObject):
381
+ kind: Literal["person"] = "person"
382
+ age: str | None = Field(None)
383
+ gender: str | None = Field(None)
384
+ ethnicity: str | None = Field(None)
385
+ skin_color: Color | None = Field(None)
386
+ keypoints: list[BodyKeypoint] | None = Field(None)
387
+ activity: str | None = Field(None)
388
+ face_bbox: BoundingBox | None = Field(None)
389
+ expression: str | None = Field(None)
390
+ eyes: Object | None = Field(None, description="Description of the eyes")
391
+ mouth: str | None = Field(None, description="Description of the mouth")
392
+ nose: str | None = Field(None, description="Description of the nose")
393
+ hair: Object | None = Field(None, description="Description of the hair")
394
+ ears: str | None = Field(None, description="Description of the ears")
395
+ facial_hair: Object | None = Field(
396
+ None,
397
+ description="Description of the facial hair",
398
+ )
399
+ body: str | None = Field(None)
400
+ left_hand: str | None = Field(None)
401
+ right_hand: str | None = Field(None)
402
+ left_leg: str | None = Field(None)
403
+ right_leg: str | None = Field(None)
404
+ clothing: list[Object] | None = Field(None)
405
+ footwear: list[Object] | None = Field(None)
406
+ accessories: list[Object] | None = Field(None)
407
+ children: list[Object | Text] | None = Field(None)
408
+
409
+ def make_long(self):
410
+ super().make_long()
411
+ self.face_bbox = None
412
+ self.keypoints = None
413
+ self.children = None
414
+
415
+ def make_short(self):
416
+ super().make_short()
417
+ self.skin_color = None
418
+ self.activity = None
419
+ self.expression = None
420
+ self.eyes = None
421
+ self.mouth = None
422
+ self.nose = None
423
+ self.hair = None
424
+ self.ears = None
425
+ self.facial_hair = None
426
+ self.body = None
427
+ self.left_hand = None
428
+ self.right_hand = None
429
+ self.left_leg = None
430
+ self.right_leg = None
431
+ self.clothing = None
432
+ self.footwear = None
433
+ self.accessories = None
434
+ self.face_bbox = None
435
+ self.children = None
436
+
437
+ def post_process(self):
438
+ super().post_process()
439
+ if self.keypoints is not None:
440
+ for keypoint in self.keypoints:
441
+ keypoint.post_process()
442
+ if self.face_bbox is not None:
443
+ self.face_bbox.post_process()
444
+ if self.skin_color is not None:
445
+ self.skin_color.post_process()
446
+ if self.facial_hair is not None:
447
+ self.facial_hair.post_process()
448
+ if self.hair is not None:
449
+ self.hair.post_process()
450
+ if self.eyes is not None:
451
+ self.eyes.post_process()
452
+ if self.clothing is not None:
453
+ for clothing in self.clothing:
454
+ clothing.post_process()
455
+ if self.footwear is not None:
456
+ for footwear in self.footwear:
457
+ footwear.post_process()
458
+ if self.accessories is not None:
459
+ for accessory in self.accessories:
460
+ accessory.post_process()
461
+ if self.children is not None:
462
+ for child in self.children:
463
+ child.post_process()
464
+
465
+
466
+ class Animal(BaseObject):
467
+ kind: Literal["animal"] = "animal"
468
+ children: list[Object | Text] | None = Field(None)
469
+ head: str | None = Field(None)
470
+ body: str | None = Field(None)
471
+ legs: str | None = Field(None)
472
+ hands: str | None = Field(None)
473
+ tail: str | None = Field(None)
474
+ wings: str | None = Field(None)
475
+
476
+ def make_long(self):
477
+ super().make_long()
478
+
479
+ def make_short(self):
480
+ super().make_short()
481
+ self.head = None
482
+ self.body = None
483
+ self.legs = None
484
+ self.hands = None
485
+ self.tail = None
486
+ self.wings = None
487
+
488
+ def post_process(self):
489
+ super().post_process()
490
+ if self.children is not None:
491
+ for child in self.children:
492
+ child.post_process()
493
+
494
+
495
+ SceneObject = Object | Person | Animal | Text | BaseObject
496
+
497
+ Object.model_rebuild()
498
+
499
+
500
+ class SceneObjectList(BaseModel):
501
+ objects: list[SceneObject] = Field(...)
502
+
503
+ def post_process(self):
504
+ if self.objects is not None:
505
+ for object in self.objects:
506
+ object.post_process()
507
+
508
+
509
+ class GeneralDescription(BaseModel):
510
+ user_prompt: str | None = Field(None, description="The user's prompt for the image")
511
+ image_type: str | None = Field(None, description="The type of the image")
512
+ style: str | None = Field(None, description="The style of the image")
513
+ medium: str | None = Field(None)
514
+ color_palette: list[Color] | None = Field(None)
515
+ aesthetic_score: float | None = Field(None, ge=0, le=10, description="The aesthetic score of the image")
516
+ atmosphere: str | None = Field(None, description="The overall feeling or emotion the image evokes")
517
+ setting: str | None = Field(None, description="The environment or context where the image takes place")
518
+ lighting: str | None = Field(None)
519
+ composition: str | None = Field(None)
520
+ depth_of_field: str | None = Field(None)
521
+ viewpoint: str | None = Field(None)
522
+ intent: str | None = Field(None, description="The purpose of the image")
523
+ description: str | None = Field(None, description="A short and comprehensive description of the image")
524
+ artifacts: list[str] | None = Field(None, description="Visual discrepancies or anomalies in the image")
525
+ camera_model: str | None = Field(None)
526
+ lens_model: str | None = Field(None)
527
+ focal_length: float | None = Field(None)
528
+ aperture: float | None = Field(None)
529
+ shutter_speed: float | None = Field(None)
530
+ iso: int | None = Field(None)
531
+ f_number: float | None = Field(None)
532
+ exposure_time_ms: float | None = Field(None)
533
+
534
+ def post_process(self):
535
+ if self.color_palette is not None:
536
+ for color in self.color_palette:
537
+ color.post_process()
538
+ return self
539
+
540
+
541
+ class SceneDescription(BaseModel):
542
+ general: GeneralDescription = Field(...)
543
+ objects: list[SceneObject] | None = Field(None)
544
+
545
+ def iter_objects(self) -> Iterator[SceneObject]:
546
+ """Yield every object in the `children` tree, parent first. Wearables stay behind."""
547
+
548
+ def walk(objects, seen):
549
+ for object in objects or []:
550
+ if id(object) in seen:
551
+ continue
552
+ seen.add(id(object))
553
+ yield object
554
+ yield from walk(getattr(object, "children", None), seen)
555
+
556
+ yield from walk(self.objects, set())
557
+
558
+ def iter_all_objects(self) -> Iterator[SceneObject]:
559
+ """Yield every object anywhere in the tree, wearables and face parts included."""
560
+ seen: set[int] = set()
561
+ for object in self.objects or []:
562
+ yield from _iter_nested(object, seen)
563
+
564
+ def _hoisted_wearables(self) -> list[SceneObject]:
565
+ """Wearables with a box, renamed "<item> on <person>", as standalone targets."""
566
+ hoisted = []
567
+ for object in self.iter_objects():
568
+ if object.kind != "person":
569
+ continue
570
+ for field in ("clothing", "footwear", "accessories"):
571
+ for item in getattr(object, field, None) or []:
572
+ if not item.name or not item.bounding_boxes:
573
+ continue
574
+ item.name = f"{item.name} on {object.name}"
575
+ hoisted.append(item)
576
+ return hoisted
577
+
578
+ def make_for_detect(self):
579
+ all_objects = list(self.iter_objects()) + self._hoisted_wearables()
580
+ for object in all_objects:
581
+ object.make_for_detect()
582
+ self.objects = all_objects
583
+
584
+ def make_for_mask(self):
585
+ all_objects = list(self.iter_objects()) + self._hoisted_wearables()
586
+ for object in all_objects:
587
+ object.make_for_mask()
588
+ self.objects = all_objects
589
+
590
+ def make_for_keypoints(self):
591
+ people = [object for object in self.iter_objects() if object.kind == "person" and object.keypoints is not None]
592
+ for object in people:
593
+ object.make_for_keypoints()
594
+ self.objects = people
595
+
596
+ def make_general(self):
597
+ self.objects = None
598
+
599
+ def make_long(self):
600
+ every_object = list(self.iter_all_objects())
601
+ flattened = list(self.iter_objects())
602
+ for object in every_object:
603
+ object.make_long()
604
+ self.objects = flattened
605
+
606
+ def make_short(self):
607
+ new_objects = []
608
+ for object in self.iter_objects():
609
+ new_objects.append(object)
610
+ for object in new_objects:
611
+ object.make_short()
612
+ self.objects = new_objects
613
+
614
+ def make_full_mask(self):
615
+ for object in self.iter_all_objects():
616
+ object.make_full_mask()
617
+
618
+ def make_full(self):
619
+ for object in self.iter_all_objects():
620
+ object.make_full()
621
+
622
+ def post_process(self):
623
+ if self.general is not None:
624
+ self.general.post_process()
625
+ for object in self.objects or []:
626
+ object.post_process()
627
+
628
+
629
+ for _model in (
630
+ BaseObject,
631
+ Object,
632
+ Person,
633
+ Animal,
634
+ TextLine,
635
+ Text,
636
+ SceneObjectList,
637
+ GeneralDescription,
638
+ SceneDescription,
639
+ ):
640
+ _model.model_rebuild(force=True)
scene_analyzer.py ADDED
@@ -0,0 +1,845 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scene Analyzer [dev] with Hugging Face Transformers.
2
+
3
+ `SceneAnalyzerModel` builds the task prompts the model was trained on, samples answers,
4
+ and returns the best valid answer per input as a `data_model` object:
5
+
6
+ from scene_analyzer import SceneAnalyzerModel, SceneAnalyzerTask, DetailLevel
7
+ model = SceneAnalyzerModel("briaai/fibo-scene-analyzer")
8
+ [caption] = model.generate([image], SceneAnalyzerTask.CAPTION, detail_level=DetailLevel.FULL)
9
+
10
+ The task logic in this module is shared by the other two harnesses, which replace only
11
+ the engine: `scene_analyzer_vllm` (vLLM in-process, with schema-constrained decoding)
12
+ and `scene_analyzer_server` (an OpenAI-compatible server).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from dataclasses import dataclass, field, replace
20
+ from enum import Enum
21
+ from typing import Any
22
+
23
+ from data_model import BoundingBox, SceneDescription, SceneObjectList, _format_color_token, _format_coord_token
24
+ from PIL import Image
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ VISION_TOKEN = "<|vision_start|><|image_pad|><|vision_end|>"
29
+ IM_START_TOKEN = "<|im_start|>"
30
+ IM_END_TOKEN = "<|im_end|>"
31
+ EOS_TOKEN = "<|endoftext|>"
32
+ STOP_TOKENS = (IM_END_TOKEN, EOS_TOKEN)
33
+ THINK_END = "</think>"
34
+ ASSISTANT_PREFIX = f"{IM_START_TOKEN}assistant\n<think>\n\n"
35
+ # The trained transition from the reasoning trace to the answer includes both newlines.
36
+ THINK_CLOSE = f"{THINK_END}\n\n"
37
+
38
+ # Sampling settings the model is tuned for.
39
+ TEMPERATURE = 0.6
40
+ TOP_P = 0.97
41
+
42
+ # Context held back for the answer when a reasoning trace is added to the prompt.
43
+ ANSWER_RESERVE_TOKENS = 8192
44
+
45
+ # A candidate that repeats one text line (3+ characters) this many times is a repetition
46
+ # loop that happened to close its JSON, and is rejected.
47
+ TEXT_LOOP_REPEATS = 25
48
+ # The same for one object name (the description, for unnamed text objects) in a caption.
49
+ # Repeated objects are grouped by design, so correct captions stay far below this.
50
+ NAME_LOOP_REPEATS = 25
51
+
52
+ # Two boxes overlapping above this IoU are the same box.
53
+ DUPLICATE_BOX_IOU = 0.95
54
+
55
+
56
+ class SceneAnalyzerTask(Enum):
57
+ CAPTION = "caption"
58
+ DESCRIBE = "describe"
59
+ DETECT = "detect"
60
+ KEYPOINTS = "keypoints"
61
+ OCR = "ocr"
62
+ EXPAND = "expand"
63
+ EXPAND_OBJECT = "expand_object"
64
+ MASK = "mask"
65
+ REFINE = "refine"
66
+ EDIT = "edit"
67
+
68
+
69
+ class DetailLevel(Enum):
70
+ """The detail level tag of <caption>, <expand>, <expand_object>, <refine> and <edit>."""
71
+
72
+ GENERAL = "general"
73
+ SHORT = "short"
74
+ LONG = "long"
75
+ FULL = "full"
76
+ FULL_MASK = "full_mask"
77
+
78
+
79
+ class ThinkingEffort(Enum):
80
+ MINIMAL = "minimal"
81
+ LOW = "low"
82
+ MEDIUM = "medium"
83
+ HIGH = "high"
84
+
85
+
86
+ PLAIN_DETAIL_LEVELS = frozenset({DetailLevel.GENERAL, DetailLevel.SHORT, DetailLevel.LONG, DetailLevel.FULL})
87
+
88
+ #: The detail levels each task accepts. <caption> accepts all; tasks without the tag ignore it.
89
+ TASK_DETAIL_LEVELS = {
90
+ SceneAnalyzerTask.EXPAND: PLAIN_DETAIL_LEVELS,
91
+ SceneAnalyzerTask.EXPAND_OBJECT: frozenset(
92
+ {DetailLevel.SHORT, DetailLevel.LONG, DetailLevel.FULL, DetailLevel.FULL_MASK}
93
+ ),
94
+ SceneAnalyzerTask.REFINE: PLAIN_DETAIL_LEVELS,
95
+ SceneAnalyzerTask.EDIT: PLAIN_DETAIL_LEVELS,
96
+ }
97
+
98
+ #: Tasks that answer with a whole SceneDescription. All others answer with a
99
+ #: SceneObjectList (<expand_object> with a one-element list).
100
+ SCENE_DESCRIPTION_TASKS = frozenset(
101
+ {SceneAnalyzerTask.CAPTION, SceneAnalyzerTask.EXPAND, SceneAnalyzerTask.REFINE, SceneAnalyzerTask.EDIT}
102
+ )
103
+
104
+ #: Tasks that take no image.
105
+ TEXT_ONLY_TASKS = frozenset({SceneAnalyzerTask.EXPAND, SceneAnalyzerTask.EXPAND_OBJECT, SceneAnalyzerTask.REFINE})
106
+
107
+ #: Tasks whose answer is geometry for the image content. Their best sample is the most
108
+ #: confident one: for them, more objects means more false positives, not a richer answer.
109
+ GROUNDING_TASKS = frozenset({SceneAnalyzerTask.DETECT, SceneAnalyzerTask.MASK, SceneAnalyzerTask.KEYPOINTS, SceneAnalyzerTask.OCR})
110
+
111
+ #: Tasks with a trained completion form (the prompt shows part of the answer and the model
112
+ #: gives the missing objects), so they support revision rounds.
113
+ ITERATIVE_TASKS = frozenset({SceneAnalyzerTask.CAPTION, *GROUNDING_TASKS})
114
+
115
+ #: Grounding tasks whose prompt can name one target.
116
+ NAMEABLE_TARGET_TASKS = frozenset({SceneAnalyzerTask.DETECT, SceneAnalyzerTask.MASK, SceneAnalyzerTask.KEYPOINTS})
117
+
118
+ #: Nested lists that a revision round can graft onto an existing root object.
119
+ NESTED_MERGE_FIELDS = ("children", "clothing", "footwear", "accessories")
120
+
121
+ #: Tasks trained with reasoning traces. The other tasks always answer directly.
122
+ THINKING_TASKS = SCENE_DESCRIPTION_TASKS
123
+
124
+ #: The reasoning efforts trained for each detail level.
125
+ DETAIL_LEVEL_EFFORTS = {
126
+ DetailLevel.GENERAL: (ThinkingEffort.MINIMAL,),
127
+ DetailLevel.SHORT: (ThinkingEffort.MINIMAL, ThinkingEffort.LOW),
128
+ DetailLevel.LONG: (ThinkingEffort.MINIMAL, ThinkingEffort.LOW, ThinkingEffort.MEDIUM),
129
+ DetailLevel.FULL: tuple(ThinkingEffort),
130
+ DetailLevel.FULL_MASK: tuple(ThinkingEffort),
131
+ }
132
+
133
+
134
+ @dataclass
135
+ class Prompt:
136
+ """One model input in the trained chat format.
137
+
138
+ :param text: the user turn after the images: the task tags and the task input.
139
+ :param images: the images, in order.
140
+ :param thinking_effort: None for a direct answer; otherwise the effort of the
141
+ reasoning trace that the model writes before the answer.
142
+ :param continuation: assistant text after the opening, such as a finished trace.
143
+ """
144
+
145
+ text: str
146
+ images: list[Image.Image] = field(default_factory=list)
147
+ thinking_effort: ThinkingEffort | None = None
148
+ continuation: str = ""
149
+
150
+ def render(self) -> str:
151
+ """The prompt as the model sees it."""
152
+ opening = f"<{self.thinking_effort.value}>" if self.thinking_effort else THINK_CLOSE
153
+ vision = VISION_TOKEN * len(self.images)
154
+ return (
155
+ f"{IM_START_TOKEN}user\n{vision}{self.text}{IM_END_TOKEN}\n{ASSISTANT_PREFIX}{opening}{self.continuation}"
156
+ )
157
+
158
+ @property
159
+ def answers_directly(self) -> bool:
160
+ """Whether the next token starts the answer, not a reasoning trace."""
161
+ return self.thinking_effort is None or self.continuation.endswith(THINK_CLOSE)
162
+
163
+
164
+ @dataclass
165
+ class SamplingConfig:
166
+ n: int = 1
167
+ temperature: float = TEMPERATURE
168
+ top_p: float = TOP_P
169
+ max_tokens: int = 32768
170
+ stop: tuple[str, ...] = STOP_TOKENS
171
+ #: JSON schema for constrained decoding. Engines without constrained decoding ignore it.
172
+ json_schema: dict | None = None
173
+ seed: int | None = None
174
+
175
+
176
+ @dataclass
177
+ class Sample:
178
+ """One sampled completion. `text` excludes the stop token; `token_ids` includes it."""
179
+
180
+ text: str
181
+ token_ids: list[int]
182
+ cumulative_logprob: float
183
+
184
+
185
+ @dataclass
186
+ class Completion:
187
+ """The samples for one prompt, and the prompt length in tokens."""
188
+
189
+ samples: list[Sample]
190
+ prompt_tokens: int
191
+
192
+
193
+ def _aspect_ratio_value(aspect_ratio: str) -> float:
194
+ """The "W:H" aspect ratio as the rounded number the prompts carry."""
195
+ width, height = aspect_ratio.split(":")
196
+ return round(int(width) / int(height), 2)
197
+
198
+
199
+ def _confidence(sample: Sample) -> float:
200
+ """Length-normalised log probability of a sample."""
201
+ return sample.cumulative_logprob / max(len(sample.token_ids), 1) ** 0.6
202
+
203
+
204
+ def prompt_json(description: SceneDescription) -> str:
205
+ """`description` as compact JSON in the trained prompt form, with coordinates and color
206
+ channels as tokens also after `post_process()` has converted them to numbers."""
207
+
208
+ def tokens(node: Any, key: str | None = None) -> Any:
209
+ if isinstance(node, dict):
210
+ if node.keys() == {"x", "y"}:
211
+ return {k: v if isinstance(v, str) else _format_coord_token(v) for k, v in node.items()}
212
+ if {"red", "green", "blue"} <= node.keys():
213
+ return {
214
+ k: _format_color_token(v) if k in ("red", "green", "blue") and not isinstance(v, str) else v
215
+ for k, v in node.items()
216
+ }
217
+ return {k: tokens(v, k) for k, v in node.items()}
218
+ if isinstance(node, list):
219
+ if key == "polygons":
220
+ return [[v if isinstance(v, str) else _format_coord_token(v) for v in ring] for ring in node]
221
+ return [tokens(v, key) for v in node]
222
+ return node
223
+
224
+ dumped = description.model_dump(mode="json", exclude_none=True, exclude_unset=True)
225
+ return json.dumps(tokens(dumped), ensure_ascii=False, separators=(",", ":"))
226
+
227
+
228
+ _DETERMINERS = frozenset({"a", "an", "the", "some", "several", "many", "two", "three", "four", "five"})
229
+
230
+
231
+ def _name_in_scene_text(scene: SceneDescription, name: str | None) -> SceneDescription:
232
+ if not name or not name.strip():
233
+ return scene
234
+ name = name.strip()
235
+ first = name.split()[0].lower()
236
+ article = "" if first in _DETERMINERS or first[0].isdigit() else ("an " if first[0] in "aeiou" else "a ")
237
+ general = scene.general.model_copy()
238
+ general.description = f"{(general.description or '').rstrip()} The scene also shows {article}{name}.".lstrip()
239
+ if general.user_prompt:
240
+ general.user_prompt = f"{general.user_prompt.rstrip().rstrip('.')}, with {article}{name}."
241
+ return scene.model_copy(update={"general": general})
242
+
243
+
244
+ def _answer_text(text: str) -> str:
245
+ """The JSON answer in a completion: after the reasoning trace, without stop tokens."""
246
+ text = text.strip()
247
+ for token in STOP_TOKENS:
248
+ text = text.removesuffix(token).strip()
249
+ return text.split(THINK_END)[-1].strip()
250
+
251
+
252
+ class SceneAnalyzerModel:
253
+ """Scene Analyzer on Hugging Face Transformers.
254
+
255
+ Transformers has no schema-constrained decoding, so an answer that is not valid JSON
256
+ for the schema is rejected and sampled again (see `retries` in `generate`). The vLLM
257
+ harness constrains the decoding and is much faster.
258
+ """
259
+
260
+ def __init__(
261
+ self,
262
+ model_name: str,
263
+ device_map: str | dict | None = "auto",
264
+ dtype: str = "bfloat16",
265
+ max_images: int = 5,
266
+ seed: int | None = None,
267
+ max_output_tokens: int = 32768,
268
+ max_model_len: int = 32768,
269
+ max_trace_tokens: int = 16384,
270
+ **model_kwargs: Any,
271
+ ):
272
+ """
273
+ :param model_name: path or HF id of the checkpoint.
274
+ :param device_map: passed to `from_pretrained`.
275
+ :param dtype: the model dtype.
276
+ :param max_images: cap on images per prompt (<edit> takes up to 5).
277
+ :param seed: sampling seed, for reproducible answers.
278
+ :param max_output_tokens: completion cap. A full caption can need tens of
279
+ thousands of tokens.
280
+ :param max_model_len: context length; the prompt and the answer must fit in it.
281
+ :param max_trace_tokens: cap on the reasoning trace.
282
+ :param model_kwargs: other `from_pretrained` arguments, e.g. `attn_implementation`.
283
+ """
284
+ import torch
285
+ from transformers import AutoModelForImageTextToText, AutoProcessor, GenerationConfig
286
+
287
+ self._torch = torch
288
+ self.processor = AutoProcessor.from_pretrained(model_name)
289
+ self.model = AutoModelForImageTextToText.from_pretrained(
290
+ model_name, dtype=getattr(torch, dtype), device_map=device_map, **model_kwargs
291
+ ).eval()
292
+ # Neutral defaults: `_LogprobRecorder` applies the temperature and top-p itself.
293
+ self.model.generation_config = GenerationConfig(do_sample=True, temperature=1.0, top_p=1.0, top_k=0)
294
+ self._setup(max_images, seed, max_output_tokens, max_model_len, max_trace_tokens)
295
+
296
+ def _setup(
297
+ self, max_images: int, seed: int | None, max_output_tokens: int, max_model_len: int, max_trace_tokens: int
298
+ ) -> None:
299
+ """Settings shared by every engine."""
300
+ self.max_images = max_images
301
+ self.seed = seed
302
+ self.max_output_tokens = max_output_tokens
303
+ self.max_model_len = max_model_len
304
+ self.max_trace_tokens = max_trace_tokens
305
+
306
+ # -- engine --------------------------------------------------------------------
307
+
308
+ def _sample(self, prompts: list[Prompt], configs: list[SamplingConfig]) -> list[Completion]:
309
+ """Sample completions for each prompt. Other engines override this method."""
310
+ return [self._sample_one(prompt, config) for prompt, config in zip(prompts, configs)]
311
+
312
+ def _decode(self, token_ids: list[int]) -> str:
313
+ return self.processor.tokenizer.decode(token_ids, skip_special_tokens=False)
314
+
315
+ def _sample_one(self, prompt: Prompt, config: SamplingConfig) -> Completion:
316
+ torch = self._torch
317
+ from transformers import LogitsProcessorList
318
+
319
+ tokenizer = self.processor.tokenizer
320
+ inputs = self.processor(text=[prompt.render()], images=prompt.images or None, return_tensors="pt")
321
+ inputs = inputs.to(self.model.device)
322
+ prompt_length = inputs["input_ids"].shape[1]
323
+ stop_ids = [tokenizer.convert_tokens_to_ids(token) for token in config.stop]
324
+ stop_ids = [token_id for token_id in stop_ids if token_id is not None and token_id != tokenizer.unk_token_id]
325
+ recorder = _LogprobRecorder(config.temperature, config.top_p, stop_ids)
326
+ if config.seed is not None:
327
+ torch.manual_seed(config.seed)
328
+ with torch.inference_mode():
329
+ sequences = self.model.generate(
330
+ **inputs,
331
+ max_new_tokens=max(1, min(config.max_tokens, self.max_model_len - prompt_length)),
332
+ num_return_sequences=config.n,
333
+ eos_token_id=stop_ids,
334
+ pad_token_id=stop_ids[0],
335
+ logits_processor=LogitsProcessorList([recorder]),
336
+ )
337
+ samples = []
338
+ for row, sequence in enumerate(sequences[:, prompt_length:].tolist()):
339
+ end = next((i for i, token_id in enumerate(sequence) if token_id in stop_ids), len(sequence))
340
+ text = self._decode(sequence[:end])
341
+ samples.append(Sample(text, sequence[: end + 1], recorder.total(row, sequence)))
342
+ return Completion(samples, prompt_length)
343
+
344
+ # -- prompts -------------------------------------------------------------------
345
+
346
+ def build_prompt(
347
+ self,
348
+ task: SceneAnalyzerTask,
349
+ image: Image.Image | list[Image.Image] | None,
350
+ thinking_effort: ThinkingEffort | None = None,
351
+ text_input: str | None = None,
352
+ aspect_ratio: str | None = None,
353
+ detail_level: DetailLevel = DetailLevel.FULL,
354
+ scene_context: SceneDescription | str | None = None,
355
+ ) -> Prompt:
356
+ """The prompt for one input.
357
+
358
+ :param image: the image; None for text-only tasks; for <edit>, 1-5 input images.
359
+ :param thinking_effort: None for a direct answer, or the effort of a reasoning trace.
360
+ :param text_input: the task's text: the target (<describe>, <detect>, <mask>,
361
+ <keypoints>; None for all targets), the prompt (<expand>), the object
362
+ (<expand_object>), or the instruction followed by the caption JSON (<refine>,
363
+ <edit>).
364
+ :param aspect_ratio: "W:H" of the output image, for <expand> and <edit>. <edit>
365
+ without it keeps the input frame and takes exactly one image.
366
+ :param scene_context: for <expand_object>, the scene the object belongs to, without
367
+ the object, as a `SceneDescription` or its JSON. The prompt adds one sentence
368
+ that names the object to the scene's general text (`_name_in_scene_text`).
369
+ """
370
+ level = detail_level.value
371
+ if scene_context is not None and task != SceneAnalyzerTask.EXPAND_OBJECT:
372
+ raise ValueError("only <expand_object> takes a scene context")
373
+ images = [] if image is None else list(image) if isinstance(image, list) else [image]
374
+ if task in TEXT_ONLY_TASKS and images:
375
+ raise ValueError(f"<{task.value}> takes no image")
376
+ if task not in TEXT_ONLY_TASKS and not images:
377
+ raise ValueError(f"<{task.value}> requires an image")
378
+ if len(images) > 1 and task != SceneAnalyzerTask.EDIT:
379
+ raise ValueError(f"<{task.value}> takes one image")
380
+ if len(images) > self.max_images:
381
+ raise ValueError(f"at most {self.max_images} images per prompt")
382
+
383
+ if task == SceneAnalyzerTask.CAPTION:
384
+ text = f"<caption><{level}>"
385
+ elif task == SceneAnalyzerTask.DESCRIBE:
386
+ text = f"<describe>{text_input}"
387
+ elif task in NAMEABLE_TARGET_TASKS:
388
+ text = f"<{task.value}>{text_input or ''}"
389
+ elif task == SceneAnalyzerTask.OCR:
390
+ text = "<ocr>"
391
+ elif task == SceneAnalyzerTask.EXPAND:
392
+ text = f"<expand><{level}>\n<aspect_ratio>{_aspect_ratio_value(aspect_ratio)}</aspect_ratio>\n{text_input}"
393
+ elif task == SceneAnalyzerTask.EXPAND_OBJECT:
394
+ text = f"<expand_object><{level}>{text_input}"
395
+ if scene_context is not None:
396
+ if isinstance(scene_context, str):
397
+ scene_context = SceneDescription.model_validate_json(scene_context)
398
+ text += f"\n{prompt_json(_name_in_scene_text(scene_context, text_input))}"
399
+ elif task == SceneAnalyzerTask.REFINE:
400
+ text = f"<refine><{level}>{text_input}"
401
+ elif task == SceneAnalyzerTask.EDIT:
402
+ if aspect_ratio is None:
403
+ if len(images) != 1:
404
+ raise ValueError("<edit> without an aspect ratio takes exactly one image")
405
+ text = f"<edit><{level}>{text_input}"
406
+ else:
407
+ text = (
408
+ f"<edit><{level}>\n<aspect_ratio>{_aspect_ratio_value(aspect_ratio)}</aspect_ratio>\n{text_input}"
409
+ )
410
+ else:
411
+ raise ValueError(f"unsupported task {task}")
412
+ return Prompt(text, images, thinking_effort)
413
+
414
+ # -- generation ----------------------------------------------------------------
415
+
416
+ def generate(
417
+ self,
418
+ images: list[Image.Image | list[Image.Image] | None],
419
+ task: SceneAnalyzerTask,
420
+ thinking_effort: ThinkingEffort | None = None,
421
+ text_inputs: list[str | None] | None = None,
422
+ num_samples: int = 1,
423
+ retries: int = 3,
424
+ aspect_ratio: str | None = None,
425
+ detail_level: DetailLevel = DetailLevel.FULL,
426
+ num_iterations: int = 0,
427
+ scene_contexts: list[SceneDescription | str | None] | None = None,
428
+ ) -> list[SceneDescription | SceneObjectList | None]:
429
+ """Answer `task` for every input, one result (or None) per input.
430
+
431
+ :param images: one image per input (a list of images for <edit>, None for
432
+ text-only tasks).
433
+ :param thinking_effort: None for direct answers, or the effort of a reasoning
434
+ trace before each answer.
435
+ :param text_inputs: one text input per image (see `build_prompt`).
436
+ :param num_samples: candidates sampled per input; the best valid one is returned.
437
+ :param retries: extra attempts for inputs that produced no valid answer.
438
+ :param num_iterations: revision rounds after the first answer. Each round shows the
439
+ model its answer and adds the objects it reports as missing. An input stops
440
+ early when a round adds nothing. 0 is a single pass.
441
+ :param scene_contexts: for <expand_object>, one scene description per input, or
442
+ None: the rest of the scene, without the object. The model then describes the
443
+ object to fit the scene, with relationships to its objects.
444
+ :return: the answers, with coordinates and colors as `<coord_N.N>` / `<color_N>`
445
+ strings; `post_process()` on an answer converts them to numbers.
446
+ """
447
+ self._validate_arguments(task, text_inputs, detail_level, num_iterations, thinking_effort)
448
+ prompts = [
449
+ self.build_prompt(
450
+ task,
451
+ image,
452
+ thinking_effort,
453
+ self._text_input(text_inputs, i),
454
+ aspect_ratio,
455
+ detail_level,
456
+ self._text_input(scene_contexts, i),
457
+ )
458
+ for i, image in enumerate(images)
459
+ ]
460
+
461
+ results: list[Any] = [None] * len(prompts)
462
+ pending = list(range(len(prompts)))
463
+ for _ in range(1 + retries):
464
+ answers = self._select(task, [prompts[i] for i in pending], num_samples)
465
+ for i, answer in zip(pending, answers):
466
+ results[i] = answer
467
+ pending = [i for i in pending if results[i] is None]
468
+ if not pending:
469
+ break
470
+
471
+ active = [i for i, result in enumerate(results) if result is not None]
472
+ rounds_run = 0
473
+ while rounds_run < num_iterations and active:
474
+ rounds_run += 1
475
+ active = self._revise_round(results, active, prompts, task, detail_level, num_samples)
476
+ return results
477
+
478
+ @staticmethod
479
+ def _validate_arguments(
480
+ task: SceneAnalyzerTask,
481
+ text_inputs: list[str | None] | None,
482
+ detail_level: DetailLevel,
483
+ num_iterations: int,
484
+ thinking_effort: ThinkingEffort | None = None,
485
+ ) -> None:
486
+ """Reject argument combinations that give prompts the model was not trained on."""
487
+ if thinking_effort is not None:
488
+ if task not in THINKING_TASKS:
489
+ raise ValueError(f"<{task.value}> is trained without reasoning traces; use thinking_effort=None")
490
+ if thinking_effort not in DETAIL_LEVEL_EFFORTS[detail_level]:
491
+ raise ValueError(
492
+ f"The detail level <{detail_level.value}> is trained with the efforts "
493
+ f"{[effort.value for effort in DETAIL_LEVEL_EFFORTS[detail_level]]}"
494
+ )
495
+ if num_iterations < 0:
496
+ raise ValueError("num_iterations must not be negative")
497
+ if num_iterations > 0:
498
+ if task not in ITERATIVE_TASKS:
499
+ raise ValueError(
500
+ f"<{task.value}> does not support num_iterations; "
501
+ f"choose one of {sorted(item.value for item in ITERATIVE_TASKS)}"
502
+ )
503
+ if task == SceneAnalyzerTask.CAPTION and detail_level == DetailLevel.GENERAL:
504
+ raise ValueError("<caption><general> does not support num_iterations: it has no objects")
505
+ if task in NAMEABLE_TARGET_TASKS and any(text is not None for text in text_inputs or []):
506
+ raise ValueError(
507
+ f"<{task.value}> with a named target does not support num_iterations; "
508
+ "the completion form is trained only for the whole-list prompt"
509
+ )
510
+ allowed = TASK_DETAIL_LEVELS.get(task)
511
+ if allowed is not None and detail_level not in allowed:
512
+ raise ValueError(
513
+ f"<{task.value}> does not support the detail level <{detail_level.value}>; "
514
+ f"choose one of {sorted(level.value for level in allowed)}"
515
+ )
516
+
517
+ @staticmethod
518
+ def _text_input(text_inputs: list[str | None] | None, index: int) -> str | None:
519
+ if text_inputs is None or index >= len(text_inputs):
520
+ return None
521
+ return text_inputs[index]
522
+
523
+ def _select(self, task: SceneAnalyzerTask, prompts: list[Prompt], num_samples: int) -> list[Any]:
524
+ """Sample answers and select the best valid one per prompt.
525
+
526
+ A caption is scored by its object count, which gives the richest answer. That
527
+ also prefers repetition loops, so loops are rejected first. Grounding answers are
528
+ scored by confidence; other object lists by object count.
529
+ """
530
+ describes_image = task in SCENE_DESCRIPTION_TASKS
531
+ answer_type = SceneDescription if describes_image else SceneObjectList
532
+ results = []
533
+ for samples in self._generate_answers(prompts, answer_type.model_json_schema(), num_samples):
534
+ best, best_score = None, float("-inf")
535
+ for sample in samples:
536
+ try:
537
+ answer = answer_type.model_validate_json(_answer_text(sample.text))
538
+ if self.has_text_loop(answer):
539
+ continue
540
+ if describes_image:
541
+ if not self.validate_duplicate_bboxes(answer) or self.has_name_loop(answer):
542
+ continue
543
+ score = len(list(answer.iter_all_objects()))
544
+ elif task in GROUNDING_TASKS:
545
+ score = _confidence(sample)
546
+ else:
547
+ score = len(answer.objects or [])
548
+ except Exception as error:
549
+ logger.debug("rejected candidate: %s", error)
550
+ continue
551
+ if score > best_score:
552
+ best, best_score = answer, score
553
+ results.append(best)
554
+ return results
555
+
556
+ def _generate_answers(self, prompts: list[Prompt], json_schema: dict, num_samples: int) -> list[list[Sample]]:
557
+ """Sample `num_samples` answers per prompt.
558
+
559
+ A thinking prompt is generated in two passes. The trace is sampled without a
560
+ schema and stops at `</think>`. The answer is then sampled from the prompt plus
561
+ the trace plus `</think>\\n\\n`, under the schema. A schema in the same pass as the
562
+ trace makes the trained newlines after `</think>` unreachable.
563
+ """
564
+ answer_config = SamplingConfig(max_tokens=self.max_output_tokens, json_schema=json_schema, seed=self.seed)
565
+ needs_trace = [i for i, prompt in enumerate(prompts) if not prompt.answers_directly]
566
+ traces: dict[int, list[str]] = {}
567
+ if needs_trace:
568
+ trace_config = SamplingConfig(
569
+ n=num_samples, max_tokens=self.max_trace_tokens, stop=(*STOP_TOKENS, THINK_END), seed=self.seed
570
+ )
571
+ completions = self._sample([prompts[i] for i in needs_trace], [trace_config] * len(needs_trace))
572
+ for i, completion in zip(needs_trace, completions):
573
+ budget = self.max_model_len - completion.prompt_tokens - ANSWER_RESERVE_TOKENS
574
+ traces[i] = [self._trace_text(sample, budget) for sample in completion.samples]
575
+
576
+ # One answer request per trace. Direct prompts draw all samples from one request.
577
+ requests: list[Prompt] = []
578
+ configs: list[SamplingConfig] = []
579
+ owners: list[int] = []
580
+ for i, prompt in enumerate(prompts):
581
+ if i in traces:
582
+ for trace in traces[i]:
583
+ requests.append(replace(prompt, continuation=prompt.continuation + trace + THINK_CLOSE))
584
+ configs.append(answer_config)
585
+ owners.append(i)
586
+ else:
587
+ requests.append(prompt)
588
+ configs.append(replace(answer_config, n=num_samples))
589
+ owners.append(i)
590
+
591
+ answers: list[list[Sample]] = [[] for _ in prompts]
592
+ for owner, completion in zip(owners, self._sample(requests, configs)):
593
+ answers[owner].extend(completion.samples)
594
+ return answers
595
+
596
+ def _trace_text(self, sample: Sample, budget: int) -> str:
597
+ """The reasoning trace, cut to `budget` tokens if it is longer.
598
+
599
+ A cut trace is still usable: the answer pass closes the think block regardless.
600
+ """
601
+ budget = max(budget, 0)
602
+ text = self._decode(sample.token_ids[:budget]) if len(sample.token_ids) > budget else sample.text
603
+ for token in STOP_TOKENS:
604
+ text = text.removesuffix(token)
605
+ return text.split(THINK_END)[0]
606
+
607
+ # -- answer checks -------------------------------------------------------------
608
+
609
+ @staticmethod
610
+ def _bbox_iou(a: BoundingBox, b: BoundingBox) -> float:
611
+ a, b = a.model_copy(deep=True), b.model_copy(deep=True)
612
+ a.post_process()
613
+ b.post_process()
614
+
615
+ def area(box: BoundingBox) -> float:
616
+ return max(0.0, box.bottom_right.x - box.top_left.x) * max(0.0, box.bottom_right.y - box.top_left.y)
617
+
618
+ width = max(0.0, min(a.bottom_right.x, b.bottom_right.x) - max(a.top_left.x, b.top_left.x))
619
+ height = max(0.0, min(a.bottom_right.y, b.bottom_right.y) - max(a.top_left.y, b.top_left.y))
620
+ intersection = width * height
621
+ union = area(a) + area(b) - intersection
622
+ return intersection / union if union > 0 else 0.0
623
+
624
+ @staticmethod
625
+ def has_text_loop(answer: SceneDescription | SceneObjectList) -> bool:
626
+ """Whether one text line (3+ characters) repeats TEXT_LOOP_REPEATS times or more."""
627
+ counts: dict[str, int] = {}
628
+
629
+ def walk(node: Any) -> None:
630
+ if isinstance(node, dict):
631
+ for line in node.get("lines") or []:
632
+ text = (line.get("text") or "").strip() if isinstance(line, dict) else ""
633
+ if len(text) >= 3:
634
+ counts[text] = counts.get(text, 0) + 1
635
+ for value in node.values():
636
+ walk(value)
637
+ elif isinstance(node, list):
638
+ for value in node:
639
+ walk(value)
640
+
641
+ # Text can nest under any object, so walk the whole dumped answer.
642
+ walk(answer.model_dump())
643
+ return max(counts.values(), default=0) >= TEXT_LOOP_REPEATS
644
+
645
+ @staticmethod
646
+ def has_name_loop(image_description: SceneDescription) -> bool:
647
+ """Whether one object name (or description, when unnamed) repeats
648
+ NAME_LOOP_REPEATS times or more."""
649
+ counts: dict[str, int] = {}
650
+ for object in image_description.iter_all_objects():
651
+ name = ((object.name or "").strip() or (object.description or "").strip()).lower()
652
+ if name:
653
+ counts[name] = counts.get(name, 0) + 1
654
+ return max(counts.values(), default=0) >= NAME_LOOP_REPEATS
655
+
656
+ def validate_duplicate_bboxes(self, image_description: SceneDescription) -> bool:
657
+ """False when two objects with the same name have the same box."""
658
+ seen: list[tuple[BoundingBox, str]] = []
659
+ for object in image_description.iter_all_objects():
660
+ for bounding_box in object.bounding_boxes or []:
661
+ for other, other_name in seen:
662
+ if object.name == other_name and self._bbox_iou(bounding_box, other) > DUPLICATE_BOX_IOU:
663
+ return False
664
+ seen.append((bounding_box, object.name))
665
+ return True
666
+
667
+ # -- revision rounds -----------------------------------------------------------
668
+
669
+ def _revise_round(
670
+ self,
671
+ results: list[Any],
672
+ active: list[int],
673
+ prompts: list[Prompt],
674
+ task: SceneAnalyzerTask,
675
+ detail_level: DetailLevel,
676
+ num_samples: int,
677
+ ) -> list[int]:
678
+ """One revision round over `active`, merging new objects into `results` in place.
679
+
680
+ Returns the indices that gained objects. An empty answer means the model considers
681
+ the answer complete, so that index stops.
682
+ """
683
+ tag = f"<caption><{detail_level.value}>" if task == SceneAnalyzerTask.CAPTION else f"<{task.value}>"
684
+ requests = [
685
+ Prompt(f"{tag}\n{self._source_text(task, results[i])}", prompts[i].images, prompts[i].thinking_effort)
686
+ for i in active
687
+ ]
688
+ schema = SceneObjectList.model_json_schema()
689
+ gained = []
690
+ for i, samples in zip(active, self._generate_answers(requests, schema, num_samples)):
691
+ # The best completion is the one that adds the most after the merge rules.
692
+ best = None
693
+ for sample in samples:
694
+ try:
695
+ candidate = SceneObjectList.model_validate_json(_answer_text(sample.text))
696
+ except Exception:
697
+ continue
698
+ plan = self._plan_merge(results[i], candidate.objects or [])
699
+ if plan[2] > (best[2] if best else 0):
700
+ best = plan
701
+ if best is not None and self._apply_merge(results[i], best):
702
+ gained.append(i)
703
+ logger.info("[revise] <%s>: asked %d, gained %d", task.value, len(active), len(gained))
704
+ return gained
705
+
706
+ @staticmethod
707
+ def _source_text(task: SceneAnalyzerTask, result: Any) -> str:
708
+ """An answer serialized as the source list of a completion prompt.
709
+
710
+ The source must have the same form as in training, so the task's own view is
711
+ applied to a copy of each object. A caption is serialized as answered: it is
712
+ already in the view of its detail level.
713
+ """
714
+ if task == SceneAnalyzerTask.CAPTION:
715
+ return result.model_dump_json(exclude_none=True, exclude_unset=True)
716
+ objects = [object.model_copy(deep=True) for object in (result.objects or [])]
717
+ for object in objects:
718
+ if task == SceneAnalyzerTask.DETECT:
719
+ object.make_for_detect()
720
+ elif task == SceneAnalyzerTask.MASK:
721
+ object.make_for_mask()
722
+ elif task == SceneAnalyzerTask.KEYPOINTS:
723
+ object.make_for_keypoints()
724
+ else:
725
+ # <ocr> keeps the text objects without their masks.
726
+ object.drop_masks()
727
+ for line in getattr(object, "lines", None) or []:
728
+ line.drop_masks()
729
+ # model_construct, because validation would restore the fields the view cleared.
730
+ return SceneObjectList.model_construct(objects=objects).model_dump_json(exclude_none=True, exclude_unset=True)
731
+
732
+ @staticmethod
733
+ def _all_objects(result: Any) -> list[Any]:
734
+ """Every object in a caption or an object list."""
735
+ walk = getattr(result, "iter_all_objects", None)
736
+ return list(walk()) if walk is not None else list(result.objects or [])
737
+
738
+ def _plan_merge(self, result: Any, new_objects: list[Any]) -> tuple[list, list, int]:
739
+ """What of `new_objects` would be added to `result`, without changing it.
740
+
741
+ Returns `(new_roots, grafts, total)`. An object whose name matches a root is a
742
+ stub: its nested items are grafted onto that root. The rules:
743
+
744
+ * No addition may reuse an existing box (IoU above DUPLICATE_BOX_IOU), whatever
745
+ its name.
746
+ * A nested item is refused when its parent already lists that name. Nested items
747
+ often have no box, so the box rule alone does not stop repeats.
748
+ * An unnamed object is refused, except a root Text, which has no name.
749
+ """
750
+ by_name = {o.name: o for o in (result.objects or []) if o.name}
751
+ existing_boxes = [box for o in self._all_objects(result) for box in (o.bounding_boxes or [])]
752
+ claimed: list[BoundingBox] = []
753
+
754
+ def box_taken(object: Any) -> bool:
755
+ return any(
756
+ self._bbox_iou(box, other) > DUPLICATE_BOX_IOU
757
+ for box in object.bounding_boxes or []
758
+ for other in existing_boxes + claimed
759
+ )
760
+
761
+ new_roots: list[Any] = []
762
+ grafts: list[tuple] = []
763
+ for object in new_objects:
764
+ parent = by_name.get(object.name) if object.name else None
765
+ if parent is not None:
766
+ for field_name in NESTED_MERGE_FIELDS:
767
+ taken = {item.name for item in (getattr(parent, field_name, None) or []) if item.name}
768
+ keep = []
769
+ for item in getattr(object, field_name, None) or []:
770
+ if not item.name or item.name in taken or box_taken(item):
771
+ continue
772
+ taken.add(item.name)
773
+ claimed.extend(item.bounding_boxes or [])
774
+ keep.append(item)
775
+ if keep:
776
+ grafts.append((parent, field_name, keep))
777
+ continue
778
+ if not object.name and object.kind != "text":
779
+ continue
780
+ if box_taken(object):
781
+ continue
782
+ claimed.extend(object.bounding_boxes or [])
783
+ new_roots.append(object)
784
+ return new_roots, grafts, len(new_roots) + sum(len(items) for _, _, items in grafts)
785
+
786
+ @staticmethod
787
+ def _apply_merge(result: Any, plan: tuple[list, list, int]) -> int:
788
+ """Apply a plan from `_plan_merge` and return how many objects it added."""
789
+ new_roots, grafts, total = plan
790
+ for parent, field_name, items in grafts:
791
+ setattr(parent, field_name, list(getattr(parent, field_name, None) or []) + items)
792
+ if new_roots:
793
+ result.objects = list(result.objects or []) + new_roots
794
+ return total
795
+
796
+
797
+ class _LogprobRecorder:
798
+ """A logits processor that applies the temperature and top-p, and sums the raw log
799
+ probability of each sampled token per sequence.
800
+
801
+ Generation calls it once per step, with the tokens sampled so far, so each call adds
802
+ the previous step's token. `total()` adds the last token.
803
+ """
804
+
805
+ def __init__(self, temperature: float, top_p: float, stop_ids: list[int]):
806
+ self.temperature = temperature
807
+ self.top_p = top_p
808
+ self.stop_ids = sorted(set(stop_ids))
809
+ self.sums = None
810
+ self.previous = None
811
+ self.finished = None
812
+
813
+ def __call__(self, input_ids, scores):
814
+ import torch
815
+
816
+ scores = scores.float()
817
+ logprobs = torch.log_softmax(scores, dim=-1)
818
+ if self.previous is None:
819
+ self.sums = torch.zeros(scores.shape[0], device=scores.device)
820
+ self.finished = torch.zeros(scores.shape[0], dtype=torch.bool, device=scores.device)
821
+ else:
822
+ last = input_ids[:, -1]
823
+ chosen = self.previous.gather(1, last[:, None]).squeeze(1)
824
+ self.sums += torch.where(self.finished, torch.zeros_like(chosen), chosen)
825
+ self.finished |= torch.isin(last, torch.tensor(self.stop_ids, device=last.device))
826
+ self.previous = logprobs
827
+
828
+ warped = scores / max(self.temperature, 1e-5)
829
+ if self.top_p < 1.0:
830
+ sorted_scores, order = torch.sort(warped, descending=True, dim=-1)
831
+ probabilities = torch.softmax(sorted_scores, dim=-1)
832
+ # Keep the smallest set of top tokens whose probability reaches top_p.
833
+ remove_sorted = probabilities.cumsum(dim=-1) - probabilities > self.top_p
834
+ remove = torch.zeros_like(remove_sorted).scatter(1, order, remove_sorted)
835
+ warped = warped.masked_fill(remove, float("-inf"))
836
+ return warped
837
+
838
+ def total(self, row: int, sequence: list[int]) -> float:
839
+ """The summed log probability of the completion in `row`."""
840
+ if self.sums is None:
841
+ return 0.0
842
+ total = float(self.sums[row])
843
+ if sequence and not bool(self.finished[row]):
844
+ total += float(self.previous[row, sequence[-1]])
845
+ return total
scene_analyzer_server.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """`SceneAnalyzerModel` for an OpenAI-compatible server.
2
+
3
+ Same interface as `scene_analyzer.SceneAnalyzerModel`; only the engine differs:
4
+
5
+ from scene_analyzer_server import SceneAnalyzerModel
6
+ model = SceneAnalyzerModel("http://localhost:8000/v1")
7
+
8
+ The client sends standard chat-completion requests. The checkpoint's chat template
9
+ renders the trained prompt, and two request fields outside the OpenAI schema are needed
10
+ for full function (vLLM and SGLang support both):
11
+
12
+ * `chat_template_kwargs.thinking_effort` selects a reasoning trace. A server that ignores
13
+ it gives direct answers. `enable_thinking` is also sent, for templates that know only it.
14
+ * `skip_special_tokens: false`. The JSON structure and the coordinate and color values
15
+ are special tokens, which servers remove from the output by default.
16
+
17
+ For example, with vLLM:
18
+
19
+ vllm serve briaai/fibo-scene-analyzer --max-model-len 32768 --limit-mm-per-prompt '{"image": 5}' \\
20
+ --reasoning-parser deepseek_r1
21
+
22
+ Unlike the in-process harnesses, a thinking request is one pass without a schema: the
23
+ server cannot continue an assistant turn in a standard request.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import base64
30
+ import concurrent.futures
31
+ import inspect
32
+ import io
33
+ import logging
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from PIL import Image
38
+
39
+ import scene_analyzer as _base
40
+ from scene_analyzer import (
41
+ Completion,
42
+ DetailLevel,
43
+ SceneAnalyzerTask,
44
+ Prompt,
45
+ Sample,
46
+ SamplingConfig,
47
+ ThinkingEffort,
48
+ )
49
+
50
+ __all__ = ["DetailLevel", "SceneAnalyzerModel", "SceneAnalyzerTask", "ThinkingEffort"]
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ #: The chat template of the checkpoint, for servers that serve an older copy of it.
55
+ CHAT_TEMPLATE_PATH = Path(__file__).with_name("chat_template.jinja")
56
+
57
+
58
+ class SceneAnalyzerModel(_base.SceneAnalyzerModel):
59
+ """Scene Analyzer on an OpenAI-compatible server."""
60
+
61
+ def __init__(
62
+ self,
63
+ base_url: str,
64
+ model: str | None = None,
65
+ api_key: str = "EMPTY",
66
+ max_images: int = 5,
67
+ seed: int | None = None,
68
+ max_output_tokens: int = 32768,
69
+ max_model_len: int = 32768,
70
+ request_timeout: float = 3600.0,
71
+ max_concurrency: int = 64,
72
+ send_chat_template: bool = False,
73
+ ):
74
+ """
75
+ :param base_url: the server's OpenAI root, e.g. ``http://localhost:8000/v1``.
76
+ :param model: served model name. Defaults to the server's first model.
77
+ :param max_images: cap on images per prompt. Must not exceed the server's limit.
78
+ :param max_output_tokens: completion cap. A cap that does not fit beside the prompt
79
+ is not sent, and the server then uses the rest of its context.
80
+ :param max_model_len: the server's context length.
81
+ :param max_concurrency: inputs in flight.
82
+ :param send_chat_template: send `chat_template.jinja` with each request, for a
83
+ server whose checkpoint has another template. vLLM needs
84
+ ``--trust-request-chat-template`` for this.
85
+
86
+ Engine settings (GPU memory, batch size, speculative decoding) are set on the
87
+ server command line.
88
+ """
89
+ from openai import OpenAI
90
+
91
+ self.base_url = base_url.rstrip("/")
92
+ self.api_key = api_key
93
+ self.request_timeout = request_timeout
94
+ self.max_concurrency = max(1, max_concurrency)
95
+ self.chat_template = CHAT_TEMPLATE_PATH.read_text() if send_chat_template else None
96
+ self.served_model = model or OpenAI(base_url=self.base_url, api_key=api_key).models.list().data[0].id
97
+ self._setup(max_images, seed, max_output_tokens, max_model_len, max_trace_tokens=0)
98
+
99
+ # -- engine --------------------------------------------------------------------
100
+
101
+ def _generate_answers(self, prompts: list[Prompt], json_schema: dict, num_samples: int) -> list[list[Sample]]:
102
+ """One pass per prompt. A thinking prompt is sampled without the schema, and the
103
+ answer is the text after the trace."""
104
+ configs = [
105
+ SamplingConfig(
106
+ n=num_samples,
107
+ max_tokens=self.max_output_tokens,
108
+ json_schema=json_schema if prompt.answers_directly else None,
109
+ seed=self.seed,
110
+ )
111
+ for prompt in prompts
112
+ ]
113
+ return [completion.samples for completion in self._sample(prompts, configs)]
114
+
115
+ def _sample(self, prompts: list[Prompt], configs: list[SamplingConfig]) -> list[Completion]:
116
+ return _run_coroutine(self._sample_async(prompts, configs))
117
+
118
+ async def _sample_async(self, prompts: list[Prompt], configs: list[SamplingConfig]) -> list[Completion]:
119
+ from openai import AsyncOpenAI
120
+
121
+ semaphore = asyncio.Semaphore(self.max_concurrency)
122
+ # A new client per call, because a client binds to the event loop it first runs on.
123
+ async with AsyncOpenAI(base_url=self.base_url, api_key=self.api_key, max_retries=3) as client:
124
+
125
+ async def one(prompt: Prompt, config: SamplingConfig) -> Completion:
126
+ async with semaphore:
127
+ # Built inside the semaphore, so only the requests in flight hold
128
+ # encoded images in memory.
129
+ request = self._request(prompt, config)
130
+ response = await client.chat.completions.create(timeout=self.request_timeout, **request)
131
+ return self._completion(response)
132
+
133
+ return list(await asyncio.gather(*(one(prompt, config) for prompt, config in zip(prompts, configs))))
134
+
135
+ def _request(self, prompt: Prompt, config: SamplingConfig) -> dict[str, Any]:
136
+ if prompt.continuation:
137
+ raise ValueError("an OpenAI-compatible request cannot continue an assistant turn")
138
+ content: list[dict[str, Any]] = [
139
+ {"type": "image_url", "image_url": {"url": _data_url(image)}} for image in prompt.images
140
+ ]
141
+ content.append({"type": "text", "text": prompt.text})
142
+ # `enable_thinking` is for templates without `thinking_effort`, such as the stock one.
143
+ template_kwargs: dict[str, Any] = {"enable_thinking": prompt.thinking_effort is not None}
144
+ if prompt.thinking_effort is not None:
145
+ template_kwargs["thinking_effort"] = prompt.thinking_effort.value
146
+ extra_body: dict[str, Any] = {"skip_special_tokens": False, "chat_template_kwargs": template_kwargs}
147
+ if self.chat_template is not None:
148
+ extra_body["chat_template"] = self.chat_template
149
+ request: dict[str, Any] = {
150
+ "model": self.served_model,
151
+ "messages": [{"role": "user", "content": content}],
152
+ "n": config.n,
153
+ "temperature": config.temperature,
154
+ "top_p": config.top_p,
155
+ "stop": list(config.stop),
156
+ "logprobs": True,
157
+ "extra_body": extra_body,
158
+ }
159
+ if config.max_tokens < self.max_model_len:
160
+ request["max_tokens"] = config.max_tokens
161
+ if config.seed is not None:
162
+ request["seed"] = config.seed
163
+ if config.json_schema is not None:
164
+ request["response_format"] = {
165
+ "type": "json_schema",
166
+ "json_schema": {"name": "answer", "schema": config.json_schema},
167
+ }
168
+ return request
169
+
170
+ @staticmethod
171
+ def _completion(response: Any) -> Completion:
172
+ samples = []
173
+ for choice in response.choices:
174
+ entries = (choice.logprobs.content or []) if choice.logprobs else []
175
+ samples.append(
176
+ Sample(
177
+ text=choice.message.content or "",
178
+ # The ids are used only for their count.
179
+ token_ids=[0] * len(entries),
180
+ cumulative_logprob=sum(entry.logprob for entry in entries),
181
+ )
182
+ )
183
+ usage = getattr(response, "usage", None)
184
+ return Completion(samples, getattr(usage, "prompt_tokens", 0) or 0)
185
+
186
+ # -- generation ----------------------------------------------------------------
187
+
188
+ def generate(self, images, task, *args, on_result=None, **kwargs):
189
+ """The inherited `generate`, run separately per input with `max_concurrency`
190
+ inputs in flight.
191
+
192
+ Generation lengths vary much between inputs, and one batched call waits for its
193
+ longest input. Per-input calls keep the server busy.
194
+
195
+ `images` and `text_inputs` can be lists or iterators. Inputs are read only when a
196
+ slot is free, so a lazy iterator keeps at most `max_concurrency` images in memory.
197
+
198
+ :param on_result: optional `on_result(index, result)`, called on the caller's
199
+ thread as each input finishes, in completion order.
200
+ :return: the results in input order. Argument errors (ValueError, TypeError) are
201
+ raised; other failures give None for that input only.
202
+ """
203
+ bound = inspect.signature(_base.SceneAnalyzerModel.generate).bind(self, images, task, *args, **kwargs)
204
+ arguments = dict(bound.arguments)
205
+ arguments.pop("self")
206
+ inputs = arguments.pop("images")
207
+ text_inputs = arguments.pop("text_inputs", None)
208
+ scene_contexts = iter(arguments.pop("scene_contexts", None) or ())
209
+ pairs = zip(inputs, text_inputs) if text_inputs is not None else ((image, None) for image in inputs)
210
+ work = ((image, text_input, next(scene_contexts, None)) for image, text_input in pairs)
211
+
212
+ def one(image, text_input, scene_context):
213
+ try:
214
+ return super(SceneAnalyzerModel, self).generate(
215
+ [image],
216
+ text_inputs=None if text_input is None else [text_input],
217
+ scene_contexts=None if scene_context is None else [scene_context],
218
+ **arguments,
219
+ )[0]
220
+ except (ValueError, TypeError):
221
+ raise
222
+ except Exception as error: # noqa: BLE001 - one failed input must not fail the others
223
+ logger.warning("generate: input failed: %r", error)
224
+ return None
225
+
226
+ results: dict[int, Any] = {}
227
+ pending: dict[concurrent.futures.Future, int] = {}
228
+
229
+ def collect(futures) -> None:
230
+ for future in futures:
231
+ index = pending.pop(future)
232
+ results[index] = future.result()
233
+ if on_result is not None:
234
+ on_result(index, results[index])
235
+
236
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_concurrency) as pool:
237
+ for index, (image, text_input, scene_context) in enumerate(work):
238
+ if len(pending) >= self.max_concurrency:
239
+ done, _ = concurrent.futures.wait(pending, return_when=concurrent.futures.FIRST_COMPLETED)
240
+ collect(done)
241
+ pending[pool.submit(one, image, text_input, scene_context)] = index
242
+ # Report finished inputs now, not only when the window is full.
243
+ collect([future for future in pending if future.done()])
244
+ collect(concurrent.futures.as_completed(list(pending)))
245
+ return [results[index] for index in range(len(results))]
246
+
247
+
248
+ def _data_url(image: Image.Image) -> str:
249
+ # PNG, because a lossy format would change the pixels the model describes.
250
+ buffer = io.BytesIO()
251
+ image.convert("RGB").save(buffer, format="PNG")
252
+ return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
253
+
254
+
255
+ def _run_coroutine(coroutine):
256
+ """Run `coroutine` to completion, also when the caller is already in an event loop."""
257
+ try:
258
+ asyncio.get_running_loop()
259
+ except RuntimeError:
260
+ return asyncio.run(coroutine)
261
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
262
+ return pool.submit(asyncio.run, coroutine).result()
scene_analyzer_vllm.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """`SceneAnalyzerModel` on an in-process vLLM engine, with schema-constrained decoding.
2
+
3
+ Same interface as `scene_analyzer.SceneAnalyzerModel`; only the engine differs:
4
+
5
+ from scene_analyzer_vllm import SceneAnalyzerModel
6
+ model = SceneAnalyzerModel("briaai/fibo-scene-analyzer", gpu_memory_utilization=0.9)
7
+
8
+ All inputs of one `generate` call run as one batch.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from typing import Any
15
+
16
+ from vllm import LLM, SamplingParams
17
+ from vllm.config import StructuredOutputsConfig
18
+ from vllm.sampling_params import StructuredOutputsParams
19
+
20
+ import scene_analyzer as _base
21
+ from scene_analyzer import (
22
+ Completion,
23
+ DetailLevel,
24
+ SceneAnalyzerTask,
25
+ Prompt,
26
+ Sample,
27
+ SamplingConfig,
28
+ ThinkingEffort,
29
+ )
30
+
31
+ __all__ = ["DetailLevel", "SceneAnalyzerModel", "SceneAnalyzerTask", "ThinkingEffort"]
32
+
33
+ # Pixel budget per image: 8192 visual tokens of 32x32 px (patch 16, merge 2), the same as
34
+ # the processor config of the checkpoint.
35
+ MAX_PIXELS = 8192 * 1024
36
+ MIN_PIXELS = 4 * 28 * 28
37
+
38
+
39
+ class SceneAnalyzerModel(_base.SceneAnalyzerModel):
40
+ """Scene Analyzer on vLLM."""
41
+
42
+ def __init__(
43
+ self,
44
+ model_name: str,
45
+ gpu_memory_utilization: float = 0.9,
46
+ max_images: int = 5,
47
+ seed: int | None = None,
48
+ max_output_tokens: int = 32768,
49
+ max_model_len: int = 32768,
50
+ max_num_seqs: int = 256,
51
+ max_trace_tokens: int = 16384,
52
+ speculative_model: str | None = None,
53
+ num_speculative_tokens: int | None = None,
54
+ **engine_kwargs: Any,
55
+ ):
56
+ """
57
+ :param model_name: path or HF id of the checkpoint.
58
+ :param max_num_seqs: concurrent sequences. The hybrid attention layers keep a
59
+ recurrent state per sequence, and vLLM fails at startup when fewer state
60
+ blocks fit on the GPU than this value. 256 fits on an 80 GB GPU.
61
+ :param speculative_model: path or HF id of a draft model for speculative decoding.
62
+ :param num_speculative_tokens: draft tokens per step, with ``speculative_model``.
63
+ :param engine_kwargs: other `vllm.LLM` arguments, e.g. ``tensor_parallel_size``.
64
+
65
+ The other parameters are as in `scene_analyzer.SceneAnalyzerModel`.
66
+ """
67
+ if speculative_model is not None:
68
+ speculative_config: dict[str, Any] = {"model": speculative_model}
69
+ if num_speculative_tokens is not None:
70
+ speculative_config["num_speculative_tokens"] = num_speculative_tokens
71
+ engine_kwargs["speculative_config"] = speculative_config
72
+ self.llm = LLM(
73
+ model=model_name,
74
+ dtype="bfloat16",
75
+ mm_processor_kwargs={"max_pixels": MAX_PIXELS, "min_pixels": MIN_PIXELS},
76
+ max_model_len=max_model_len,
77
+ max_num_seqs=max_num_seqs,
78
+ gpu_memory_utilization=gpu_memory_utilization,
79
+ limit_mm_per_prompt={"image": max_images},
80
+ # Answer prompts always end with a closed think block, so the reasoning parser
81
+ # reports the reasoning as ended and the schema applies from the first token.
82
+ structured_outputs_config=StructuredOutputsConfig(
83
+ reasoning_parser="deepseek_r1", enable_in_reasoning=False
84
+ ),
85
+ **engine_kwargs,
86
+ )
87
+ self.tokenizer = self.llm.get_tokenizer()
88
+ self._setup(max_images, seed, max_output_tokens, max_model_len, max_trace_tokens)
89
+
90
+ def _decode(self, token_ids: list[int]) -> str:
91
+ return self.tokenizer.decode(token_ids, skip_special_tokens=False)
92
+
93
+ def _sample(self, prompts: list[Prompt], configs: list[SamplingConfig]) -> list[Completion]:
94
+ inputs = [self._engine_input(prompt) for prompt in prompts]
95
+ params = [self._sampling_params(config) for config in configs]
96
+ outputs = self.llm.generate(inputs, sampling_params=params, use_tqdm=False)
97
+ completions = []
98
+ for output in outputs:
99
+ samples = []
100
+ for sample in output.outputs:
101
+ samples.append(Sample(sample.text, list(sample.token_ids), sample.cumulative_logprob or 0.0))
102
+ completions.append(Completion(samples, len(output.prompt_token_ids or [])))
103
+ return completions
104
+
105
+ @staticmethod
106
+ def _engine_input(prompt: Prompt) -> dict[str, Any] | str:
107
+ if not prompt.images:
108
+ return prompt.render()
109
+ return {
110
+ "prompt": prompt.render(),
111
+ "multi_modal_data": {"image": list(prompt.images)},
112
+ # A fresh uuid per image, so the engine's cache never matches another image.
113
+ "multi_modal_uuids": {"image": [str(uuid.uuid4()) for _ in prompt.images]},
114
+ }
115
+
116
+ @staticmethod
117
+ def _sampling_params(config: SamplingConfig) -> SamplingParams:
118
+ return SamplingParams(
119
+ n=config.n,
120
+ temperature=config.temperature,
121
+ top_p=config.top_p,
122
+ max_tokens=config.max_tokens,
123
+ stop=list(config.stop),
124
+ # The JSON structure and the coordinate and color values are special tokens.
125
+ skip_special_tokens=False,
126
+ # Needed for cumulative_logprob.
127
+ logprobs=1,
128
+ seed=config.seed,
129
+ structured_outputs=StructuredOutputsParams(json=config.json_schema) if config.json_schema else None,
130
+ )