File size: 15,434 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
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
#!/usr/bin/env python3
"""Cache physical video mirrors and controlled occlusions on the full frozen plan.

Every source/variant retains all 16 frame slots. Per-frame missing localization,
impossible controls, failed pixel parity and missing backend results remain in the
denominator. Geometry coverage never substitutes for independent visibility labels.
"""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from vbench_prompts_compile import annotation_jobs as J
from vbench_prompts_compile.official_replay import UPSTREAM_SHA
from vbench_prompts_compile.visual_transforms import mirror_detections, patch_frame
from cache_backend_outputs import sha


class BackendUnavailable(RuntimeError):
    """Stop a batch on model initialization failure instead of repeating it."""


def evidence_id(row):
    fields = [row['task'], row['relative_path'], row['evidence_kind'], row.get('axis'),
              row.get('occlusion_control'), row.get('level')]
    return hashlib.sha256(json.dumps(fields).encode()).hexdigest()[:24]


def unique_plan(rows):
    result = {}
    for row in rows:
        if row['evidence_kind'] == 'original':
            continue
        key = evidence_id(row)
        result.setdefault(key, {**row, 'evidence_id': key, 'matrix_ids': []})['matrix_ids'].append(row['sample_id'])
    return list(result.values())


def frame_hash(frame):
    return hashlib.sha256(frame.tobytes()).hexdigest()


def frames_for_backend(path, load_video):
    import torchvision.transforms as tv
    video = load_video(str(path), num_frames=16)
    _, _, height, width = video.size()
    if min(height, width) > 768:
        scale = 720. / min(height, width)
        video = tv.Resize(size=(int(scale * height), int(scale * width)))(video)
    return video.permute(0, 2, 3, 1).numpy()


def physical_mirror(source, target, axis, load_video):
    """Lossless RGB encode; GIFs use the official PIL decoder and frame indices."""
    import numpy as np
    target.parent.mkdir(parents=True, exist_ok=True)
    input_bytes = None
    timing = 'source timestamps passed through'
    input_args = ['-i', str(source)]
    if source.suffix.lower() == '.gif':
        # FFmpeg's GIF decoder can discard zero-duration frames that the official
        # PIL loader includes. Decode with that same loader before lossless encode.
        from fractions import Fraction
        from PIL import Image, ImageSequence
        with Image.open(source) as image:
            durations = [f.info.get('duration') for f in ImageSequence.Iterator(image)]
        known = set(durations) - {None, 0}
        if len(known) > 1 or (known and any(d is None or d == 0 for d in durations)):
            raise ValueError('variable GIF timing requires an explicit timestamp-preserving encoder')
        rate = str(Fraction(1000, next(iter(known)))) if known else '10'
        timing = 'uniform GIF duration preserved' if known else 'GIF has no duration metadata; 10 fps representation, all PIL frame indices preserved'
        full = load_video(str(source), return_tensor=False)
        _, height, width, _ = full.shape
        input_bytes = full.tobytes()
        input_args = ['-f', 'rawvideo', '-pixel_format', 'rgb24', '-video_size', f'{width}x{height}',
                      '-framerate', rate, '-i', 'pipe:0']
    command = ['ffmpeg', '-v', 'error', '-nostdin', '-y', *input_args,
        '-map', '0:v:0', '-vf', axis, '-c:v', 'libx264rgb', '-crf', '0', '-preset', 'veryfast',
        '-pix_fmt', 'rgb24', '-fps_mode', 'passthrough', '-an', '-f', 'mp4']
    if not target.exists():
        temporary = target.with_suffix('.partial.mp4')
        run = subprocess.run(command + [str(temporary)], input=input_bytes, capture_output=True)
        if run.returncode:
            raise RuntimeError('ffmpeg: ' + run.stderr[-800:].decode(errors='replace'))
        temporary.replace(target)
    before = load_video(str(source), num_frames=16, return_tensor=False)
    after = load_video(str(target), num_frames=16, return_tensor=False)
    expected = np.flip(before, axis=2 if axis == 'hflip' else 1)
    parity = before.shape == after.shape and np.array_equal(expected, after)
    if not parity:
        difference = float(np.max(np.abs(expected.astype('float32') - after.astype('float32')))) if expected.shape == after.shape else None
        raise ValueError(f'lossless mirror sample mismatch: {before.shape} vs {after.shape}, max error {difference}')
    return {'transformed_video': str(target), 'transformed_video_sha256': sha(target),
            'timing_policy': timing,
            'sampled_pixels_equal_exact_mirror': True, 'source_sample_sha256': [frame_hash(f) for f in before],
            'transformed_sample_sha256': [frame_hash(f) for f in after], 'ffmpeg_command': command + [str(target)]}


