File size: 13,973 Bytes
0ab2566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
"""
Generate CoT data for embedding model using local Qwen3.5-397B-A17B via vLLM.

Usage:
    # 本地测试(前100条)
    python cot_gen/generate_cot.py --test

    # 本地全量运行(断点续传)
    python generate_cot.py --resume

    # 指定范围(分片输出)
    python cot_gen/generate_cot.py --start_idx 0 --end_idx 1000 --shard

    # 通过 nebula 提交(自动分片,结束后运行 merge_shards.py 合并)
    sh run_nebula.sh
"""

import os
import json
import argparse
import random
from pathlib import Path

from PIL import Image
from tqdm import tqdm
from vllm import LLM, SamplingParams

from config import (
    MODEL_PATH, TENSOR_PARALLEL_SIZE, GPU_MEMORY_UTILIZATION, MAX_MODEL_LEN,
    INPUT_PATH, OUTPUT_DIR, OUTPUT_PATH, FAILED_LOG_PATH,
    IMAGE_CDN_PREFIX, IMAGE_MAX_PIXELS, PROMPT_SELECT_MODE,
    BATCH_SIZE, MAX_TOKENS, TEMPERATURE, TOP_P, SAVE_INTERVAL,
    SYSTEM_PROMPT, COT_PROMPT_TEMPLATE,
)


def load_image_pair(image_name: str, bbox: str) -> tuple[Image.Image, Image.Image]:
    """Load original image and cropped image from local filesystem."""
    image_path = os.path.join(IMAGE_CDN_PREFIX, image_name)
    original = Image.open(image_path)
    if original.mode != "RGB":
        original = original.convert("RGB")

    if bbox and bbox != "ori":
        parts = bbox.split()
        if len(parts) >= 4:
            xmin, ymin, xmax, ymax = [int(float(z)) for z in parts[:4]]
            xmin = max(0, xmin)
            xmax = min(original.width - 1, xmax)
            ymin = max(0, ymin)
            ymax = min(original.height - 1, ymax)
            if (ymax - ymin) > 2 and (xmax - xmin) > 2:
                cropped = original.crop((xmin, ymin, xmax, ymax))
            else:
                cropped = original.copy()
        else:
            cropped = original.copy()
    else:
        cropped = original.copy()

    return original, cropped


def parse_records(raw_data: list[dict]) -> list[dict]:
    """
    Parse raw records into individual samples.
    Behavior depends on PROMPT_SELECT_MODE:
      - "all": each prompt in pos_prompts becomes a separate sample (data augmentation)
      - "random_one": randomly pick one prompt per record
    """
    all_samples = []
    for record in raw_data:
        query_tfsid = record["query_tfsid"]
        query_bbox = record["query_bbox"]
        rn = record["rn"]

        pos_prompts = record["pos_prompts"].split("##")
        pos_titles_list = record["pos_item_titles"].split("##")
        pos_tfsids_list = record["pos_item_tfsids"].split("##")
        pos_bboxs_list = record["pos_item_bboxs"].split("##")

        neg_tfsids_list = [x for x in record.get("neg_item_tfsids", "").split("##") if x.strip()]
        neg_titles_list = [x for x in record.get("neg_item_titles", "").split("##") if x.strip()]
        neg_bboxs_list = [x for x in record.get("neg_item_bboxs", "").split("##") if x.strip()]

        def pick_random_neg():
            if not neg_tfsids_list:
                return "", "", ""
            idx = random.randint(0, len(neg_tfsids_list) - 1)
            tfsid = neg_tfsids_list[idx]
            title = neg_titles_list[idx] if idx < len(neg_titles_list) else ""
            bbox = neg_bboxs_list[idx] if idx < len(neg_bboxs_list) else ""
            return tfsid, title, bbox

        def build_sample(i):
            title_indices = [max(0, i - 1), i, min(len(pos_titles_list) - 1, i + 1)]
            ref_titles = list(dict.fromkeys(
                pos_titles_list[idx] for idx in title_indices if idx < len(pos_titles_list)
            ))
            neg_tfsid, neg_title, neg_bbox = pick_random_neg()
            return {
                "rn": rn,
                "query_tfsid": query_tfsid,
                "query_bbox": query_bbox,
                "instruction": pos_prompts[i].strip(),
                "pos_titles_ref": ref_titles,
                "prompt_index": i,
                "pos_item_tfsid": pos_tfsids_list[i] if i < len(pos_tfsids_list) else "",
                "pos_item_title": pos_titles_list[i] if i < len(pos_titles_list) else "",
                "pos_item_bbox": pos_bboxs_list[i] if i < len(pos_bboxs_list) else "",
                "neg_item_tfsid": neg_tfsid,
                "neg_item_title": neg_title,
                "neg_item_bbox": neg_bbox,
            }

        if PROMPT_SELECT_MODE == "random_one":
            valid_indices = [i for i, p in enumerate(pos_prompts) if p.strip()]
            if not valid_indices:
                continue
            i = random.choice(valid_indices)
            all_samples.append(build_sample(i))
        else:
            for i, prompt in enumerate(pos_prompts):
                if not prompt.strip():
                    continue
                all_samples.append(build_sample(i))

    return all_samples


