Files
startrail-org--pixelrag/train/sft/prepare_sft_data_multiimage.py
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

242 lines
8.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Prepare multi-image SFT data from the top-6 retrieval outputs.
Input:
<retrieval-dir>/{train,eval,test}.jsonl (from fetch_top6_retrieval.py)
<retrieval-dir>/tiles/... (raw mirror from download_tiles.py)
Output (per compression ratio N):
<out-root>/<split>.json (LlamaFactory ShareGPT format, 6 images)
<out-root>/images/shard_.../chunk.png (compressed to 1/sqrt(N) per dim)
<out-root>/dataset_info.json
Each example has exactly 6 images:
= { gold } { top-6 hits } (dedup by shard-suffix)
which collapses to:
- gold in hits: gold + 5 non-gold hits
- gold not hits: gold + top-5 hits
The gold's position among the 6 is randomized (per-example) so the model
does not learn a positional shortcut.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import random
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from PIL import Image
from tqdm import tqdm
def shard_suffix(p: str) -> str:
parts = p.split("/")
for i, x in enumerate(parts):
if x.startswith("shard_"):
return "/".join(parts[i:])
return p
def compress_image(src: str, dst: str, scale_factor: float) -> bool:
try:
Image.MAX_IMAGE_PIXELS = 300_000_000
with Image.open(src) as img:
new_w = max(1, int(img.width * scale_factor))
new_h = max(1, int(img.height * scale_factor))
if img.mode != "RGB":
img = img.convert("RGB")
img_resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
img_resized.save(dst, format="PNG")
return True
except Exception as e:
print(f" WARN: compress failed {src}: {e}", file=sys.stderr)
return False
def build_image_set(row: dict, seed_base: int, n_images: int) -> tuple[list[str], int]:
"""Return (list of n_images shard-suffixes, gold index) with gold position shuffled.
Composition: gold + (n_images-1) non-gold hits from top-6."""
gold = row["gold_suffix"]
hit_sufs = [shard_suffix(h["path"]) for h in row["hits"]]
non_gold = [s for s in hit_sufs if s != gold]
chosen = [gold] + non_gold[: n_images - 1]
while len(chosen) < n_images:
chosen.append(gold)
rng = random.Random(seed_base)
idx = list(range(n_images))
rng.shuffle(idx)
shuffled = [chosen[i] for i in idx]
gold_pos = idx.index(0)
return shuffled, gold_pos
def main():
p = argparse.ArgumentParser()
p.add_argument(
"--retrieval-dir",
default="/scratch/users/zwcolin/cxr_embeds/sft_data/retrieval_raw",
)
p.add_argument(
"--tiles-mirror", default=None, help="Default: <retrieval-dir>/tiles"
)
p.add_argument(
"--out-root",
required=True,
help="Where to write compressed images + split JSONs",
)
p.add_argument(
"--compress-ratio",
type=int,
required=True,
help="Pixel-area ratio (2 / 3 / 4 / 5 / 9 …)",
)
p.add_argument("--splits", nargs="+", default=["train", "eval", "test"])
p.add_argument("--workers", type=int, default=32)
p.add_argument("--shuffle-seed", type=int, default=42)
p.add_argument(
"--n-images",
type=int,
default=3,
help="Images per sample. top-3 = gold + 2 non-gold hits; top-6 = gold + 5 hits",
)
p.add_argument(
"--json-suffix",
default="",
help="Append to output JSON filenames (e.g. '_top3' → train_top3.json). Empty = overwrite train.json",
)
args = p.parse_args()
retrieval_dir = Path(args.retrieval_dir)
mirror = Path(args.tiles_mirror) if args.tiles_mirror else retrieval_dir / "tiles"
out_root = Path(args.out_root)
out_images = out_root / "images"
out_images.mkdir(parents=True, exist_ok=True)
scale = 1.0 / math.sqrt(args.compress_ratio)
no_compression = args.compress_ratio == 1
print(
f"Compress ratio: {args.compress_ratio} (scale={scale:.4f}/dim)"
+ (" [NO-OP hardlink from mirror]" if no_compression else "")
)
print(f"Mirror: {mirror}")
print(f"Out: {out_root}")
print(f"Splits: {args.splits}")
# Pass 1: gather unique suffixes that need compression across all splits
all_split_rows = {}
needed = set()
for split in args.splits:
p_in = retrieval_dir / f"{split}.jsonl"
if not p_in.exists():
print(f" SKIP {split}: {p_in} missing")
continue
rows = []
with open(p_in) as f:
for line in f:
rows.append(json.loads(line))
all_split_rows[split] = rows
for r in rows:
shuffled, _ = build_image_set(r, args.shuffle_seed, args.n_images)
needed.update(shuffled)
print(f"\nUnique suffixes needed: {len(needed):,}")
# Filter to those not already compressed
to_compress = []
missing_src = 0
for s in needed:
dst = out_images / s
if dst.exists():
continue
src = mirror / s
if not src.exists():
missing_src += 1
continue
to_compress.append((str(src), str(dst)))
print(f" Already compressed: {len(needed) - len(to_compress) - missing_src:,}")
print(f" To compress: {len(to_compress):,}")
print(f" Missing src (skip): {missing_src:,}")
# Compress in parallel (or hardlink when ratio=1)
if to_compress:
def _work(args_):
src, dst = args_
os.makedirs(os.path.dirname(dst), exist_ok=True)
if no_compression:
try:
os.link(src, dst)
return True
except OSError:
import shutil
shutil.copy2(src, dst)
return True
return compress_image(src, dst, scale)
ok = 0
fail = 0
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = [pool.submit(_work, item) for item in to_compress]
for fut in tqdm(as_completed(futs), total=len(futs), desc="compress"):
if fut.result():
ok += 1
else:
fail += 1
print(f" compressed: ok={ok} fail={fail}")
# Pass 2: build ShareGPT JSON per split
dataset_info = {}
for split, rows in all_split_rows.items():
sg = []
skipped = 0
for r in rows:
shuffled, gold_pos = build_image_set(r, args.shuffle_seed, args.n_images)
img_paths = [str(out_images / s) for s in shuffled]
if any(not os.path.exists(pp) for pp in img_paths):
skipped += 1
continue
# ShareGPT multi-image: N <image> tokens in user content, N paths in images
user_content = ("<image>" * args.n_images) + "\n" + r["query"]
sg.append(
{
"messages": [
{"role": "user", "content": user_content},
{"role": "assistant", "content": r["answer"]},
],
"images": img_paths,
# meta (not used by LlamaFactory, retained for debugging)
"_gold_pos": gold_pos,
"_gold_in_top6_pos": r.get("gold_in_top6_pos", -1),
}
)
out_json = out_root / f"{split}{args.json_suffix}.json"
with open(out_json, "w") as f:
json.dump(sg, f, ensure_ascii=False)
print(f" {split}: {len(sg)} examples, skipped {skipped}")
ds_key = f"multimage_top{args.n_images}_{split}"
dataset_info[ds_key] = {
"file_name": str(out_json),
"formatting": "sharegpt",
"columns": {"messages": "messages", "images": "images"},
"tags": {
"role_tag": "role",
"content_tag": "content",
"user_tag": "user",
"assistant_tag": "assistant",
},
}
info_path = out_root / "dataset_info.json"
with open(info_path, "w") as f:
json.dump(dataset_info, f, indent=2)
print(f"\ndataset_info: {info_path}")
if __name__ == "__main__":
main()