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

from openai import OpenAI


ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)

from config import api_key, base_url


SCORE_FIELDS = [
    "chart_readability",
    "data_encoding_clarity",
    "label_legibility",
    "visual_aesthetics",
    "layout_integrity",
    "rendering_correctness",
    "variation_quality",
    "overall",
]

DEFAULT_PROMPT = """
You are evaluating chart template implementation quality.

You will receive one generated infographic image. The image may include an infographic title, subtitle, decorative background, theme illustration, icons, or non-chart imagery.

Important: ignore everything except the chart itself.
- Ignore the infographic title and subtitle.
- Ignore theme images, illustrations, decorative icons, and non-chart imagery.
- Ignore the overall page layout outside the chart.
- Ignore whether the underlying real-world data facts are true.
- First identify the chart region, then score only that chart region.

Score each field from 1 to 10. Use the full scale. A score of 10 means excellent, 1 means unusable.

Scoring fields:
- chart_readability: whether the chart body is easy to understand. Consider marks, axes, ticks, legends, series separation, and data-value relationships.
- data_encoding_clarity: whether the chart type clearly expresses the data relationship. Consider temporal order, category order, proportions, grouping, stacking, multi-series structure, and whether the chosen visual encoding is understandable.
- label_legibility: whether chart-internal text is readable. Only evaluate axis labels, tick labels, legends, and data labels inside the chart. Do not evaluate infographic title/subtitle.
- visual_aesthetics: whether the chart itself looks visually pleasing. Consider colors, shape/line style, spacing, density, and visual hierarchy inside the chart.
- layout_integrity: whether the chart-internal layout is intact. Penalize overlap, clipping, misalignment, cramped legends, label collisions, and chart elements overflowing the chart area.
- rendering_correctness: whether there are obvious implementation or rendering bugs. Penalize empty charts, fallback screenshots, missing marks, broken shapes, abnormal blocks, missing axes, or obviously wrong scaling.
- variation_quality: whether this chart template variation appears stable, reusable, and distinctive as an implementation, independent of the specific topic.
- overall: weighted overall chart-only quality. Suggested weighting: chart_readability 25%, data_encoding_clarity 20%, layout_integrity 20%, rendering_correctness 15%, label_legibility 10%, visual_aesthetics 10%. Use variation_quality as an auxiliary judgment.

Return only valid JSON with this schema:
{
  "chart_readability": 1,
  "data_encoding_clarity": 1,
  "label_legibility": 1,
  "visual_aesthetics": 1,
  "layout_integrity": 1,
  "rendering_correctness": 1,
  "variation_quality": 1,
  "overall": 1,
  "chart_region_description": "short description of the chart region you evaluated",
  "reason": "short reason for the scores"
}
""".strip()


def parse_args():
    parser = argparse.ArgumentParser()
    parser.add_argument("--input-root", default="output/chart_template_samples/20260527_083945")
    parser.add_argument("--output-dir", default=None)
    parser.add_argument("--model", default="gpt-4o")
    parser.add_argument("--prompt", default=DEFAULT_PROMPT)
    parser.add_argument("--limit", type=int, default=0)
    parser.add_argument("--workers", type=int, default=1)
    parser.add_argument("--templates", nargs="+", default=None)
    parser.add_argument("--resume", action="store_true")
    parser.add_argument("--aggregate-only", action="store_true")
    parser.add_argument("--detail", default="high", choices=["low", "high", "auto"])
    return parser.parse_args()


def read_jsonl(path: Path):
    if not path.is_file():
        return []
    with open(path, "r", encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]


def append_jsonl(path: Path, record: dict):
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")


def sample_key(record: dict):
    return f"{record.get('template_key')}::{record.get('sample_index')}::{record.get('sample_dir')}"


def encode_image(path: Path):
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")


def mime_type(path: Path):
    suffix = path.suffix.lower()
    if suffix == ".jpg" or suffix == ".jpeg":
        return "image/jpeg"
    if suffix == ".webp":
        return "image/webp"
    return "image/png"


def collect_tasks(input_root: Path, limit: int, templates: set[str] | None):
    manifest_path = input_root / "manifest.jsonl"
    records = read_jsonl(manifest_path)
    tasks = []
    failures = []
    for record in records:
        if not record.get("success"):
            continue
        if templates and record.get("template_key") not in templates:
            continue
        png_path = record.get("final_png")
        if not png_path:
            failures.append({**record, "error": "missing final_png"})
            continue
        image_path = Path(png_path)
        if not image_path.is_file():
            image_path = ROOT / png_path
        if not image_path.is_file():
            failures.append({**record, "error": f"final_png not found: {png_path}"})
            continue
        tasks.append({**record, "image_path": str(image_path)})
        if limit > 0 and len(tasks) >= limit:
            break
    return tasks, failures


def scored_keys(path: Path):
    return {sample_key(record) for record in read_jsonl(path)}


def score_one(args, record: dict):
    image_path = Path(record["image_path"])
    client = OpenAI(api_key=api_key, base_url=base_url)
    image_b64 = encode_image(image_path)
    response = client.chat.completions.create(
        model=args.model,
        response_format={"type": "json_object"},
        messages=[
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": args.prompt},
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:{mime_type(image_path)};base64,{image_b64}",
                            "detail": args.detail,
                        },
                    },
                ],
            }
        ],
    )
    payload = json.loads(response.choices[0].message.content)
    scores = {field: float(payload[field]) for field in SCORE_FIELDS}
    return {
        "template_key": record["template_key"],
        "sample_index": record["sample_index"],
        "sample_dir": record["sample_dir"],
        "input_image": str(image_path),
        "scores": scores,
        "chart_region_description": payload.get("chart_region_description", ""),
        "reason": payload.get("reason", ""),
        "model": args.model,
    }


