julyanghar's picture
Add self-contained TOSC evaluation pipeline
7e01f18
Raw
History Blame Contribute Delete
21.4 kB
"""
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