def build_vllm_prompt(sample: dict) -> str:
    """Build the text prompt part for vLLM (image is passed separately)."""
    instruction = sample["instruction"]
    pos_titles = "\n".join(f"- {t}" for t in sample["pos_titles_ref"])

    user_text = COT_PROMPT_TEMPLATE.format(
        instruction=instruction,
        pos_titles=pos_titles,
    )

    return user_text


def build_messages(sample: dict, original: Image.Image, cropped: Image.Image) -> list[dict]:
    """Build chat messages for vLLM with original and cropped images."""
    user_text = build_vllm_prompt(sample)

    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {
            "role": "user",
            "content": [
                {"type": "image_pil", "image_pil": original},
                {"type": "image_pil", "image_pil": cropped},
                {"type": "text", "text": user_text},
            ],
        },
    ]
    return messages


def load_processed_keys(output_path: str) -> set:
    """Load already processed (rn, prompt_index) pairs for resume."""
    processed = set()
    if os.path.exists(output_path):
        with open(output_path, "r", encoding="utf-8") as f:
            for line in f:
                if line.strip():
                    try:
                        item = json.loads(line)
                        processed.add((item["rn"], item["prompt_index"]))
                    except (json.JSONDecodeError, KeyError):
                        continue
    return processed


def save_results(results: list[dict], output_path: str):
    """Append results to JSONL file."""
    with open(output_path, "a", encoding="utf-8") as f:
        for item in results:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")


def save_failed(failed_samples: list[dict], failed_path: str):
    """Append failed samples to log."""
    with open(failed_path, "a", encoding="utf-8") as f:
        for item in failed_samples:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")


