File size: 8,790 Bytes
fa82a9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Caption one real video frame with the official VBench Tag2Text pipeline (B5).

Runs **on the scoring host**, where the pinned VBench checkout and the Tag2Text
weights live; it uses ``vbench.utils.load_video`` / ``tag2text_transform`` and
``tag2text_caption`` exactly as ``vbench.scene`` does, so the captions match the
production distribution instead of an approximation.

The prompt split is enforced here: only prompts whose frozen split matches
``--split-filter`` are captioned, so test prompts can never leak into training.

Usage::

    python scripts/caption_frames.py --manifest <scene.csv> --video-root <dir> \
        --frozen-split splits/e0_prompt_split.csv --split-filter dev_only \
        --output captions-dev.jsonl --limit 200 --device cuda:1
"""

from __future__ import annotations

import argparse
import csv
import json
import os
from pathlib import Path
import sys


def load_frozen_split(path: Path) -> dict[str, set[str]]:
    frozen: dict[str, set[str]] = {}
    with path.open(newline="", encoding="utf-8") as handle:
        for row in csv.DictReader(handle):
            frozen.setdefault(row["prompt_id"], set()).add(row["split"])
    return frozen


def split_of(frozen: dict[str, set[str]], prompt: str) -> str:
    splits = frozen.get(prompt)
    if not splits:
        return "outside"
    if splits == {"dev"}:
        return "dev_only"
    if splits == {"test"}:
        return "test_only"
    return "dev_and_test"


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--manifest", required=True, help="human-preference scene.csv")
    parser.add_argument("--video-root", required=True, help="directory that video paths in the manifest are relative to")
    parser.add_argument("--frozen-split", required=True)
    parser.add_argument("--split-filter", default="dev_only", choices=["dev_only", "test_only", "dev_and_test", "outside", "all"])
    parser.add_argument("--vbench-root", default="/root/wenbiao_zhao/VBench")
    parser.add_argument("--weights", default="/root/.cache/vbench/caption_model/tag2text_swin_14m.pth")
    parser.add_argument("--output", required=True)
    parser.add_argument("--num-frames", type=int, default=16)
    parser.add_argument("--frame-index", default="middle", help="middle | first | last | integer")
    parser.add_argument("--frame-fractions", default=None, help="comma list of positions in [0,1); overrides --frame-index and emits one row per frame")
    parser.add_argument("--limit", type=int, default=None)
    parser.add_argument("--device", default="cuda:0")
    parser.add_argument("--caption-retries", type=int, default=3)
    parser.add_argument("--save-frames", default=None, help="directory to write the captioned frame as PNG (for vision labelling)")
    args = parser.parse_args(argv)

    frozen = load_frozen_split(Path(args.frozen_split))
    rows: list[dict[str, str]] = []
    seen: set[tuple[str, str]] = set()
    with Path(args.manifest).open(newline="", encoding="utf-8") as handle:
        for row in csv.DictReader(handle):
            prompt = row.get("prompt_en", "")
            if args.split_filter != "all" and split_of(frozen, prompt) != args.split_filter:
                continue
            for key in ("video_a_path", "video_b_path"):
                relative = row.get(key)
                if not relative:
                    continue
                if (prompt, relative) in seen:
                    continue
                seen.add((prompt, relative))
                rows.append({"prompt": prompt, "relative_path": relative, "generator": relative.split("/")[1] if "/" in relative else "unknown", "instance_id": row.get("instance_id", "")})
    if args.limit is not None:
        rows = rows[: args.limit]
    print(json.dumps({"selected_videos": len(rows), "unique_prompts": len({row['prompt'] for row in rows}), "split_filter": args.split_filter}), flush=True)

    sys.path.insert(0, str(Path(args.vbench_root).resolve()))
    import torch  # noqa: E402
    from vbench.third_party.tag2Text.tag2text import tag2text_caption  # noqa: E402
    from vbench.utils import load_video, tag2text_transform  # noqa: E402

    device = torch.device(args.device if torch.cuda.is_available() else "cpu")
    model = tag2text_caption(pretrained=args.weights, image_size=384, vit="swin_b").to(device).eval()
    transform = tag2text_transform(384)

    output = Path(args.output)
    output.parent.mkdir(parents=True, exist_ok=True)
    frame_dir = Path(args.save_frames) if args.save_frames else None
    if frame_dir is not None:
        frame_dir.mkdir(parents=True, exist_ok=True)
    written = failed = 0
    with output.open("w", encoding="utf-8") as handle:
        for row in rows:
            video_path = Path(args.video_root) / row["relative_path"]
            if not video_path.exists():
                failed += 1
                handle.write(json.dumps({**row, "status": "missing_video", "video_path": str(video_path)}, ensure_ascii=False) + "\n")
                continue
            try:
                frames = load_video(str(video_path), num_frames=args.num_frames, return_tensor=False, width=384, height=384)
            except Exception as error:  # noqa: BLE001 - report, never crash the batch
                failed += 1
                handle.write(json.dumps({**row, "status": f"load_error:{type(error).__name__}", "video_path": str(video_path)}, ensure_ascii=False) + "\n")
                continue
            if args.frame_fractions:
                indices = [min(len(frames) - 1, max(0, int(float(frac) * len(frames)))) for frac in args.frame_fractions.split(",")]
            elif args.frame_index == "middle":
                indices = [len(frames) // 2]
            elif args.frame_index == "first":
                indices = [0]
            elif args.frame_index == "last":
                indices = [len(frames) - 1]
            else:
                indices = [min(int(args.frame_index), len(frames) - 1)]
            for position, index in enumerate(indices):
              frame = frames[index]
              caption = None
              for _ in range(max(1, args.caption_retries)):
                try:
                    tensor = transform(frame).to(device).unsqueeze(0)
                    with torch.no_grad():
                        captions, _tags = model.generate(tensor, tag_input=None, return_tag_predict=True)
                    caption = captions[0] if isinstance(captions, (list, tuple)) else str(captions)
                    break
                except Exception:  # noqa: BLE001
                    continue
              if caption is None:
                failed += 1
                handle.write(json.dumps({**row, "status": "caption_error", "video_path": str(video_path)}, ensure_ascii=False) + "\n")
                continue
              frame_path = None
              if frame_dir is not None:
                safe = f"{written:05d}_{abs(hash((row['prompt'], row['relative_path']))) % 10**10}.png"
                frame_path = frame_dir / safe
                try:
                    from PIL import Image  # noqa: PLC0415 - available on the scoring host

                    if hasattr(frame, "convert"):
                        image = frame.convert("RGB")
                    else:
                        import numpy as np  # noqa: PLC0415

                        array = np.asarray(frame)
                        image = Image.fromarray(array.astype("uint8")).convert("RGB")
                    image.save(frame_path)
                except Exception as error:  # noqa: BLE001 - frame saving is best effort but must be visible
                    print(json.dumps({"frame_save_error": f"{type(error).__name__}: {error}"}), flush=True)
                    frame_path = None
              written += 1
              handle.write(
                json.dumps(
                    {
                        **row,
                        "status": "ok",
                        "video_path": str(video_path),
                        "frame_index": index,
                        "frames_sampled": len(frames),
                        "caption": caption,
                        "frame_path": str(frame_path) if frame_path else None,
                    },
                    ensure_ascii=False,
                )
                + "\n"
              )
              if written % 25 == 0:
                print(json.dumps({"written": written, "failed": failed}), flush=True)
    print(json.dumps({"status": "done", "written": written, "failed": failed, "output": str(output)}), flush=True)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())