162 lines
5.4 KiB
Python
162 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""v2: Generate reasoning traces WITH the actual image in context.
|
|
|
|
v1 sent only Q+A → GPT hallucinated "look at byline" etc. without knowing if it
|
|
was actually visible. v2 sends the screenshot image so the reasoning describes
|
|
real visible elements.
|
|
|
|
Uses detail=low on image (85 tokens) to keep cost ~$3 for 30k calls with mini.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
from openai import OpenAI
|
|
from tqdm import tqdm
|
|
|
|
PROMPT = """Question: {query}
|
|
Correct answer: {answer}
|
|
|
|
Look at this Wikipedia screenshot. In 2-3 sentences, describe concretely what elements in THIS image (section title, infobox row, table cell, byline, date line, etc.) a reader would scan to find the answer. Reference the actual layout and named elements visible in the screenshot. No preamble. Output ONLY the reasoning."""
|
|
|
|
|
|
def encode_image(path: str, max_bytes: int = 4_000_000) -> str | None:
|
|
"""Return base64 data-URL for the image; return None if missing/too-big."""
|
|
try:
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
if len(data) > max_bytes:
|
|
# For large images, re-encode smaller via PIL to stay under limit
|
|
from PIL import Image
|
|
import io
|
|
|
|
Image.MAX_IMAGE_PIXELS = 300_000_000
|
|
img = Image.open(path)
|
|
if img.mode != "RGB":
|
|
img = img.convert("RGB")
|
|
# Shrink longest side to 1024 if larger
|
|
w, h = img.size
|
|
m = max(w, h)
|
|
if m > 1024:
|
|
s = 1024 / m
|
|
img = img.resize((int(w * s), int(h * s)), Image.Resampling.LANCZOS)
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="JPEG", quality=85)
|
|
data = buf.getvalue()
|
|
b64 = base64.b64encode(data).decode("ascii")
|
|
return f"data:image/png;base64,{b64}"
|
|
except Exception as e:
|
|
print(f" WARN: encode {path}: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def process_one(client, model, ex, image_root):
|
|
img_path = os.path.join(image_root, ex["chunk_path"])
|
|
img_url = encode_image(img_path) if os.path.exists(img_path) else None
|
|
if img_url is None:
|
|
return {**ex, "reasoning": None, "_error": "no_image"}
|
|
try:
|
|
resp = client.chat.completions.create(
|
|
model=model,
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": PROMPT.format(
|
|
query=ex["query"], answer=ex["answer"]
|
|
),
|
|
},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": img_url, "detail": "high"},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
max_tokens=200,
|
|
temperature=0.3,
|
|
)
|
|
reasoning = resp.choices[0].message.content.strip()
|
|
return {**ex, "reasoning": reasoning}
|
|
except Exception as e:
|
|
return {**ex, "reasoning": None, "_error": str(e)[:200]}
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--input", required=True)
|
|
p.add_argument("--output", required=True)
|
|
p.add_argument(
|
|
"--image-root",
|
|
required=True,
|
|
help="Dataset root containing the images/ subtree that chunk_path points into",
|
|
)
|
|
p.add_argument("--n", type=int, default=30000)
|
|
p.add_argument("--model", default="gpt-4.1-mini-2025-04-14")
|
|
p.add_argument("--concurrency", type=int, default=64)
|
|
p.add_argument("--seed", type=int, default=42)
|
|
args = p.parse_args()
|
|
|
|
with open(args.input) as f:
|
|
data = [json.loads(line) for line in f]
|
|
print(f"Loaded {len(data)} examples")
|
|
|
|
if args.n > 0 and args.n < len(data):
|
|
import random
|
|
|
|
rng = random.Random(args.seed)
|
|
data = rng.sample(data, args.n)
|
|
print(f"Sampled {len(data)} examples (seed={args.seed})")
|
|
|
|
cached = {}
|
|
if Path(args.output).exists():
|
|
with open(args.output) as f:
|
|
for line in f:
|
|
r = json.loads(line)
|
|
if r.get("reasoning"):
|
|
cached[(r["query"], r["chunk_path"])] = r
|
|
print(f"Found {len(cached)} cached results")
|
|
|
|
todo = [ex for ex in data if (ex["query"], ex["chunk_path"]) not in cached]
|
|
print(f"Generating {len(todo)} new image-aware traces with {args.model}")
|
|
if not todo:
|
|
return
|
|
|
|
client = OpenAI()
|
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
fout = open(args.output, "a", buffering=1)
|
|
|
|
fail = 0
|
|
t0 = time.time()
|
|
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
|
|
futures = [
|
|
pool.submit(process_one, client, args.model, ex, args.image_root)
|
|
for ex in todo
|
|
]
|
|
for fut in tqdm(
|
|
as_completed(futures), total=len(todo), desc="GPT vision trace"
|
|
):
|
|
r = fut.result()
|
|
if r.get("reasoning") is None:
|
|
fail += 1
|
|
fout.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
fout.close()
|
|
|
|
dur = time.time() - t0
|
|
print(f"\nDone in {dur:.1f}s. Failed: {fail}/{len(todo)}")
|
|
print(f"Output: {args.output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|