Image-Text-to-Text
Transformers
Safetensors
qwen3_5_moe
image-captioning
structured-output
json
object-detection
image-segmentation
keypoint-detection
ocr
vision-language
conversational
Instructions to use briaai/fibo-scene-analyzer with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use briaai/fibo-scene-analyzer with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="briaai/fibo-scene-analyzer") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] pipe(text=messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("briaai/fibo-scene-analyzer") model = AutoModelForMultimodalLM.from_pretrained("briaai/fibo-scene-analyzer", device_map="auto") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(processor.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use briaai/fibo-scene-analyzer with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "briaai/fibo-scene-analyzer" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "briaai/fibo-scene-analyzer", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker
docker model run hf.co/briaai/fibo-scene-analyzer
- SGLang
How to use briaai/fibo-scene-analyzer with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "briaai/fibo-scene-analyzer" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "briaai/fibo-scene-analyzer", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "briaai/fibo-scene-analyzer" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "briaai/fibo-scene-analyzer", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }' - Docker Model Runner
How to use briaai/fibo-scene-analyzer with Docker Model Runner:
docker model run hf.co/briaai/fibo-scene-analyzer
Add the harness code: Transformers, vLLM and server clients, the schema and the visualizer
Browse files- caption_visualizer.py +574 -0
- data_model.py +640 -0
- scene_analyzer.py +845 -0
- scene_analyzer_server.py +262 -0
- scene_analyzer_vllm.py +130 -0
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 |
+
)
|