""" LLaVA 评估工具模块 ================== 本模块提供 LLaVA 评估所需的通用工具函数和类。 主要功能: 1. OpenAIModel 类: 封装 OpenAI API 调用(支持批量并行) 2. 模型初始化: init_model() - 加载 LLaVA 模型 3. 数据读取: read_json() - 读取 JSON/JSONL 文件 4. 同义词映射: object_synonyms_txt - COCO 物体同义词表 5. 文本处理: remove_negetive_sents(), remove_woodpecker_boxes() 用途: - 在各种评估脚本中导入使用 - 提供统一的 OpenAI API 调用接口 - 提供物体同义词映射 """ from __future__ import annotations import json import os import re import sys import time from concurrent.futures import Future, ThreadPoolExecutor, as_completed # openai / nltk are only used by OpenAIModel and remove_negetive_sents(), which # the TOSC pipeline never calls. Guard the imports so the package loads with just # the core inference deps (torch / transformers / peft / ...). try: import nltk except ImportError: nltk = None try: import openai from openai import OpenAI from openai.types.chat.chat_completion import ChatCompletion except ImportError: openai = None OpenAI = None ChatCompletion = None from tqdm import tqdm # 添加路径以导入 LLaVA 模块 sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) from llava.model.builder import load_pretrained_model from llava.utils import disable_torch_init # ==================== OpenAI API 配置 ==================== # 优先使用环境变量(同时支持官方 API 和兼容代理) # - OPENAI_BASE_URL / OPENAI_API_BASE: API 根地址(如 https://api.openai.com 或代理地址) # - OPENAI_API_KEY: API 密钥 DEFAULT_BASE_URL = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") or "https://api.openai.com" DEFAULT_API_KEY = os.getenv("OPENAI_API_KEY") or "" # ==================== API 调用配置 ==================== NUM_SECONDS_TO_SLEEP = 0.5 # 失败重试间隔(秒) MAX_RETRIES = 3 # 最大重试次数 class OpenAIModel: """ OpenAI API 封装类 提供方便的接口调用 OpenAI API(或兼容 API),支持: - 批量并行请求(提高效率) - 自动重试(处理 rate limit 等错误) - 灵活的输入格式(支持 users/systems 或 messages) Attributes: client: OpenAI 客户端实例 model: 默认使用的模型名称(如 "gpt-4") Example: >>> openai_model = OpenAIModel(model="gpt-4") >>> # 单个请求 >>> response = openai_model.gen(users="Describe this image.") >>> # 批量请求(并行) >>> responses = openai_model.gen( ... users=["Question 1", "Question 2"], ... use_parallel=True ... ) """ def __init__( self, base_url: str | None = None, api_key: str | None = None, model: str | None = None, timeout_sec: int = 20, ): """ 初始化 OpenAI 客户端 Args: base_url: API 根地址(如果为 None,从环境变量读取) api_key: API 密钥(如果为 None,从环境变量读取) model: 默认模型名称 timeout_sec: 请求超时时间(秒) """ base_url = (base_url or DEFAULT_BASE_URL).strip() api_key = (api_key or DEFAULT_API_KEY).strip() # 标准化 base_url 为 ".../v1/" 格式 # 接受多种输入格式: # - https://api.openai.com # - https://api.openai.com/v1 # - https://api.openai.com/v1/ if base_url.endswith("/"): base_url = base_url[:-1] if base_url.endswith("/v1"): base_url = base_url[: -len("/v1")] base_url = f"{base_url}/v1/" # 验证 API Key if not api_key: raise ValueError( "缺少 OpenAI API key。请设置环境变量 OPENAI_API_KEY 或通过 --openai_key 参数传递。" ) # 初始化 OpenAI 客户端 self.client: OpenAI = OpenAI( base_url=base_url, api_key=api_key, timeout=timeout_sec, max_retries=MAX_RETRIES, ) self.model: str | None = model def _u_a_to_messages(self, users: list[str], systems: list[str]) -> list[list[dict]]: assert len(users) == len(systems), "Length of users and systems must be the same." messages_list: list[list[dict]] = [] for u, s in zip(users, systems): messages: list[dict] = [] if s: messages.append({"role": "system", "content": s}) if u: messages.append({"role": "user", "content": u}) messages_list.append(messages) return messages_list def _prepare_messages_list( self, users: list[str] | str | None, systems: list[str] | str | None, messages: list[list[dict]] | list[dict] | None, ) -> list[list[dict]]: def ensure_lists(*args) -> list: """ 确保输入的参数都是列表形式,并且第一个参数的长度决定了后续参数的列表长度。 Args: 多个参数,每个参数可以是单个元素或列表。 Returns: 处理后的参数列表,每个参数都是列表形式。 第一个参数的长度将决定后续参数的列表长度。 如果某个参数是单个元素,则会被转换为包含该元素的列表。 如果某个参数是列表,则保持不变。 """ if not args: return [] first_arg: list = args[0] if isinstance(args[0], list) else [args[0]] length: int = len(first_arg) result = [first_arg] + [ a if isinstance(a, list) and len(a) == length else ([a] * length if not isinstance(a, list) else a * (length // len(a)) + a[: length % len(a)]) for a in args[1:] ] return result if len(result) > 1 else result[0] if users is not None: users, systems = ensure_lists(users, systems) return self._u_a_to_messages(users, systems) elif systems is not None: systems, users = ensure_lists(systems, users) return self._u_a_to_messages(users, systems) else: if not isinstance(messages[0], list): return [messages] return messages def gen( self, users: list[str] | str | None = None, systems: list[str] | list | None = None, messages: list[list[dict]] | list[dict] | None = None, temperature: float = 0.2, max_tokens: int = 512, model: str | None = None, sample=False, force_list: bool = False, return_completions: bool = False, use_parallel: bool = True, use_tqdm: bool = False, max_workers: int = 64, ) -> list[str] | str: """ 调用 OpenAI API 生成文本 支持两种输入方式: 1. 使用 users 和 systems 参数(推荐,简洁) 2. 使用 messages 参数(灵活,完全控制) Args: users: 用户消息(单个或列表) systems: 系统消息(单个或列表) messages: 完整的消息列表(与 users/systems 二选一) temperature: 采样温度(0=确定性,越高越随机) max_tokens: 最大生成 token 数 model: 模型名称(如果为 None 使用初始化时的 model) sample: 是否采样多个候选(n=5) force_list: 是否强制返回列表 return_completions: 是否返回完整的 completion 对象 use_parallel: 是否使用并行请求(多个请求时) use_tqdm: 是否显示进度条 max_workers: 并行线程数 Returns: str | list[str]: 生成的文本(单个或列表) Example: >>> # 单个请求 >>> response = model.gen(users="Hello") >>> # 批量并行请求 >>> responses = model.gen( ... users=["Q1", "Q2", "Q3"], ... use_parallel=True ... ) """ assert (users is None and systems is None) == (messages is not None), "Invalid input arguments." messages: list[list[dict]] = self._prepare_messages_list(users, systems, messages) n = 1 if not sample else 5 outputs: list[str | list[str]] = [None] * len(messages) def gen_completion(messages: list[dict]) -> str | list[str]: completions: ChatCompletion = self._gen( messages=messages, model=model if model else self.model, temperature=temperature, max_tokens=max_tokens, n=n, ) if return_completions: return completions if len(completions.choices) == 1: return completions.choices[0].message.content.strip() else: return [choice.message.content.strip() for choice in completions.choices] if len(messages) == 1: use_tqdm = False if use_tqdm: pb = tqdm(total=len(messages)) if use_parallel and len(messages) > 1: with ThreadPoolExecutor(max_workers=max_workers) as executor: futures: dict[Future, int] = {executor.submit(gen_completion, m): i for i, m in enumerate(messages)} for completed_future in as_completed(futures): if use_tqdm: pb.update(1) index: int = futures[completed_future] outputs[index] = completed_future.result() else: for i, m in enumerate(messages): outputs[i] = gen_completion(m) if use_tqdm: pb.update(1) return outputs if force_list or len(outputs) > 1 else outputs[0] def _gen( self, messages: list[dict], max_tokens: int = 512, model: str | None = None, temperature: float = 0.2, n: int = 1, seed: int | None = None, top_p: float = 1.0, ) -> ChatCompletion: assert model or self.model, "Model must be provided." last_err: Exception | None = None for _attempt in range(MAX_RETRIES): try: response: ChatCompletion = self.client.chat.completions.create( messages=messages, model=model if model else self.model, temperature=temperature, max_tokens=max_tokens, n=n, seed=seed, top_p=top_p, ) return response except openai.RateLimitError: last_err = None except Exception as e: last_err = e print(f"Error when generating: {e}") time.sleep(NUM_SECONDS_TO_SLEEP) raise RuntimeError( "OpenAI request failed after retries. " "If you're behind a proxy, set OPENAI_BASE_URL/OPENAI_API_BASE; " "otherwise check networking/DNS and that your key is valid." ) from last_err def init_model(args): """ 初始化 LLaVA 模型 加载预训练的 LLaVA 模型,支持基础模型和 LoRA 微调模型。 Args: args: 参数对象,包含: - model_path: 模型路径(基础模型或 LoRA 模型) - model_base: 基础模型路径(使用 LoRA 时需要) Returns: tuple: (tokenizer, model, image_processor) Example: >>> args.model_path = "llava-hf/llava-1.5-7b-hf" >>> args.model_base = None >>> tokenizer, model, image_processor = init_model(args) """ disable_torch_init() # 禁用 torch 的默认初始化(加速) model_path = os.path.expanduser(args.model_path) # 判断是否使用 LoRA if not args.model_base or args.model_base == "None" or len(args.model_base) < 5: # 使用基础模型 model_base = None model_name = "llava-v1.5-7b" else: # 使用 LoRA 微调模型 model_base = args.model_base model_name = "llava-v1.5-7b-lora" print(f"正在加载模型: {model_path},基础模型: {model_base}...") tokenizer, model, image_processor, _ = load_pretrained_model(model_path, model_base, model_name) return tokenizer, model, image_processor def read_json(file_path: str) -> list[dict] | dict: """ 读取 JSON 文件,支持多种格式 支持的格式: - .json / .jsonfile: 标准 JSON 格式 - .jsonl: JSON Lines 格式(每行一个 JSON 对象) Args: file_path: 文件路径 Returns: - .json: 返回 dict 或 list - .jsonl: 返回 list[dict] Raises: ValueError: 不支持的文件扩展名 """ ext = os.path.splitext(file_path)[-1] if ext == ".json" or ext == ".jsonfile": with open(os.path.expanduser(file_path), "r", encoding="utf-8") as f: data = json.load(f) elif ext == ".jsonl": with open(os.path.expanduser(file_path), "r", encoding="utf-8") as f: data = [json.loads(line) for line in f] else: raise ValueError(f"不支持的文件扩展名 {ext},文件: {file_path}") return data # ==================== COCO 物体同义词映射表 ==================== # 来源: https://github.com/LisaAnne/Hallucination/blob/master/data/synonyms.txt # 用于物体幻觉检测:判断模型提到的词是否指同一物体 # 格式: 每行第一个词是代表词,后面是同义词 object_synonyms_txt = """ person, girl, boy, man, woman, kid, child, chef, baker, people, adult, rider, children, baby, worker, passenger, sister, brother, biker, policeman, cop, officer, lady, cowboy, bride, groom, male, female, guy, traveler, mother, father, gentleman, pitcher, player, skier, snowboarder, skater, skateboarder, guy, foreigner, child, gentleman, caller, offender, coworker, trespasser, patient, politician, soldier, grandchild, serviceman, walker, drinker, doctor, bicyclist, thief, buyer, teenager, student, camper, driver, solider, hunter, shopper, villager, pedestrian bicycle, bike, unicycle, minibike, trike car, automobile, van, minivan, sedan, suv, hatchback, cab, jeep, coupe, taxicab, limo, taxi motorcycle, scooter, motor bike, motor cycle, motorbike, scooter, moped airplane, jetliner, plane, air plane, monoplane, aircraft, jet, jetliner, airbus, biplane, seaplane bus, minibus, trolley train, locomotive, tramway, caboose truck, pickup, lorry, hauler, firetruck boat, ship, liner, sailboat, motorboat, dinghy, powerboat, speedboat, canoe, skiff, yacht, kayak, catamaran, pontoon, houseboat, vessel, rowboat, trawler, ferryboat, watercraft, tugboat, schooner, barge, ferry, sailboard, paddleboat, lifeboat, freighter, steamboat, riverboat, battleship, steamship traffic light, street light, traffic signal, stop light, streetlight, stoplight fire hydrant, hydrant stop sign parking meter bench, pew bird, ostrich, owl, seagull, goose, duck, parakeet, falcon, robin, pelican, waterfowl, heron, hummingbird, mallard, finch, pigeon, sparrow, seabird, osprey, blackbird, fowl, shorebird, woodpecker, egret, chickadee, quail, bluebird, kingfisher, buzzard, willet, gull, swan, bluejay, flamingo, cormorant, parrot, loon, gosling, waterbird, pheasant, rooster, sandpiper, crow, raven, turkey, oriole, cowbird, warbler, magpie, peacock, cockatiel, lorikeet, puffin, vulture, condor, macaw, peafowl, cockatoo, songbird cat, kitten, feline, tabby dog, puppy, beagle, pup, chihuahua, schnauzer, dachshund, rottweiler, canine, pitbull, collie, pug, terrier, poodle, labrador, doggie, doberman, mutt, doggy, spaniel, bulldog, sheepdog, weimaraner, corgi, cocker, greyhound, retriever, brindle, hound, whippet, husky horse, colt, pony, racehorse, stallion, equine, mare, foal, palomino, mustang, clydesdale, bronc, bronco sheep, lamb, ram, lamb, goat, ewe cow, cattle, oxen, ox, calf, cattle, holstein, heifer, buffalo, bull, zebu, bison elephant bear, panda zebra giraffe backpack, knapsack umbrella handbag, wallet, purse, briefcase tie, bow, bow tie suitcase, suit case, luggage frisbee skis, ski snowboard sports ball, ball kite baseball bat baseball glove skateboard surfboard, longboard, skimboard, shortboard, wakeboard tennis racket, racket bottle wine glass cup fork knife, pocketknife, knive spoon bowl, container banana apple sandwich, burger, sub, cheeseburger, hamburger orange broccoli carrot hot dog pizza donut, doughnut, bagel cake, cheesecake, cupcake, shortcake, coffeecake, pancake chair, seat, stool couch, sofa, recliner, futon, loveseat, settee, chesterfield potted plant, houseplant bed dining table, table, desk, coffee table toilet, urinal, commode, toilet, lavatory, potty tv, monitor, televison, television laptop, computer, notebook, netbook, lenovo, macbook, laptop computer mouse remote, remote control keyboard cell phone, mobile phone, phone, cellphone, telephone, phon, smartphone, iPhone microwave oven, stovetop, stove, stove top oven toaster sink refrigerator, fridge, fridge, freezer book clock vase scissors teddy bear, teddybear hair drier, hairdryer toothbrush """ # ==================== Visual Genome 常见物体列表 ==================== # Visual Genome 数据集中最常见的物体 visual_genome_obj: list[str] = [ "tree", "window", "shirt", "building", "person", "table", "car", "door", "light", "fence", "chair", "people", "plate", "glass", "jacket", "sidewalk", "snow", "flower", "hat", "bag", "track", "roof", "umbrella", "helmet", "plant", "train", "bench", "box", "food", "pillow", "bus", "bowl", "horse", "trunk", "clock", "mountain", "elephant", "giraffe", "banana", "house", "cabinet", "hill", "dog", "book", "bike", "coat", "glove", "zebra", "bird", "motorcycle", "lamp", "cow", "skateboard", "surfboard", "beach", "sheep", "kite", "cat", "pizza", "bed", "bear", "windshield", "towel", "desk", ] # ==================== 关系同义词映射 ==================== # 用于判断两个空间关系是否相等 relation_sysnonyms_txt = """ in, on, at equals, is belongs to, is part of """ # ==================== COCO 双词物体列表 ==================== # 包含两个词的物体名称(如 "hot dog", "cell phone") # 在分词时需要特殊处理,避免被拆分 coco_double_words = [ "motor bike", "motor cycle", "air plane", "traffic light", "street light", "traffic signal", "stop light", "fire hydrant", "stop sign", "parking meter", "suit case", "sports ball", "baseball bat", "baseball glove", "tennis racket", "wine glass", "hot dog", "cell phone", "mobile phone", "teddy bear", "hair drier", "potted plant", "bow tie", "laptop computer", "stove top oven", "hot dog", "teddy bear", "home plate", "train track", "dining table", "coffee table", ] # ==================== 特殊物体类别 ==================== # 动物类词汇 animal_words = ["bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "animal", "cub"] # 交通工具类词汇 vehicle_words = ["jet", "train"] # ==================== 文本处理工具函数 ==================== def remove_negetive_sents(caption: str) -> str: """ 移除描述中的否定句 移除包含 "There is no" 或 "There are no" 的句子, 因为这些句子不包含实际物体信息。 Args: caption: 输入描述文本 Returns: str: 移除否定句后的文本 Example: >>> remove_negetive_sents("A dog is here. There is no cat.") "A dog is here." """ sents: list[str] = nltk.sent_tokenize(caption) sents = [sent for sent in sents if "There is no" not in sent and "There are no" not in sent] return " ".join(sents) def remove_woodpecker_boxes(text: str) -> str: """ 移除 Woodpecker 生成的边界框标记 Woodpecker 是一个视觉幻觉纠正方法,会在输出中添加边界框标记。 本函数移除这些标记以便评估。 标记格式: ([x1, y1, x2, y2]) 或 ([...]; Args: text: 包含边界框标记的文本 Returns: str: 移除标记后的文本 """ text = re.sub(r"\(\[.*?\]\)", "", text) # 移除 ([...]) text = re.sub(r"\(\[.*?\]\;", "", text) # 移除 ([...]; text = re.sub(r"\[.*?\]\;", "", text) # 移除 [...]; return text