#!/usr/bin/env python3 """ Identify hallucinated captions (LLaVA mentions target object where absent) and rewrite them using Qwen3-8B via vLLM. Pipeline: 1. Regex pre-filter (compound mode, wide net) 2. LLM judge (Qwen3-8B YES/NO on each regex match) 3. LLM rewrite (Qwen3-8B removes object mentions) 4. Strict check (negation-aware keyword detector on rewrites) Usage: python EFUF/scripts/clean_captions.py --relation bathroom_toilet python EFUF/scripts/clean_captions.py --relation bathroom_toilet --dry_run CUDA_VISIBLE_DEVICES=0,1 python EFUF/scripts/clean_captions.py --relation kitchen_oven --n_gpus 2 """ from __future__ import annotations import argparse import json import os import sys import time sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) from experiment.config.relation_config import get_relation_config from experiment.evaluation.metrics import KeywordMentionDetector def load_data(path: str) -> list[dict]: with open(path) as f: return json.load(f) def save_data(path: str, data: list[dict]) -> None: os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) os.replace(tmp, path) def load_checkpoint(path: str) -> dict: ckpt_path = path + ".ckpt.json" if not os.path.exists(ckpt_path): return {} with open(ckpt_path) as f: return json.load(f) def save_checkpoint(path: str, ckpt: dict) -> None: ckpt_path = path + ".ckpt.json" tmp = ckpt_path + ".tmp" with open(tmp, "w") as f: json.dump(ckpt, f, indent=2, ensure_ascii=False) os.replace(tmp, ckpt_path) def create_llm(model: str, n_gpus: int): from vllm import LLM from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained(model) llm = LLM( model=model, dtype="bfloat16", tensor_parallel_size=n_gpus, gpu_memory_utilization=0.30, max_model_len=1024, enforce_eager=True, ) return llm, tokenizer def step1_regex_filter( data: list[dict], rc, ckpt: dict, ) -> dict: if ckpt.get("step1_done"): print("[Step 1] Regex filter already done -- skipping.") return ckpt object_key = rc.object_key detector = KeywordMentionDetector(keywords=rc.mention_keywords, compound_mode=True) regex_matched = {} for entry in data: iid = entry["image_id"] if entry.get(object_key, 0) == 1: continue caption = entry.get("llava_caption", "") if detector.mentions_object(caption): regex_matched[iid] = caption existing_matched = ckpt.get("step1_regex_matched", {}) existing_matched.update(regex_matched) ckpt["step1_regex_matched"] = existing_matched ckpt["step1_done"] = True total_obj0 = sum(1 for e in data if e.get(object_key, 0) == 0) print(f"[Step 1] Regex filter: {len(regex_matched)}/{total_obj0} object=0 entries matched") return ckpt def step2_llm_judge( data: list[dict], rc, ckpt: dict, llm, tokenizer, batch_size: int, ) -> dict: if ckpt.get("step2_done"): print("[Step 2] LLM judge already done -- skipping.") return ckpt from vllm import SamplingParams regex_matched = ckpt.get("step1_regex_matched", {}) already_judged = ckpt.get("step2_judge_done", {}) to_judge = { iid: cap for iid, cap in regex_matched.items() if iid not in already_judged } if not to_judge: ckpt["step2_done"] = True print("[Step 2] No new entries to judge.") return ckpt print(f"[Step 2] Judging {len(to_judge)} entries ...") judge_sys = ( "You are determining whether an image description mentions a specific physical object. " "Only answer YES if the description explicitly refers to the {judge_object_name} " "as a physical object in the scene — not as part of a compound word like " "\"toilet paper\", \"toilet seat\", etc. unless it actually refers to the object itself." ).format(judge_object_name=rc.judge_object_name) prompts = [] iids_ordered = [] for iid, cap in to_judge.items(): user_msg = ( f"Does the following image description mention a {rc.judge_object_name}?\n\n" f'Answer ONLY "YES" or "NO".\n\n' f"Description: {cap}" ) messages = [ {"role": "system", "content": judge_sys}, {"role": "user", "content": user_msg}, ] prompt_text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, enable_thinking=False ) prompts.append(prompt_text) iids_ordered.append(iid) sampling = SamplingParams(temperature=0.0, max_tokens=5) results = {} for i in range(0, len(prompts), batch_size): batch_prompts = prompts[i : i + batch_size] batch_iids = iids_ordered[i : i + batch_size] outputs = llm.generate(batch_prompts, sampling, use_tqdm=True) for iid, out in zip(batch_iids, outputs): text = out.outputs[0].text.strip() results[iid] = "YES" if "YES" in text.upper() else "NO" already_judged.update(results) ckpt["step2_judge_done"] = already_judged ckpt["step2_done"] = True yes_count = sum(1 for v in results.values() if v == "YES") no_count = sum(1 for v in results.values() if v == "NO") print(f"[Step 2] Judge results: {yes_count} YES, {no_count} NO (out of {len(results)} judged)") return ckpt def step3_llm_rewrite( rc, ckpt: dict, llm, tokenizer, batch_size: int, ) -> dict: if ckpt.get("step3_done"): print("[Step 3] LLM rewrite already done -- skipping.") return ckpt from vllm import SamplingParams regex_matched = ckpt.get("step1_regex_matched", {}) judge_done = ckpt.get("step2_judge_done", {}) already_rewritten = ckpt.get("step3_rewrite_done", {}) yes_iids = [iid for iid, verdict in judge_done.items() if verdict == "YES"] to_rewrite = { iid: regex_matched[iid] for iid in yes_iids if iid not in already_rewritten } if not to_rewrite: ckpt["step3_done"] = True print("[Step 3] No new entries to rewrite.") return ckpt print(f"[Step 3] Rewriting {len(to_rewrite)} entries ...") rewrite_sys = ( "You are a meticulous writing assistant. " "Rewrite the given image description, removing ALL mentions of {object} " "as if the object was never present in the scene. " "The rewritten description must be fluent, coherent, and self-consistent -- " "do NOT leave gaps, placeholders, or awkward phrasing. " "Do not add any new content about the scene. " "Output only the rewritten description with no preamble or explanation." ).format(object=rc.judge_object_name) prompts = [] iids_ordered = [] for iid, cap in to_rewrite.items(): messages = [ {"role": "system", "content": rewrite_sys}, {"role": "user", "content": f"Original description:\n{cap}"}, ] prompt_text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, enable_thinking=False ) prompts.append(prompt_text) iids_ordered.append(iid) sampling = SamplingParams(temperature=0.0, max_tokens=200, repetition_penalty=1.05) rewrite_results = {} for i in range(0, len(prompts), batch_size): batch_prompts = prompts[i : i + batch_size] batch_iids = iids_ordered[i : i + batch_size] outputs = llm.generate(batch_prompts, sampling, use_tqdm=True) for iid, out in zip(batch_iids, outputs): rewrite_results[iid] = out.outputs[0].text.strip() already_rewritten.update(rewrite_results) ckpt["step3_rewrite_done"] = already_rewritten ckpt["step3_done"] = True print(f"[Step 3] Rewrote {len(rewrite_results)} captions") return ckpt def step4_strict_check( rc, ckpt: dict, ) -> dict: if ckpt.get("step4_done"): print("[Step 4] Strict quality check already done -- skipping.") return ckpt detector = KeywordMentionDetector(keywords=rc.mention_keywords, compound_mode=False) rewrite_done = ckpt.get("step3_rewrite_done", {}) already_checked = ckpt.get("step4_strict_done", {}) strict_results = {} for iid, rewritten in rewrite_done.items(): if iid not in already_checked: strict_results[iid] = not detector.mentions_object(rewritten) already_checked.update(strict_results) ckpt["step4_strict_done"] = already_checked ckpt["step4_done"] = True passed = sum(1 for v in strict_results.values() if v) failed = sum(1 for v in strict_results.values() if not v) print(f"[Step 4] Strict check: {passed} passed, {failed} failed (out of {len(strict_results)} rewrites)") return ckpt def assemble_output( data: list[dict], rc, ckpt: dict, ) -> list[dict]: object_key = rc.object_key regex_matched = ckpt.get("step1_regex_matched", {}) judge_done = ckpt.get("step2_judge_done", {}) rewrite_done = ckpt.get("step3_rewrite_done", {}) strict_done = ckpt.get("step4_strict_done", {}) output = [] for entry in data: out = dict(entry) iid = out["image_id"] if out.get(object_key, 0) == 1: out["hallucinating"] = False out["edited_caption"] = None out["mention_confidence"] = None out["rewrite_passed_strict_check"] = None elif iid in regex_matched: verdict = judge_done.get(iid, "NO") if verdict == "YES": out["hallucinating"] = True out["edited_caption"] = rewrite_done.get(iid, entry.get("llava_caption", "")) out["mention_confidence"] = "regex+llm" out["rewrite_passed_strict_check"] = strict_done.get(iid, None) else: out["hallucinating"] = False out["edited_caption"] = None out["mention_confidence"] = "regex" out["rewrite_passed_strict_check"] = None else: out["hallucinating"] = False out["edited_caption"] = None out["mention_confidence"] = None out["rewrite_passed_strict_check"] = None output.append(out) return output def main() -> None: ap = argparse.ArgumentParser( description=( "Identify hallucinated captions and rewrite them with Qwen3-8B via vLLM. " "Pipeline: regex filter -> LLM judge -> LLM rewrite -> strict quality check." ) ) ap.add_argument("--relation", default="bathroom_toilet", help="Relation key (default: bathroom_toilet)") ap.add_argument("--gpus", default="0", help="Comma-separated GPU IDs for CUDA_VISIBLE_DEVICES (default: 0)") ap.add_argument("--n_gpus", type=int, default=1, help="Number of GPUs for vLLM tensor parallel (default: 1)") ap.add_argument("--model", default="Qwen/Qwen3-8B", help="Qwen model path (default: Qwen/Qwen3-8B)") ap.add_argument("--input", default=None, help="Override input path") ap.add_argument("--output", default=None, help="Override output path (default: same as input)") ap.add_argument("--batch_size", type=int, default=64, help="vLLM batch size per step (default: 64)") ap.add_argument("--dry_run", action="store_true", help="Run regex filter only, skip all LLM inference") args = ap.parse_args() rc = get_relation_config(args.relation) if args.input: input_path = args.input else: input_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "data", args.relation, "all_captions.json" ) output_path = args.output if args.output else input_path print(f"[Config] relation={args.relation} object_key={rc.object_key}") print(f"[Config] input={input_path}") print(f"[Config] output={output_path}") data = load_data(input_path) print(f"[Config] Loaded {len(data)} entries") total_obj0 = sum(1 for e in data if e.get(rc.object_key, 0) == 0) total_obj1 = sum(1 for e in data if e.get(rc.object_key, 0) == 1) print(f"[Config] object=0: {total_obj0}, object=1: {total_obj1}") ckpt = load_checkpoint(output_path) print("\n[Step 1] Regex pre-filter (compound mode) ...") ckpt = step1_regex_filter(data, rc, ckpt) save_checkpoint(output_path, ckpt) if args.dry_run: regex_matched = ckpt.get("step1_regex_matched", {}) print(f"\n[Dry run] Regex matched {len(regex_matched)}/{total_obj0} object=0 entries") print("[Dry run] Saving regex-only output ...") output = assemble_output(data, rc, ckpt) save_data(output_path, output) print(f"[Dry run] Saved {len(output)} entries to {output_path}") return os.environ["CUDA_VISIBLE_DEVICES"] = args.gpus print(f"\n[Config] CUDA_VISIBLE_DEVICES={args.gpus}, tensor_parallel_size={args.n_gpus}") llm, tokenizer = create_llm(args.model, args.n_gpus) print("\n[Step 2] LLM judge ...") t0 = time.time() ckpt = step2_llm_judge(data, rc, ckpt, llm, tokenizer, args.batch_size) save_checkpoint(output_path, ckpt) print(f"[Step 2] Completed in {time.time() - t0:.1f}s") print("\n[Step 3] LLM rewrite ...") t0 = time.time() ckpt = step3_llm_rewrite(rc, ckpt, llm, tokenizer, args.batch_size) save_checkpoint(output_path, ckpt) print(f"[Step 3] Completed in {time.time() - t0:.1f}s") del llm import gc gc.collect() import torch torch.cuda.empty_cache() print("\n[Step 4] Strict quality check ...") ckpt = step4_strict_check(rc, ckpt) save_checkpoint(output_path, ckpt) print("\n[Output] Assembling final output ...") output = assemble_output(data, rc, ckpt) save_data(output_path, output) hallucinating = sum(1 for e in output if e.get("hallucinating")) regex_only = sum(1 for e in output if e.get("mention_confidence") == "regex") regex_llm = sum(1 for e in output if e.get("mention_confidence") == "regex+llm") rewritten = sum(1 for e in output if e.get("edited_caption") is not None) strict_pass = sum(1 for e in output if e.get("rewrite_passed_strict_check") is True) strict_fail = sum(1 for e in output if e.get("rewrite_passed_strict_check") is False) print(f"\n[Summary]") print(f" Total entries: {len(output)}") print(f" object=0 entries: {total_obj0}") print(f" Regex matches: {regex_only + regex_llm}") print(f" LLM judge NO: {regex_only}") print(f" LLM judge YES: {regex_llm}") print(f" Hallucinating: {hallucinating}") print(f" Rewritten: {rewritten}") print(f" Strict check pass: {strict_pass}") print(f" Strict check fail: {strict_fail}") print(f"\nSaved {len(output)} entries to {output_path}") if __name__ == "__main__": main()