def detect(model, frame):
    import torch
    with torch.no_grad():
        result = model.run_caption_tensor(frame)
    detections = [{'label': r[0], 'box': [float(v) for v in r[1][:4]]} for r in result[0]]
    labels = sorted(set(str(v) for v in result[0][0][2])) if result[0] else []
    return detections, labels


def parse_args(argv=None):
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument('--task', choices=['spatial','objects'], required=True)
    p.add_argument('--plan', required=True)
    p.add_argument('--cache', required=True, help='complete original backend export for this task')
    p.add_argument('--video-root', required=True)
    p.add_argument('--out', required=True)
    p.add_argument('--artifact-root', required=True)
    p.add_argument('--vbench-root', default='/root/wenbiao_zhao/VBench')
    p.add_argument('--grit-weights', default='/root/.cache/vbench/grit_model/grit_b_densecap_objectdet.pth')
    p.add_argument('--device', default='cuda:0')
    p.add_argument('--max-videos', type=int, help='pilot; keeps complete plan')
    p.add_argument('--reserve-disk-gb', type=float, default=20)
    p.add_argument('--dry-run', action='store_true')
    return p.parse_args(argv)


def run(args, out):
    plan = unique_plan([r for r in J.read_jsonl(Path(args.plan)) if r['task'] == args.task])
    if not plan:
        raise ValueError('empty transform plan')
    cache = {r['relative_path']: r for r in J.read_jsonl(Path(args.cache))}
    upstream = subprocess.check_output(['git','-C',args.vbench_root,'rev-parse','HEAD'], text=True).strip()
    if upstream != UPSTREAM_SHA:
        raise ValueError('upstream commit mismatch')
    identity = {'schema': 1, 'task': args.task, 'plan_sha256': sha(args.plan), 'cache_sha256': sha(args.cache),
        'code_sha256': {n: sha(ROOT / n) for n in ['scripts/cache_matrix_transforms.py', 'src/vbench_prompts_compile/visual_transforms.py']},
        'upstream_sha': upstream, 'weights_sha256': sha(args.grit_weights), 'frames': 16,
        'variants': len(plan), 'artifact_root': args.artifact_root,
        'note': 'occlusion geometry verified, semantic visibility unverified'}
    J.bind_job(out.with_suffix('.manifest.json'), identity)
    if args.dry_run:
        print(json.dumps(identity, indent=2)); return 0
    journal = out.with_suffix('.attempts.jsonl')
    latest = {r['evidence_id']: r for r in J.read_jsonl(journal)}
    grouped = defaultdict(list)
    for row in plan:
        if row['evidence_id'] not in latest:
            grouped[row['relative_path']].append(row)
    # GPU imports are only needed for pending physical variants.
    sys.path.insert(0, str(Path(args.vbench_root).resolve()))
    import numpy as np
    import torch
    from PIL import Image
    from vbench.utils import load_video
    if args.device.startswith('cuda') and not torch.cuda.is_available():
        raise RuntimeError('requested CUDA backend unavailable')
    model = None
    artifact_root = Path(args.artifact_root)
    artifact_root.mkdir(parents=True, exist_ok=True)
    videos_attempted = 0
    stopped = None
    for relative, variants in grouped.items():
        if args.max_videos is not None and videos_attempted >= args.max_videos:
            break
        if shutil.disk_usage(artifact_root).free < args.reserve_disk_gb * 1024 ** 3:
            stopped = 'disk_reserve_reached'; break
        base = cache.get(relative, {})
        source = Path(args.video_root) / relative
        video_uid = hashlib.sha256(relative.encode()).hexdigest()[:24]
        frames = None
        frames_error = None
        memo = {}
        if base.get('status') == 'ok' and any(v['eligible'] and v['evidence_kind'] != 'evidence_mirror' for v in variants):
            try:
                if sha(source) != base.get('video_sha256'):
                    raise ValueError('source video changed since original cache')
                frames = frames_for_backend(source, load_video)
                if len(frames) != 16 or [frame_hash(f) for f in frames] != base['frame_sha256']:
                    raise ValueError('original frame sampling/preprocessing changed')
                for frame, detections, labels in zip(frames, base['frame_detections'], base['frame_labels']):
                    memo[frame_hash(frame)] = (detections, labels)
            except Exception as error:
                frames_error = f'{type(error).__name__}: {error}'
        for row in variants:
            record = {**row, 'source_video_sha256': base.get('video_sha256'), 'frame_size': base.get('frame_size')}
            try:
                if not row['eligible']:
                    record['status'] = 'ineligible'
                elif base.get('status') != 'ok':
                    record.update(status='source_cache_missing', source_status=base.get('status'))
                elif row['evidence_kind'] == 'evidence_mirror':
                    record.update(status='ok', frame_detections=mirror_detections(base['frame_detections'], base['frame_size'], row['axis']),
                                  frame_labels=base['frame_labels'], frame_status=['ok'] * 16,
                                  source_frame_sha256=base['frame_sha256'])
                else:
                    if frames_error or frames is None:
                        raise ValueError(frames_error or 'no source frames')
                    if row['evidence_kind'] == 'video_mirror':
                        target = artifact_root / 'spatial' / (video_uid + '-' + row['axis'] + '.mp4')
                        record.update(physical_mirror(source, target, row['axis'], load_video))
                        transformed = frames_for_backend(target, load_video)
                        patch_details = [{'status': 'ok', 'kind': row['axis']} for _ in transformed]
                    else:
                        required = row['base_official_target'].split(' and ')
                        patched = [patch_frame(frame, detections, required, row['occlusion_control'], row['level'])
                                   for frame, detections in zip(frames, base['frame_detections'])]
                        transformed = [pair[0] for pair in patched]
                        patch_details = [pair[1] for pair in patched]
                    detections, labels, hashes, errors = [], [], [], []
                    for i, (frame, details) in enumerate(zip(transformed, patch_details)):
                        if frame is None:
                            detections.append(None); labels.append(None); hashes.append(None); errors.append(details['status'])
                            continue
                        digest = frame_hash(frame)
                        hashes.append(digest)
                        try:
                            if digest not in memo:
                                if model is None:
                                    try:
                                        from vbench.third_party.grit_model import DenseCaptioning
                                        candidate = DenseCaptioning(torch.device(args.device))
                                        candidate.initialize_model_det(model_weight=args.grit_weights)
                                        model = candidate
                                    except Exception as error:
                                        raise BackendUnavailable(str(error)) from error
                                memo[digest] = detect(model, frame)
                            d, lab = memo[digest]
                            detections.append(d); labels.append(lab); errors.append('ok')
                        except BackendUnavailable:
                            raise
                        except Exception as error:
                            detections.append(None); labels.append(None); errors.append('backend:' + type(error).__name__)
                            details['backend_error'] = str(error)[:300]
                        if args.task == 'objects' and row.get('occlusion_control') == 'target' and row.get('level') == 1:
                            png = artifact_root / 'objects' / video_uid / f'target-full-frame-{i:02d}.png'
                            png.parent.mkdir(parents=True, exist_ok=True)
                            Image.fromarray(frame.astype('uint8')).save(png)
                            details['endpoint_image'] = str(png)
                            details['endpoint_image_sha256'] = sha(png)
                    record.update(status='ok' if all(e == 'ok' for e in errors) else 'partial',
                        frame_detections=detections, frame_labels=labels, frame_sha256=hashes, frame_status=errors,
                        frame_transform=patch_details, source_frame_sha256=base['frame_sha256'])
            except BackendUnavailable:
                raise
            except Exception as error:
                record.update(status='error:' + type(error).__name__, error=str(error)[:800])
            J.append_jsonl(journal, record)
            latest[row['evidence_id']] = record
            print(json.dumps({'cached_variants':len(latest), 'planned_variants':len(plan), 'last_status':record['status'],
                              'task':args.task, 'relative_path':relative, 'transform':row['transform']}), flush=True)
        videos_attempted += 1
    records = [latest.get(row['evidence_id'], {**row, 'status':'not_run'}) for row in plan]
    temporary = out.with_suffix('.jsonl.tmp')
    with temporary.open('w') as f:
        for row in records:
            f.write(json.dumps(row, ensure_ascii=False) + '\n')
    temporary.replace(out)
    statuses = dict(Counter(r['status'] for r in records))
    report = {'planned':len(plan), 'attempted':len(latest), 'status':statuses,
        'all_attempted':len(latest) == len(plan), 'all_eligible_complete':all(r['status']=='ok' for r in records if r['eligible']),
        'frame_status':dict(Counter(s for r in records for s in r.get('frame_status', []))),
        'stopped':stopped, 'visibility_verified':0, 'note':'full box coverage is not a visibility annotation'}
    J.atomic_json(out.with_suffix('.report.json'), report)
    print(json.dumps(report), flush=True)
    return 0 if report['all_attempted'] else 2


def main(argv=None):
    args = parse_args(argv)
    out = Path(args.out)
    with J.job_lock(out.with_suffix('.lock')):
        return run(args, out)


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