def main(args):
    # Load data
    print(f"Loading data from {INPUT_PATH} ...")
    with open(INPUT_PATH, "r", encoding="utf-8") as f:
        raw_data = json.load(f)
    print(f"Total raw records: {len(raw_data)}")

    # Slice
    end_idx = len(raw_data) if args.end_idx < 0 else min(args.end_idx, len(raw_data))
    raw_data = raw_data[args.start_idx:end_idx]
    print(f"Processing records [{args.start_idx}, {end_idx})")

    # Parse into samples
    all_samples = parse_records(raw_data)
    print(f"Prompt select mode: {PROMPT_SELECT_MODE}")
    print(f"Total samples (query x instruction pairs): {len(all_samples)}")

    # Output setup
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    if args.shard:
        base, ext = os.path.splitext(OUTPUT_PATH)
        output_path = f"{base}_{args.start_idx}_{end_idx}{ext}"
        failed_path = f"{base}_{args.start_idx}_{end_idx}_failed{ext}"
    else:
        output_path = OUTPUT_PATH
        failed_path = FAILED_LOG_PATH

    # Resume
    if args.resume:
        processed = load_processed_keys(output_path)
        before = len(all_samples)
        all_samples = [
            s for s in all_samples
            if (s["rn"], s["prompt_index"]) not in processed
        ]
        print(f"Resume: skipping {before - len(all_samples)} processed, {len(all_samples)} remaining")

    if not all_samples:
        print("No samples to process. Done.")
        return

    # Initialize vLLM
    print(f"Loading model from {MODEL_PATH} ...")
    print(f"  tensor_parallel_size={args.tp or TENSOR_PARALLEL_SIZE}")
    print(f"  gpu_memory_utilization={GPU_MEMORY_UTILIZATION}")
    print(f"  image_dir={IMAGE_CDN_PREFIX}")

    llm = LLM(
        model=MODEL_PATH,
        tensor_parallel_size=args.tp or TENSOR_PARALLEL_SIZE,
        gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
        max_model_len=MAX_MODEL_LEN,
        trust_remote_code=True,
        dtype="bfloat16",
        limit_mm_per_prompt={"image": 2},
        mm_processor_kwargs={"max_pixels": IMAGE_MAX_PIXELS},
        enable_prefix_caching=True,
    )
    sampling_params = SamplingParams(
        max_tokens=MAX_TOKENS,
        temperature=TEMPERATURE,
        top_p=TOP_P,
    )

    print(f"Model loaded. Starting generation...")
    print(f"Output: {output_path}")

    # Process in batches
    total_success = 0
    total_failed = 0
    batch_results = []

    for batch_start in tqdm(range(0, len(all_samples), BATCH_SIZE), desc="Batches"):
        batch_samples = all_samples[batch_start:batch_start + BATCH_SIZE]

        # Prepare batch inputs: load images and build prompts
        valid_samples = []
        batch_inputs = []

        for sample in batch_samples:
            try:
                original, cropped = load_image_pair(sample["query_tfsid"], sample["query_bbox"])
                messages = build_messages(sample, original, cropped)
                valid_samples.append(sample)
                batch_inputs.append(messages)
            except Exception as e:
                total_failed += 1
                save_failed([{**sample, "error": f"image_load: {str(e)}"}], failed_path)

        if not batch_inputs:
            continue

        # vLLM batch inference using chat interface
        try:
            outputs = llm.chat(
                messages=batch_inputs,
                sampling_params=sampling_params,
                chat_template_kwargs={"enable_thinking": False},
            )

            for sample, output in zip(valid_samples, outputs):
                generated_text = output.outputs[0].text.strip()

                if generated_text and len(generated_text) >= 10:
                    result = {
                        "rn": sample["rn"],
                        "query_tfsid": sample["query_tfsid"],
                        "query_bbox": sample["query_bbox"],
                        "instruction": sample["instruction"],
                        "cot": generated_text,
                        "prompt_index": sample["prompt_index"],
                        "pos_item_tfsid": sample["pos_item_tfsid"],
                        "pos_item_title": sample["pos_item_title"],
                        "pos_item_bbox": sample["pos_item_bbox"],
                        "neg_item_tfsid": sample["neg_item_tfsid"],
                        "neg_item_title": sample["neg_item_title"],
                        "neg_item_bbox": sample["neg_item_bbox"],
                    }
                    batch_results.append(result)
                    total_success += 1
                else:
                    total_failed += 1
                    save_failed([{**sample, "error": f"short_output: {generated_text}"}], failed_path)

        except Exception as e:
            print(f"Batch inference error: {e}")
            total_failed += len(valid_samples)
            for sample in valid_samples:
                save_failed([{**sample, "error": f"inference: {str(e)}"}], failed_path)

        # Periodic save
        if len(batch_results) >= SAVE_INTERVAL:
            save_results(batch_results, output_path)
            batch_results = []

    # Save remaining
    if batch_results:
        save_results(batch_results, output_path)

    print(f"\nDone! Success: {total_success}, Failed: {total_failed}")
    print(f"Output: {output_path}")
    if total_failed > 0:
        print(f"Failed log: {failed_path}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Generate CoT data using local Qwen3.5-397B-A17B")
    parser.add_argument("--test", action="store_true", help="Test mode: process first 100 records")
    parser.add_argument("--start_idx", type=int, default=0, help="Start index of raw records")
    parser.add_argument("--end_idx", type=int, default=-1, help="End index (-1 for all)")
    parser.add_argument("--tp", type=int, default=None, help="Override tensor_parallel_size")
    parser.add_argument("--resume", action="store_true", help="Resume from previous progress")
    parser.add_argument("--shard", action="store_true", help="Shard output by index range")
    args = parser.parse_args()

    if args.test:
        args.start_idx = 0
        args.end_idx = 100
        args.resume = False
    else:
        # Auto-shard based on RANK/WORLD_SIZE env vars (set by nebula/distributed launcher)
        rank = int(os.environ.get("RANK", os.environ.get("INDEX", 0)))
        world_size = int(os.environ.get("WORLD_SIZE", os.environ.get("WORKER_NUM", 1)))
        if world_size > 1:
            with open(INPUT_PATH, "r", encoding="utf-8") as f:
                total_records = len(json.load(f))
            if args.end_idx < 0:
                args.end_idx = total_records
            total = min(args.end_idx, total_records) - args.start_idx
            shard_size = (total + world_size - 1) // world_size
            args.start_idx = args.start_idx + rank * shard_size
            args.end_idx = min(args.start_idx + shard_size, total_records)
            args.shard = True
            print(f"[Auto-shard] rank={rank}, world_size={world_size}, "
                  f"processing [{args.start_idx}, {args.end_idx})")

    main(args)