def aggregate(output_dir: Path):
    rows = read_jsonl(output_dir / "sample_scores.jsonl")
    failures = read_jsonl(output_dir / "failed_scores.jsonl")
    groups = {}
    failure_counts = {}
    for record in failures:
        template_key = record.get("template_key", "")
        failure_counts[template_key] = failure_counts.get(template_key, 0) + 1
    for record in rows:
        template_key = record["template_key"]
        group = groups.setdefault(template_key, [])
        group.append(record)

    summary_rows = []
    for template_key, records in groups.items():
        item = {
            "template_key": template_key,
            "n_scored": len(records),
            "n_failed": failure_counts.get(template_key, 0),
        }
        for field in SCORE_FIELDS:
            values = [record["scores"][field] for record in records]
            item[f"{field}_mean"] = round(sum(values) / len(values), 4)
        summary_rows.append(item)

    for template_key, count in failure_counts.items():
        if template_key not in groups:
            summary_rows.append({
                "template_key": template_key,
                "n_scored": 0,
                "n_failed": count,
                **{f"{field}_mean": "" for field in SCORE_FIELDS},
            })

    summary_rows.sort(
        key=lambda row: (
            row["overall_mean"] == "",
            row["overall_mean"] if row["overall_mean"] != "" else 999,
            row["template_key"],
        )
    )

    with open(output_dir / "template_scores.json", "w", encoding="utf-8") as f:
        json.dump(summary_rows, f, indent=2, ensure_ascii=False)

    fieldnames = [
        "template_key",
        "n_scored",
        "n_failed",
        *[f"{field}_mean" for field in SCORE_FIELDS],
    ]
    with open(output_dir / "template_scores.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(summary_rows)

    return summary_rows


def write_combined_json(output_dir: Path, summary: dict, template_scores: list[dict]):
    samples = read_jsonl(output_dir / "sample_scores.jsonl")
    failures = read_jsonl(output_dir / "failed_scores.jsonl")
    payload = {
        "summary": summary,
        "score_fields": SCORE_FIELDS,
        "samples": samples,
        "templates": template_scores,
        "failures": failures,
    }
    with open(output_dir / "all_scores.json", "w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2, ensure_ascii=False)


def main():
    args = parse_args()
    input_root = Path(args.input_root)
    output_dir = Path(args.output_dir) if args.output_dir else (
        ROOT / "output" / "chart_template_quality_scores" / input_root.name
    )
    output_dir.mkdir(parents=True, exist_ok=True)

    templates = set(args.templates) if args.templates else None
    tasks, missing_failures = collect_tasks(input_root, args.limit, templates)
    sample_scores_path = output_dir / "sample_scores.jsonl"
    failed_scores_path = output_dir / "failed_scores.jsonl"

    if args.aggregate_only:
        template_scores = aggregate(output_dir)
        summary = {
            "input_root": str(input_root),
            "output_dir": str(output_dir),
            "model": args.model,
            "total_tasks": len(tasks),
            "pending_scored": 0,
            "missing_png": len(missing_failures),
            "templates_scored": len([row for row in template_scores if row["n_scored"] > 0]),
            "score_fields": SCORE_FIELDS,
        }
        with open(output_dir / "summary.json", "w", encoding="utf-8") as f:
            json.dump(summary, f, indent=2, ensure_ascii=False)
        write_combined_json(output_dir, summary, template_scores)
        print(f"wrote {output_dir / 'all_scores.json'}", flush=True)
        return

    existing = scored_keys(sample_scores_path) if args.resume else set()
    if args.resume:
        existing.update(scored_keys(failed_scores_path))

    pending = [record for record in tasks if sample_key(record) not in existing]
    for record in missing_failures:
        if not args.resume or sample_key(record) not in existing:
            append_jsonl(failed_scores_path, record)

    print(f"input_root={input_root}", flush=True)
    print(f"output_dir={output_dir}", flush=True)
    print(f"tasks={len(tasks)} pending={len(pending)} missing_png={len(missing_failures)} workers={args.workers}", flush=True)

    if args.workers > 1:
        with ThreadPoolExecutor(max_workers=args.workers) as executor:
            futures = [executor.submit(score_one, args, record) for record in pending]
            for index, future in enumerate(as_completed(futures), 1):
                result = future.result()
                append_jsonl(sample_scores_path, result)
                print(f"[{index}/{len(pending)}] {result['template_key']} sample_{result['sample_index']} overall={result['scores']['overall']}", flush=True)
    else:
        for index, record in enumerate(pending, 1):
            result = score_one(args, record)
            append_jsonl(sample_scores_path, result)
            print(f"[{index}/{len(pending)}] {result['template_key']} sample_{result['sample_index']} overall={result['scores']['overall']}", flush=True)

    template_scores = aggregate(output_dir)
    summary = {
        "input_root": str(input_root),
        "output_dir": str(output_dir),
        "model": args.model,
        "total_tasks": len(tasks),
        "pending_scored": len(pending),
        "missing_png": len(missing_failures),
        "templates_scored": len([row for row in template_scores if row["n_scored"] > 0]),
        "score_fields": SCORE_FIELDS,
    }
    with open(output_dir / "summary.json", "w", encoding="utf-8") as f:
        json.dump(summary, f, indent=2, ensure_ascii=False)
    write_combined_json(output_dir, summary, template_scores)


if __name__ == "__main__":
    main()