| """ |
| Build caption targets for knowledge editing. |
| |
| Three-stage pipeline: |
| Stage 1: Run original LLaVA on all relevant images -> raw captions |
| Stage 1.5: Regex coarse filter + LLM judge to confirm toilet mentions |
| Stage 2: Use an LLM to rewrite hallucinating captions (toilet removed) |
| |
| Hallucinating = image has no toilet (ground truth) but LLaVA mentions toilet. |
| |
| Saves a reusable JSON dataset that any edit method can consume. |
| |
| Usage: |
| # Full pipeline (inference + LLM judge + LLM cleaning) |
| python -m experiment.data.build_caption_targets \ |
| --output experiment/data/caption_targets.json |
| |
| # Stage 1 only (inference, no judge/cleaning) |
| python -m experiment.data.build_caption_targets --inference_only |
| |
| # Run LLM judge on existing file (regex-positive entries) |
| python -m experiment.data.build_caption_targets \ |
| --judge_only experiment/data/caption_targets.json |
| |
| # Run LLM cleaning on existing file (hallucinating entries) |
| python -m experiment.data.build_caption_targets \ |
| --clean experiment/data/caption_targets.json |
| |
| Output format (caption_targets.json): |
| { |
| "images": { |
| "<image_id>": { |
| "image_path": "...", |
| "bathroom": 1, |
| "toilet": 0, |
| "split": "train", |
| "category": "bathroom_no_toilet", |
| "original_caption": "A bathroom with a toilet, sink...", |
| "had_toilet_mention_regex": true, |
| "had_toilet_mention_llm": true, |
| "is_hallucinating": true, |
| "cleaned_caption": "A bathroom with a sink...", |
| "cleaning_method": "llm", |
| "is_usable": true |
| }, |
| ... |
| }, |
| "stats": { ... }, |
| "config": { ... } |
| } |
| """ |
|
|
| import argparse |
| import csv |
| import json |
| import os |
| import re |
| import sys |
| from typing import Optional |
|
|
| from sklearn.model_selection import train_test_split |
| from tqdm import tqdm |
|
|
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) |
|
|
| from experiment.config.relation_config import get_relation_config, RelationConfig |
| from experiment.data.hf_loader import HF_DATASET_ID, hf_rows as _hf_rows |
|
|
| |
| |
| |
|
|
| SPLIT_SEED = 42 |
| SPLIT_TEST_SIZE = 0.2 |
|
|
| CAPTION_PROMPT = "Describe this image." |
|
|
| |
| TOILET_KEYWORDS = [ |
| "toilet", "toilets", "Toilet", "Toilets", |
| "commode", "lavatory", "latrine", |
| ] |
|
|
| _TOILET_RE = re.compile( |
| r"\b(?:" + "|".join(re.escape(k) for k in TOILET_KEYWORDS) + r")s?\b", |
| re.IGNORECASE, |
| ) |
|
|
|
|
| def _build_object_re(keywords: list[str]) -> re.Pattern: |
| return re.compile( |
| r"\b(?:" + "|".join(re.escape(k) for k in keywords) + r")s?\b", |
| re.IGNORECASE, |
| ) |
|
|
|
|
| _JUDGE_PROMPT = """\ |
| Does the following caption mention a {object_name} or any similar object? Answer with exactly YES or NO. |
| |
| Caption: "{caption}" |
| Answer:""" |
|
|
| _CLEAN_PROMPT = """\ |
| You are editing an image caption. Your task: remove ALL mentions of "{object_name}" AND any surrounding context that describes, references, or relates to it (its appearance, location, state, actions, etc.). The result should read as if the {object_name} was never part of the scene. |
| |
| Rules: |
| 1. Remove the {object_name} word itself and ALL clauses/phrases about it (e.g. "the {object_name} is sitting on a stand", "a large {object_name} mounted on the wall", "next to the {object_name}"). |
| 2. Remove dangling connectors, conjunctions, and transitions that no longer make sense after removal. |
| 3. Keep everything else EXACTLY as the original — same wording, style, and level of detail. |
| 4. The final caption must flow naturally as a complete, coherent sentence. Re-join remaining parts smoothly. |
| 5. If the ENTIRE caption is about the {object_name} and nothing meaningful remains, reply with exactly: N/A |
| |
| Examples: |
| - Input: "A living room with a couch, a coffee table, and a television that is sitting in the corner of the room. The television is displaying a news channel." |
| Output: "A living room with a couch and a coffee table." |
| |
| - Input: "The image shows a bathroom with a toilet next to a sink. The walls are tiled in white." |
| Output: "The image shows a bathroom with a sink. The walls are tiled in white." |
| |
| - Input: "A flat screen TV mounted on a wooden entertainment center in a cozy living room with bookshelves." |
| Output: "A cozy living room with a wooden entertainment center and bookshelves." |
| |
| Input: "{caption}" |
| Output:""" |
|
|
|
|
| |
| |
| |
|
|
| def load_csv(csv_path: str = None, image_dir: str = None, |
| dataset_id: str = HF_DATASET_ID, |
| relation_config: RelationConfig = None): |
| """Load dataset, categorize rows, assign train/val splits. |
| |
| Uses HF dataset by default, or CSV+image_dir if both provided. |
| """ |
| scene_col = relation_config.scene_key if relation_config else "bathroom" |
| object_col = relation_config.object_key if relation_config else "toilet" |
|
|
| if csv_path is not None and image_dir is not None: |
| |
| rows = [] |
| missing = 0 |
| with open(csv_path, "r") as f: |
| reader = csv.DictReader(f) |
| for row in reader: |
| image_path = os.path.join(image_dir, f"{row['image_id']}.jpg") |
| if not os.path.exists(image_path): |
| missing += 1 |
| continue |
| b = int(row.get(scene_col, row.get("bathroom", 0))) |
| t = int(row.get(object_col, row.get("toilet", 0))) |
| if relation_config: |
| if b == 1 and t == 0: cat = relation_config.scene_no_object |
| elif b == 1 and t == 1: cat = relation_config.scene_with_object |
| elif b == 0 and t == 1: cat = relation_config.non_scene_with_object |
| else: cat = "unrelated" |
| else: |
| if b == 1 and t == 0: cat = "bathroom_no_toilet" |
| elif b == 1 and t == 1: cat = "bathroom_with_toilet" |
| elif b == 0 and t == 1: cat = "non_bathroom_with_toilet" |
| else: cat = "unrelated" |
| rows.append({ |
| "image_id": row["image_id"], |
| "is_scene": b, |
| "has_object": t, |
| "image_path": image_path, |
| "category": cat, |
| }) |
|
|
| print(f"Loaded {len(rows)} rows from CSV ({missing} images not found on disk)") |
|
|
| |
| all_ids = [r["image_id"] for r in rows] |
| train_ids, val_ids = train_test_split( |
| all_ids, test_size=SPLIT_TEST_SIZE, random_state=SPLIT_SEED, |
| ) |
| train_set = set(train_ids) |
| for row in rows: |
| row["split"] = "train" if row["image_id"] in train_set else "val" |
| else: |
| |
| hf_kwargs = {} |
| if relation_config: |
| hf_kwargs = {"scene_col": scene_col, "object_col": object_col} |
| rows = _hf_rows(dataset_id, **hf_kwargs) |
| print(f"Loaded {len(rows)} rows from HuggingFace dataset ({dataset_id})") |
|
|
| |
| from collections import Counter |
| cat_counts = Counter(r["category"] for r in rows) |
| for cat, count in sorted(cat_counts.items()): |
| print(f" {cat}: {count}") |
|
|
| return rows |
|
|
|
|
| |
| |
| |
|
|
| def _worker_inference( |
| gpu_id: str, |
| rank: int, |
| rows: list[dict], |
| model_name: str, |
| prompt_text: str, |
| batch_size: int, |
| gpu_memory_utilization: float, |
| return_dict: dict, |
| object_keywords: list[str] = None, |
| ): |
| """Single-GPU worker for data-parallel LLaVA inference.""" |
| import os |
| os.environ["CUDA_VISIBLE_DEVICES"] = gpu_id |
|
|
| import re |
| import torch |
| from PIL import Image |
| from transformers import AutoProcessor, LlavaForConditionalGeneration |
|
|
| |
| if object_keywords: |
| mention_re = _build_object_re(object_keywords) |
| else: |
| mention_re = _TOILET_RE |
|
|
| processor = AutoProcessor.from_pretrained(model_name) |
| model = LlavaForConditionalGeneration.from_pretrained( |
| model_name, torch_dtype=torch.float16, device_map="cuda", |
| ) |
| model.eval() |
|
|
| |
| valid_rows = [] |
| images = [] |
| for row in rows: |
| try: |
| if "image_path" in row: |
| image = Image.open(row["image_path"]).convert("RGB") |
| else: |
| image = row["image"].convert("RGB") |
| valid_rows.append(row) |
| images.append(image) |
| except Exception as e: |
| print(f" [GPU {rank}] Skipping {row['image_id']}: {e}") |
|
|
| results = {} |
| for i in tqdm(range(0, len(valid_rows), batch_size), |
| desc=f"Captioning (GPU {rank})", position=rank): |
| batch_rows = valid_rows[i:i + batch_size] |
| batch_images = images[i:i + batch_size] |
|
|
| inputs = processor( |
| text=[prompt_text] * len(batch_images), |
| images=batch_images, |
| return_tensors="pt", |
| padding=True, |
| ).to("cuda") |
|
|
| with torch.no_grad(): |
| output_ids = model.generate( |
| **inputs, |
| max_new_tokens=300, |
| do_sample=False, |
| ) |
|
|
| input_len = inputs["input_ids"].shape[1] |
| for row, out_ids in zip(batch_rows, output_ids): |
| generated = processor.decode( |
| out_ids[input_len:], skip_special_tokens=True, |
| ).strip() |
| results[row["image_id"]] = { |
| "original_caption": generated, |
| "had_toilet_mention": bool(mention_re.search(generated)), |
| } |
|
|
| del model |
| torch.cuda.empty_cache() |
| return_dict[rank] = results |
|
|
|
|
| def run_inference( |
| rows: list[dict], |
| model_name: str, |
| prompt: str, |
| device: str = "cuda", |
| categories: Optional[list[str]] = None, |
| batch_size: int = 64, |
| gpu_memory_utilization: float = 0.8, |
| num_gpus: int = 1, |
| object_keywords: list[str] = None, |
| ) -> dict[str, dict]: |
| """Run LLaVA to generate captions using transformers with data parallelism. |
| |
| Each GPU gets its own model instance and a shard of the images. |
| |
| Args: |
| rows: list of row dicts from load_csv() |
| model_name: HuggingFace model ID |
| prompt: captioning prompt |
| device: cuda device |
| categories: which categories to caption (default: all) |
| batch_size: batch size per GPU |
| gpu_memory_utilization: unused, kept for API compat |
| num_gpus: number of GPUs for data parallelism |
| |
| Returns: |
| dict mapping image_id → {original_caption, had_toilet_mention} |
| """ |
| if categories: |
| rows = [r for r in rows if r["category"] in categories] |
|
|
| print(f"\nRunning inference on {len(rows)} images with {model_name} " |
| f"(transformers, {num_gpus} GPU{'s' if num_gpus > 1 else ''})...") |
|
|
| prompt_text = f"USER: <image>\n{prompt}\nASSISTANT:" |
|
|
| |
| visible = os.environ.get("CUDA_VISIBLE_DEVICES", "") |
| if visible: |
| gpu_ids = [g.strip() for g in visible.split(",")] |
| else: |
| import torch |
| gpu_ids = [str(i) for i in range(torch.cuda.device_count())] |
| gpu_ids = gpu_ids[:num_gpus] |
| if len(gpu_ids) < num_gpus: |
| print(f" WARNING: requested {num_gpus} GPUs but only " |
| f"{len(gpu_ids)} visible, using {len(gpu_ids)}") |
| num_gpus = len(gpu_ids) |
|
|
| |
| |
| tmp_dir = None |
| if num_gpus > 1: |
| import tempfile |
| from PIL import Image as _Image |
| needs_save = any("image_path" not in r for r in rows) |
| if needs_save: |
| tmp_dir = tempfile.mkdtemp(prefix="llava_inference_") |
| print(f" Saving HF images to {tmp_dir} for multi-GPU...") |
| for row in rows: |
| if "image_path" not in row: |
| path = os.path.join(tmp_dir, f"{row['image_id']}.jpg") |
| row["image"].convert("RGB").save(path) |
| row["image_path"] = path |
| |
| serializable_rows = [ |
| {k: v for k, v in r.items() if k != "image"} |
| for r in rows |
| ] |
| else: |
| serializable_rows = rows |
|
|
| if num_gpus <= 1: |
| |
| return_dict = {} |
| _worker_inference( |
| gpu_id=gpu_ids[0], rank=0, rows=serializable_rows, |
| model_name=model_name, prompt_text=prompt_text, |
| batch_size=batch_size, |
| gpu_memory_utilization=gpu_memory_utilization, |
| return_dict=return_dict, |
| object_keywords=object_keywords, |
| ) |
| results = return_dict[0] |
| else: |
| |
| import torch.multiprocessing as mp |
| mp.set_start_method("spawn", force=True) |
|
|
| |
| shards = [[] for _ in range(num_gpus)] |
| for i, row in enumerate(serializable_rows): |
| shards[i % num_gpus].append(row) |
|
|
| manager = mp.Manager() |
| return_dict = manager.dict() |
| processes = [] |
| for rank in range(num_gpus): |
| p = mp.Process( |
| target=_worker_inference, |
| args=(gpu_ids[rank], rank, shards[rank], model_name, |
| prompt_text, batch_size, gpu_memory_utilization, |
| return_dict, object_keywords), |
| ) |
| p.start() |
| processes.append(p) |
|
|
| for p in processes: |
| p.join() |
|
|
| |
| for rank, p in enumerate(processes): |
| if p.exitcode != 0: |
| raise RuntimeError( |
| f"Worker on GPU {gpu_ids[rank]} " |
| f"exited with code {p.exitcode}") |
|
|
| |
| results = {} |
| for rank in range(num_gpus): |
| results.update(return_dict[rank]) |
|
|
| |
| if tmp_dir is not None: |
| import shutil |
| shutil.rmtree(tmp_dir, ignore_errors=True) |
| for row in rows: |
| if row.get("image_path", "").startswith(tmp_dir): |
| del row["image_path"] |
|
|
| n_toilet = sum(1 for r in results.values() if r["had_toilet_mention"]) |
| print(f" {len(results)} captions generated") |
| print(f" {n_toilet}/{len(results)} mentioned toilet") |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def judge_hallucination_with_llm( |
| captions: dict[str, str], |
| model_name: str = "Qwen/Qwen3-8B", |
| batch_size: int = 64, |
| gpu_memory_utilization: float = 0.8, |
| tensor_parallel_size: int = 1, |
| object_name: str = "toilet", |
| ) -> dict[str, bool]: |
| """Use an LLM to confirm whether captions truly mention toilet. |
| |
| Takes regex-filtered candidates and asks the LLM to judge each one. |
| This catches edge cases the regex misses (negations, indirect references, |
| false positives from partial matches, etc.). |
| |
| Args: |
| captions: dict mapping image_id → caption text (regex-positive candidates) |
| model_name: LLM to use for judging |
| batch_size: vLLM batch size |
| gpu_memory_utilization: fraction of GPU memory for vLLM |
| tensor_parallel_size: number of GPUs for tensor parallelism |
| |
| Returns: |
| dict mapping image_id → True if LLM confirms toilet mention |
| """ |
| from vllm import LLM, SamplingParams |
| from transformers import AutoTokenizer |
|
|
| print(f"\nJudging {len(captions)} regex-positive captions with {model_name} (vLLM)") |
|
|
| if not captions: |
| return {} |
|
|
| tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) |
|
|
| iids = list(captions.keys()) |
| prompts = [] |
| for iid in iids: |
| user_msg = _JUDGE_PROMPT.format( |
| caption=captions[iid].replace('"', "'"), |
| object_name=object_name, |
| ) |
| messages = [{"role": "user", "content": user_msg}] |
| text = tokenizer.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=True, |
| enable_thinking=False, |
| ) |
| prompts.append(text) |
|
|
| sampling_params = SamplingParams(max_tokens=10, temperature=0) |
|
|
| llm = LLM( |
| model=model_name, |
| trust_remote_code=True, |
| gpu_memory_utilization=gpu_memory_utilization, |
| tensor_parallel_size=tensor_parallel_size, |
| dtype="float16", |
| ) |
|
|
| outputs = llm.generate(prompts, sampling_params) |
|
|
| results = {} |
| for iid, output in zip(iids, outputs): |
| response = output.outputs[0].text.strip().upper() |
| results[iid] = response.startswith("YES") |
|
|
| del llm |
| import torch |
| torch.cuda.empty_cache() |
|
|
| n_confirmed = sum(1 for v in results.values() if v) |
| print(f" LLM confirmed {n_confirmed}/{len(results)} as mentioning toilet") |
| print(f" Regex false positives filtered: {len(results) - n_confirmed}") |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def clean_captions_with_llm( |
| captions: dict[str, str], |
| model_name: str = "Qwen/Qwen3-8B", |
| device: str = "cuda", |
| batch_size: int = 64, |
| gpu_memory_utilization: float = 0.8, |
| tensor_parallel_size: int = 1, |
| object_name: str = "toilet", |
| object_re: re.Pattern = None, |
| ) -> dict[str, dict]: |
| """Use an LLM to rewrite captions with toilet mentions removed. |
| |
| Uses vLLM for fast batched inference. Qwen3 thinking is disabled via |
| ``extra_body={"chat_template_kwargs": {"enable_thinking": False}}``. |
| |
| Only processes captions that actually mention toilet. |
| |
| Args: |
| captions: dict mapping image_id → original caption text |
| model_name: LLM to use for cleaning (default: Qwen/Qwen3-8B) |
| device: cuda device |
| batch_size: vLLM batch size |
| gpu_memory_utilization: fraction of GPU memory for vLLM |
| tensor_parallel_size: number of GPUs for tensor parallelism |
| |
| Returns: |
| dict mapping image_id → {cleaned_caption, is_usable} |
| """ |
| from vllm import LLM, SamplingParams |
| from transformers import AutoTokenizer |
|
|
| if object_re is None: |
| object_re = _TOILET_RE |
|
|
| |
| needs_cleaning = { |
| iid: cap for iid, cap in captions.items() |
| if object_re.search(cap) |
| } |
| no_cleaning = { |
| iid: cap for iid, cap in captions.items() |
| if not object_re.search(cap) |
| } |
|
|
| print(f"\nCleaning {len(needs_cleaning)} captions with {model_name} (vLLM)") |
| print(f" ({len(no_cleaning)} captions have no toilet mentions, kept as-is)") |
|
|
| |
| results = {} |
| for iid, cap in no_cleaning.items(): |
| results[iid] = { |
| "cleaned_caption": cap, |
| "is_usable": True, |
| "cleaning_method": "passthrough", |
| } |
|
|
| if not needs_cleaning: |
| return results |
|
|
| |
| tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) |
|
|
| iids = list(needs_cleaning.keys()) |
| prompts = [] |
| for iid in iids: |
| caption = needs_cleaning[iid] |
| user_msg = _CLEAN_PROMPT.format( |
| caption=caption.replace('"', "'"), |
| object_name=object_name, |
| ) |
| messages = [{"role": "user", "content": user_msg}] |
| |
| text = tokenizer.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=True, |
| enable_thinking=False, |
| ) |
| prompts.append(text) |
|
|
| |
| sampling_params = SamplingParams( |
| max_tokens=300, |
| temperature=0, |
| ) |
|
|
| llm = LLM( |
| model=model_name, |
| trust_remote_code=True, |
| gpu_memory_utilization=gpu_memory_utilization, |
| tensor_parallel_size=tensor_parallel_size, |
| dtype="float16", |
| ) |
|
|
| outputs = llm.generate(prompts, sampling_params) |
|
|
| for iid, output in zip(iids, outputs): |
| response = output.outputs[0].text.strip() |
|
|
| |
| cleaned = response.strip().strip('"').strip("'").strip() |
|
|
| |
| is_usable = cleaned.upper() != "N/A" and len(cleaned.split()) >= 4 |
|
|
| |
| if object_re.search(cleaned): |
| print(f" WARNING: LLM failed to remove toilet from {iid}, " |
| f"retrying with stricter prompt is recommended") |
|
|
| results[iid] = { |
| "cleaned_caption": cleaned, |
| "is_usable": is_usable, |
| "cleaning_method": "llm", |
| } |
|
|
| del llm |
| import torch |
| torch.cuda.empty_cache() |
|
|
| n_usable = sum(1 for r in results.values() if r["is_usable"]) |
| print(f" {n_usable}/{len(results)} usable after cleaning") |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def build_targets( |
| rows: list[dict], |
| inference_results: dict[str, dict], |
| judge_results: Optional[dict[str, bool]] = None, |
| cleaning_results: Optional[dict[str, dict]] = None, |
| ) -> dict: |
| """Build the caption_targets.json structure.""" |
| images = {} |
| for row in rows: |
| iid = row["image_id"] |
| entry = { |
| "image_path": iid, |
| "is_scene": row.get("is_scene", row.get("bathroom", 0)), |
| "has_object": row.get("has_object", row.get("toilet", 0)), |
| "split": row.get("split", "train"), |
| "category": row.get("category", "unrelated"), |
| "original_caption": None, |
| "cleaned_caption": None, |
| "cleaning_method": None, |
| "is_usable": None, |
| "had_toilet_mention_regex": None, |
| "had_toilet_mention_llm": None, |
| "is_hallucinating": None, |
| } |
|
|
| if iid in inference_results: |
| inf = inference_results[iid] |
| entry["original_caption"] = inf["original_caption"] |
| entry["had_toilet_mention_regex"] = inf["had_toilet_mention"] |
|
|
| |
| if judge_results is not None and iid in judge_results: |
| entry["had_toilet_mention_llm"] = judge_results[iid] |
| elif judge_results is not None and entry["had_toilet_mention_regex"] is False: |
| |
| entry["had_toilet_mention_llm"] = False |
|
|
| |
| if entry["had_toilet_mention_llm"] is not None: |
| has_obj = row.get("has_object", row.get("toilet", 0)) |
| entry["is_hallucinating"] = ( |
| has_obj == 0 and entry["had_toilet_mention_llm"] |
| ) |
|
|
| if cleaning_results and iid in cleaning_results: |
| cl = cleaning_results[iid] |
| entry["cleaned_caption"] = cl["cleaned_caption"] |
| entry["is_usable"] = cl["is_usable"] |
| entry["cleaning_method"] = cl["cleaning_method"] |
|
|
| images[iid] = entry |
|
|
| |
| all_entries = list(images.values()) |
| stats = { |
| "total_images": len(all_entries), |
| "with_captions": sum(1 for e in all_entries if e["original_caption"]), |
| "with_cleaned": sum(1 for e in all_entries if e["cleaned_caption"]), |
| "had_toilet_mention_regex": sum( |
| 1 for e in all_entries if e.get("had_toilet_mention_regex")), |
| "had_toilet_mention_llm": sum( |
| 1 for e in all_entries if e.get("had_toilet_mention_llm")), |
| "hallucinating": sum( |
| 1 for e in all_entries if e.get("is_hallucinating")), |
| "usable": sum(1 for e in all_entries if e.get("is_usable")), |
| "by_category": {}, |
| "by_split": {}, |
| } |
| from collections import Counter |
| for key in ["category", "split"]: |
| counts = Counter(e[key] for e in all_entries) |
| stats[f"by_{key}"] = dict(counts) |
|
|
| return {"images": images, "stats": stats} |
|
|
|
|
| def save_targets(targets: dict, output_path: str, config: dict): |
| """Save targets with config metadata.""" |
| targets["config"] = config |
| os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) |
| with open(output_path, "w") as f: |
| json.dump(targets, f, indent=2) |
| print(f"\nSaved to {output_path}") |
| print(f" Stats: {targets['stats']}") |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description="Build caption targets: LLaVA inference + LLM judge + LLM cleaning" |
| ) |
|
|
| |
| parser.add_argument("--relation", type=str, default="bathroom_toilet", |
| help="Relation key from relations.json (default: bathroom_toilet)") |
|
|
| |
| parser.add_argument("--csv", type=str, default=None, |
| help="(Legacy) Path to CSV. If omitted, loads from HuggingFace.") |
| parser.add_argument("--image_dir", type=str, default=None, |
| help="(Legacy) Image directory. If omitted, loads from HuggingFace.") |
| parser.add_argument("--dataset_id", type=str, default=None, |
| help="HuggingFace dataset ID (default: auto from relation config)") |
| parser.add_argument("--output", type=str, |
| default="experiment/data/caption_targets.json") |
|
|
| |
| parser.add_argument("--model", type=str, default="llava-hf/llava-1.5-7b-hf", |
| help="LLaVA model for caption generation") |
| parser.add_argument("--judge_model", type=str, |
| default="Qwen/Qwen3-8B", |
| help="LLM for judging toilet mentions (via vLLM)") |
| parser.add_argument("--cleaner_model", type=str, |
| default="Qwen/Qwen3-8B", |
| help="LLM for cleaning toilet mentions (via vLLM)") |
| parser.add_argument("--device", type=str, default="cuda") |
| parser.add_argument("--prompt", type=str, default=CAPTION_PROMPT) |
| parser.add_argument("--batch_size", type=int, default=64, |
| help="vLLM batch size per GPU for LLaVA inference") |
| parser.add_argument("--gpu_memory", type=float, default=0.8, |
| help="GPU memory utilization for vLLM LLaVA") |
| parser.add_argument("--num_gpus", type=int, default=1, |
| help="Number of GPUs for data-parallel LLaVA inference") |
| parser.add_argument("--judge_batch_size", type=int, default=64, |
| help="vLLM batch size for LLM judge") |
| parser.add_argument("--judge_gpu_memory", type=float, default=0.8, |
| help="GPU memory utilization for vLLM judge") |
| parser.add_argument("--judge_tp", type=int, default=1, |
| help="Tensor parallel size for vLLM judge") |
| parser.add_argument("--cleaner_batch_size", type=int, default=64, |
| help="vLLM batch size for caption cleaning") |
| parser.add_argument("--cleaner_gpu_memory", type=float, default=0.8, |
| help="GPU memory utilization for vLLM cleaner") |
| parser.add_argument("--cleaner_tp", type=int, default=1, |
| help="Tensor parallel size for vLLM cleaner") |
|
|
| |
| parser.add_argument("--categories", nargs="+", default=None, |
| help="Which image categories to run inference on (default: all from relation)") |
|
|
| |
| parser.add_argument("--inference_only", action="store_true", |
| help="Run Stage 1 (inference) only") |
| parser.add_argument("--skip_judge", action="store_true", |
| help="Skip LLM judge, use regex only for toilet detection") |
| parser.add_argument("--clean", type=str, default=None, |
| help="Path to existing caption_targets.json — " |
| "run LLM judge + cleaning on entries that need it") |
| parser.add_argument("--judge_only", type=str, default=None, |
| help="Path to existing caption_targets.json — " |
| "run LLM judge on regex-positive entries only") |
|
|
| args = parser.parse_args() |
|
|
| |
| rc = get_relation_config(args.relation) |
| object_name = rc.judge_object_name |
| object_re = _build_object_re(rc.object_keywords) |
|
|
| |
| if args.judge_only: |
| print(f"Loading existing targets from {args.judge_only}") |
| print(f"Relation: {rc}") |
| with open(args.judge_only) as f: |
| targets = json.load(f) |
|
|
| |
| regex_positive = { |
| iid: entry["original_caption"] |
| for iid, entry in targets["images"].items() |
| if entry.get("original_caption") |
| and (entry.get("had_toilet_mention_regex") |
| or entry.get("had_toilet_mention")) |
| and entry.get("had_toilet_mention_llm") is None |
| } |
|
|
| if not regex_positive: |
| print("All regex-positive entries already judged by LLM.") |
| return |
|
|
| judge_results = judge_hallucination_with_llm( |
| regex_positive, |
| model_name=args.judge_model, |
| batch_size=args.judge_batch_size, |
| gpu_memory_utilization=args.judge_gpu_memory, |
| tensor_parallel_size=args.judge_tp, |
| object_name=object_name, |
| ) |
|
|
| |
| for iid, confirmed in judge_results.items(): |
| entry = targets["images"][iid] |
| entry["had_toilet_mention_llm"] = confirmed |
| has_obj = entry.get("has_object", entry.get("toilet", 0)) |
| entry["is_hallucinating"] = ( |
| has_obj == 0 and confirmed |
| ) |
|
|
| |
| for iid, entry in targets["images"].items(): |
| if entry.get("had_toilet_mention_llm") is None: |
| entry["had_toilet_mention_llm"] = False |
| entry["is_hallucinating"] = False |
|
|
| |
| all_entries = list(targets["images"].values()) |
| targets["stats"]["had_toilet_mention_llm"] = sum( |
| 1 for e in all_entries if e.get("had_toilet_mention_llm")) |
| targets["stats"]["hallucinating"] = sum( |
| 1 for e in all_entries if e.get("is_hallucinating")) |
|
|
| save_targets(targets, args.judge_only, targets.get("config", {})) |
| return |
|
|
| |
| if args.clean: |
| print(f"Loading existing targets from {args.clean}") |
| print(f"Relation: {rc}") |
| with open(args.clean) as f: |
| targets = json.load(f) |
|
|
| |
| needs_cleaning = { |
| iid: entry["original_caption"] |
| for iid, entry in targets["images"].items() |
| if entry.get("original_caption") |
| and entry.get("cleaned_caption") is None |
| and entry.get("is_hallucinating", False) |
| } |
|
|
| if not needs_cleaning: |
| print("All hallucinating entries already have cleaned captions.") |
| return |
|
|
| cleaning_results = clean_captions_with_llm( |
| needs_cleaning, |
| model_name=args.cleaner_model, |
| batch_size=args.cleaner_batch_size, |
| gpu_memory_utilization=args.cleaner_gpu_memory, |
| tensor_parallel_size=args.cleaner_tp, |
| object_name=object_name, |
| object_re=object_re, |
| ) |
|
|
| |
| for iid, cl in cleaning_results.items(): |
| targets["images"][iid]["cleaned_caption"] = cl["cleaned_caption"] |
| targets["images"][iid]["is_usable"] = cl["is_usable"] |
| targets["images"][iid]["cleaning_method"] = cl["cleaning_method"] |
|
|
| |
| all_entries = list(targets["images"].values()) |
| targets["stats"]["with_cleaned"] = sum( |
| 1 for e in all_entries if e.get("cleaned_caption") |
| ) |
| targets["stats"]["usable"] = sum( |
| 1 for e in all_entries if e.get("is_usable") |
| ) |
|
|
| save_targets(targets, args.clean, targets.get("config", {})) |
| return |
|
|
| |
| dataset_id = args.dataset_id or rc.dataset_id |
| categories = args.categories or rc.category_names |
|
|
| print(f"Relation: {rc}") |
| print(f"Dataset: {dataset_id}") |
|
|
| rows = load_csv(args.csv, args.image_dir, dataset_id, relation_config=rc) |
|
|
| config = { |
| "relation": args.relation, |
| "csv_path": args.csv, |
| "image_dir": args.image_dir, |
| "dataset_id": dataset_id, |
| "model": args.model, |
| "judge_model": args.judge_model if not args.skip_judge else None, |
| "cleaner_model": args.cleaner_model if not args.inference_only else None, |
| "prompt": args.prompt, |
| "categories": categories, |
| "split_seed": SPLIT_SEED, |
| "split_test_size": SPLIT_TEST_SIZE, |
| } |
|
|
| |
| inference_results = run_inference( |
| rows, |
| model_name=args.model, |
| prompt=args.prompt, |
| device=args.device, |
| categories=categories, |
| object_keywords=rc.object_keywords, |
| batch_size=args.batch_size, |
| gpu_memory_utilization=args.gpu_memory, |
| num_gpus=args.num_gpus, |
| ) |
|
|
| |
| targets = build_targets(rows, inference_results) |
| save_targets(targets, args.output, config) |
| print("Stage 1 complete — all captions saved.") |
|
|
| if args.inference_only: |
| return |
|
|
| |
| judge_results = None |
| if not args.skip_judge: |
| |
| regex_positive = { |
| iid: inf["original_caption"] |
| for iid, inf in inference_results.items() |
| if inf["had_toilet_mention"] |
| } |
|
|
| if regex_positive: |
| judge_results = judge_hallucination_with_llm( |
| regex_positive, |
| model_name=args.judge_model, |
| batch_size=args.judge_batch_size, |
| gpu_memory_utilization=args.judge_gpu_memory, |
| tensor_parallel_size=args.judge_tp, |
| object_name=object_name, |
| ) |
|
|
| |
| targets = build_targets(rows, inference_results, judge_results=judge_results) |
| save_targets(targets, args.output, config) |
| print("Stage 1.5 complete — LLM judge results saved.") |
|
|
| |
| |
| |
| hallucinating_captions = {} |
| for iid, entry in targets["images"].items(): |
| if entry.get("is_hallucinating"): |
| hallucinating_captions[iid] = entry["original_caption"] |
|
|
| if hallucinating_captions: |
| print(f"\n{len(hallucinating_captions)} hallucinating samples found — " |
| f"generating fixed captions...") |
|
|
| cleaning_results = clean_captions_with_llm( |
| hallucinating_captions, |
| model_name=args.cleaner_model, |
| batch_size=args.cleaner_batch_size, |
| gpu_memory_utilization=args.cleaner_gpu_memory, |
| tensor_parallel_size=args.cleaner_tp, |
| object_name=object_name, |
| object_re=object_re, |
| ) |
|
|
| |
| for iid, cl in cleaning_results.items(): |
| targets["images"][iid]["cleaned_caption"] = cl["cleaned_caption"] |
| targets["images"][iid]["is_usable"] = cl["is_usable"] |
| targets["images"][iid]["cleaning_method"] = cl["cleaning_method"] |
|
|
| all_entries = list(targets["images"].values()) |
| targets["stats"]["with_cleaned"] = sum( |
| 1 for e in all_entries if e.get("cleaned_caption") |
| ) |
| targets["stats"]["usable"] = sum( |
| 1 for e in all_entries if e.get("is_usable") |
| ) |
| save_targets(targets, args.output, config) |
| print("Stage 2 complete — fixed captions saved.") |
| else: |
| print("\nNo hallucinating samples found — skipping cleaning.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|