Skip to content

Commit 273ff22

Browse files
author
Changdae Park
committed
Add VisionPsy-Nano-460M / -Flash support
New adapter in vlmeval/vlm/visionpsy.py. The checkpoints are Hub-packaged and bundle their own processor and modeling code, so the default backend is transformers with trust_remote_code and needs no extra install; use_vllm=True serves the same checkpoint through an in-process vLLM engine via the VisionPsy vLLM plugin. The base/Flash preprocessing variants are resolved from the checkpoint config. Existing adapters are untouched.
1 parent 5a91183 commit 273ff22

4 files changed

Lines changed: 369 additions & 0 deletions

File tree

vlmeval/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2651,5 +2651,14 @@
26512651

26522652
model_groups.append(nanovlm_series)
26532653

2654+
visionpsy_series = {
2655+
"VisionPsy-Nano-460M": partial(vlm.VisionPsy, model_path="qvac/VisionPsy-Nano-460M"),
2656+
"VisionPsy-Nano-460M-Flash": partial(
2657+
vlm.VisionPsy, model_path="qvac/VisionPsy-Nano-460M-Flash"
2658+
),
2659+
}
2660+
2661+
model_groups.append(visionpsy_series)
2662+
26542663
for grp in model_groups:
26552664
supported_VLM.update(grp)

vlmeval/inference.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def infer_data(model, model_name, work_dir, dataset, out_file, verbose=False, ap
132132
'Llama-4' in model_name
133133
or 'Qwen2-VL' in model_name
134134
or 'Qwen2.5-VL' in model_name
135+
or 'VisionPsy' in model_name
135136
):
136137
kwargs = {'use_vllm': use_vllm}
137138

vlmeval/vlm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
VideoLLaVA_HF)
9292
from .vila import NVILA, VILA
9393
from .vintern_chat import VinternChat
94+
from .visionpsy import VisionPsy
9495
from .visualglm import VisualGLM
9596
from .vita import VITA, VITAQwen2
9697
from .vlaa_thinker import VLAAThinkerChat

vlmeval/vlm/visionpsy.py

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import os
2+
import sys
3+
import warnings
4+
5+
import torch
6+
from PIL import Image
7+
8+
from .base import BaseModel
9+
10+
_VLLM_PLUGIN_INSTALL_MSG = (
11+
'VisionPsy with use_vllm=True needs the VisionPsy vLLM plugin, which '
12+
'registers the architecture with vLLM:\n'
13+
' pip install "git+https://github.com/tether-ai-research/'
14+
'qvac-visionpsy-nano#subdirectory=vllm-inference"'
15+
)
16+
17+
18+
class VisionPsy(BaseModel):
19+
"""VisionPsy-Nano, a ~460M-parameter vision-language model for edge devices.
20+
21+
The checkpoints are Hub-packaged: they bundle their own processor and
22+
modeling code, so the default backend is plain `transformers` with
23+
`trust_remote_code=True` and needs no extra install. Passing
24+
`use_vllm=True` serves the same checkpoint through an in-process vLLM
25+
engine instead (requires the VisionPsy vLLM plugin).
26+
27+
Two preprocessing variants exist and are resolved from the checkpoint's own
28+
config: the base model resizes to the max side, the Flash variant keeps the
29+
native resolution with a min-side clamp.
30+
"""
31+
32+
INSTALL_REQ = False
33+
INTERLEAVE = True
34+
35+
_MODEL_TYPES = ('visionpsynano', 'visionpsy')
36+
37+
def __init__(self, model_path='qvac/VisionPsy-Nano-460M', use_vllm=False, **kwargs):
38+
super().__init__()
39+
self.use_vllm = use_vllm
40+
41+
raw_cfg = self._read_raw_config(model_path)
42+
if raw_cfg is None:
43+
raise RuntimeError(f'cannot read config.json for {model_path}')
44+
if raw_cfg.get('model_type') not in self._MODEL_TYPES:
45+
raise ValueError(
46+
f'{model_path} is not a VisionPsy checkpoint '
47+
f'(config model_type: {raw_cfg.get("model_type")!r}).'
48+
)
49+
50+
kwargs_default = {'max_new_tokens': 2048}
51+
kwargs_default.update(kwargs)
52+
self.kwargs = kwargs_default
53+
warnings.warn(f'VisionPsy kwargs: {self.kwargs}')
54+
55+
if use_vllm:
56+
self._init_vllm(model_path, raw_cfg)
57+
else:
58+
self._init_transformers(model_path)
59+
60+
# ------------------------------------------------------------------
61+
# Backends
62+
# ------------------------------------------------------------------
63+
def _init_transformers(self, model_path):
64+
from transformers import AutoModelForImageTextToText, AutoProcessor
65+
66+
self.vlm = AutoModelForImageTextToText.from_pretrained(
67+
model_path, trust_remote_code=True, dtype=torch.float32,
68+
).to('cuda').eval()
69+
if hasattr(self.vlm, 'apply_eager_profile'):
70+
self.vlm.apply_eager_profile()
71+
self.processor = AutoProcessor.from_pretrained(
72+
model_path, trust_remote_code=True,
73+
)
74+
torch.cuda.empty_cache()
75+
76+
def _init_vllm(self, model_path, raw_cfg):
77+
# Build the vLLM engine FIRST: HF tokenizers and torch spawn worker
78+
# threads, and vLLM forks its engine-core subprocess -- forking a
79+
# threaded parent deadlocks the child. Only config reads happen before
80+
# the engine is up.
81+
self.llm = self._build_engine(
82+
model_path, raw_cfg.get('lm_max_position_embeddings', 8192),
83+
)
84+
85+
self._ensure_reference_importable()
86+
from dataclasses import fields as dc_fields
87+
88+
from data.processors import apply_model_preprocess, get_image_processor, get_tokenizer
89+
from models.config import VLMConfig
90+
91+
valid = {fld.name for fld in dc_fields(VLMConfig)}
92+
self.cfg = VLMConfig(**{k: v for k, v in raw_cfg.items() if k in valid})
93+
self.tokenizer = get_tokenizer(
94+
self.cfg.lm_tokenizer,
95+
getattr(self.cfg, 'vlm_extra_tokens', None),
96+
getattr(self.cfg, 'lm_chat_template', None),
97+
)
98+
# Resolves the base/Flash resize policy from the checkpoint config.
99+
apply_model_preprocess(self.cfg)
100+
max_img = (getattr(self.cfg, 'inference_max_img_size', None)
101+
or getattr(self.cfg, 'max_img_size', self.cfg.vit_img_size))
102+
self.image_processor = get_image_processor(
103+
max_img,
104+
self.cfg.vit_img_size,
105+
getattr(self.cfg, 'resize_to_max_side_len', False),
106+
getattr(self.cfg, 'resize_min_side_len', None),
107+
)
108+
109+
@staticmethod
110+
def _build_engine(model_path, max_model_len):
111+
try:
112+
import visionpsy_vllm
113+
except ImportError:
114+
raise ImportError(_VLLM_PLUGIN_INSTALL_MSG)
115+
visionpsy_vllm.register()
116+
from vllm import LLM
117+
118+
return LLM(
119+
model=model_path,
120+
dtype='float32',
121+
max_model_len=max_model_len,
122+
limit_mm_per_prompt={'image': 128},
123+
enforce_eager=True,
124+
)
125+
126+
@staticmethod
127+
def _ensure_reference_importable():
128+
"""Expose the reference preprocessing bundled with the vLLM plugin."""
129+
try:
130+
import visionpsy_vllm
131+
except ImportError:
132+
raise ImportError(_VLLM_PLUGIN_INSTALL_MSG)
133+
ref = os.path.join(os.path.dirname(visionpsy_vllm.__file__), 'reference')
134+
if os.path.isdir(ref) and ref not in sys.path:
135+
sys.path.append(ref)
136+
137+
@staticmethod
138+
def _read_raw_config(model_path):
139+
"""Return the checkpoint's raw config.json as a dict, or None."""
140+
import json
141+
import os.path as osp
142+
try:
143+
if osp.isdir(model_path):
144+
cfg_path = osp.join(model_path, 'config.json')
145+
else:
146+
from huggingface_hub import hf_hub_download
147+
cfg_path = hf_hub_download(repo_id=model_path, filename='config.json')
148+
with open(cfg_path) as f:
149+
return json.load(f)
150+
except Exception:
151+
return None
152+
153+
# ------------------------------------------------------------------
154+
# Core generation entry point required by VLMEvalKit
155+
# ------------------------------------------------------------------
156+
def generate_inner(self, message, dataset=None):
157+
if dataset in self._MMBENCH_DATASETS:
158+
prompt, images = self._build_prompt_mmbench(message)
159+
elif dataset in ('MMMU_DEV_VAL', 'MMMU_TEST'):
160+
prompt, images = self._build_prompt_mmmu(message)
161+
elif dataset in ('MathVista_MINI',):
162+
prompt, images = self._build_prompt_mathvista(message)
163+
elif dataset in (
164+
'ChartQA_TEST', 'DocVQA_VAL', 'DocVQA_TEST',
165+
'TextVQA_VAL', 'TextVQA_TEST',
166+
):
167+
prompt, images = self._build_prompt_default(message, add_direct=True)
168+
elif dataset in (
169+
'MME', 'OCRVQA_TEST', 'OCRVQA_TESTCORE',
170+
'InfoVQA_VAL', 'InfoVQA_TEST', 'OCRBench', 'POPE',
171+
'BLINK',
172+
):
173+
prompt, images = self._build_prompt_default(message, add_brief=True)
174+
elif dataset == 'HallusionBench':
175+
prompt, images = self._build_prompt_default(message, add_yes_or_no=True)
176+
elif dataset in (
177+
'MMStar', 'SEEDBench_IMG', 'AI2D_TEST',
178+
'ScienceQA_VAL', 'ScienceQA_TEST', 'RealWorldQA',
179+
):
180+
prompt, images = self._build_prompt_puremcq(message)
181+
else:
182+
prompt, images = self._build_prompt_default(message)
183+
184+
if self.use_vllm:
185+
return self._run_generation_vllm(prompt, images)
186+
return self._run_generation(prompt, images)
187+
188+
def _run_generation(self, prompt, pil_images):
189+
"""Greedy generation through the Hub-packaged model.
190+
191+
The bundled processor reproduces the reference preprocessing (tiling,
192+
image-token string, chat template), so the adapter only supplies the
193+
per-dataset prompt text and the raw images.
194+
"""
195+
inputs = self.processor(
196+
images=pil_images if pil_images else None,
197+
text=prompt,
198+
return_tensors='pt',
199+
)
200+
inputs = {
201+
k: (v.to('cuda') if torch.is_tensor(v) else v)
202+
for k, v in inputs.items()
203+
if v is not None
204+
}
205+
inputs.pop('pixel_values', None)
206+
with torch.inference_mode():
207+
generated_ids = self.vlm.generate(
208+
**inputs,
209+
max_new_tokens=self.kwargs.get('max_new_tokens', 2048),
210+
greedy=True,
211+
)
212+
return self.processor.batch_decode(
213+
generated_ids, skip_special_tokens=True
214+
)[0].strip()
215+
216+
def _run_generation_vllm(self, prompt, pil_images):
217+
"""Greedy generation through the in-process vLLM engine.
218+
219+
The image is tiled client-side and the prompt carries the global/tile
220+
position tokens with ONE image placeholder per tile; the plugin's
221+
multimodal processor expands each placeholder to mp_image_token_length
222+
image tokens inside vLLM.
223+
"""
224+
from data.processors import get_image_string
225+
from vllm import SamplingParams
226+
227+
image_string = ''
228+
tiles = []
229+
for img in pil_images:
230+
processed, ratio = self.image_processor(img)
231+
if (not hasattr(self.tokenizer, 'global_image_token')
232+
and ratio[0] * ratio[1] == len(processed) - 1):
233+
processed = processed[1:]
234+
image_string += get_image_string(self.tokenizer, [ratio], 1)
235+
tiles.extend(processed)
236+
pil_tiles = [
237+
Image.fromarray(
238+
(t.clamp(0, 1) * 255).round().byte().permute(1, 2, 0).numpy()
239+
)
240+
for t in tiles
241+
]
242+
243+
messages = [{'role': 'user', 'content': image_string + prompt}]
244+
full_prompt = self.tokenizer.apply_chat_template(
245+
[messages], tokenize=False, add_generation_prompt=True,
246+
)
247+
if isinstance(full_prompt, list):
248+
full_prompt = full_prompt[0]
249+
250+
inputs = {'prompt': full_prompt}
251+
if pil_tiles:
252+
inputs['multi_modal_data'] = {'image': pil_tiles}
253+
outputs = self.llm.generate(
254+
inputs,
255+
SamplingParams(
256+
temperature=0.0,
257+
max_tokens=self.kwargs.get('max_new_tokens', 2048),
258+
),
259+
use_tqdm=False,
260+
)
261+
return outputs[0].outputs[0].text.strip()
262+
263+
# ------------------------------------------------------------------
264+
# Per-dataset prompt builders
265+
# ------------------------------------------------------------------
266+
_MMBENCH_DATASETS = {
267+
'MMBench_DEV_EN', 'MMBench_TEST_EN', 'MMBench_DEV_CN',
268+
'MMBench_TEST_CN', 'MMBench', 'MMBench_CN',
269+
'MMBench_DEV_EN_V11', 'MMBench_DEV_CN_V11',
270+
'MMBench_TEST_EN_V11', 'MMBench_TEST_CN_V11',
271+
'MMBench_V11', 'MMBench_CN_V11', 'CCBench',
272+
}
273+
274+
@staticmethod
275+
def _load_images(message):
276+
images = []
277+
for msg in message:
278+
if msg['type'] == 'image':
279+
img = Image.open(msg['value']).convert('RGB')
280+
images.append(img)
281+
return images
282+
283+
@staticmethod
284+
def _get_text(message):
285+
return '\n'.join(m['value'].strip() for m in message if m['type'] == 'text')
286+
287+
def _build_prompt_default(self, message, add_brief=False, add_yes_or_no=False,
288+
add_direct=False):
289+
images = self._load_images(message)
290+
text = self._get_text(message)
291+
if add_brief:
292+
text += '\nGive a very brief answer.'
293+
if add_yes_or_no:
294+
text += '\nAnswer yes or no.'
295+
if add_direct:
296+
text += '\nPlease answer directly with only the final answer, do not give any explanation.'
297+
return text, images
298+
299+
def _build_prompt_puremcq(self, message):
300+
images = self._load_images(message)
301+
text = self._get_text(message)
302+
replacements = {
303+
'\nOptions:': '\nChoices:',
304+
'Please select the correct answer from the options above.': 'Answer with the letter.',
305+
}
306+
for old, new in replacements.items():
307+
text = text.replace(old, new)
308+
text += '\nAnswer:'
309+
return text, images
310+
311+
def _build_prompt_mmbench(self, message):
312+
images = self._load_images(message)
313+
text = self._get_text(message)
314+
replacements = {
315+
'\nOptions:': '\nChoices:',
316+
'Please select the correct answer from the options above.': 'Answer with a letter.',
317+
}
318+
for old, new in replacements.items():
319+
text = text.replace(old, new)
320+
if text.startswith('Hint:'):
321+
try:
322+
hint, rest = text.split('\nQuestion:')
323+
question, choices = rest.split('\nChoices:')
324+
text = 'Question:' + question + '\n' + hint + '\nChoices:' + choices
325+
except ValueError:
326+
pass
327+
text += '\nAnswer:'
328+
return text, images
329+
330+
def _build_prompt_mmmu(self, message):
331+
images = self._load_images(message)
332+
text = self._get_text(message)
333+
replacements = {
334+
'Question:': '',
335+
'Please select the correct answer from the options above.': 'Answer with the letter.',
336+
'\nOptions:': '\nChoices:',
337+
}
338+
for old, new in replacements.items():
339+
text = text.replace(old, new)
340+
text = 'Question: ' + text.strip()
341+
if 'A.' in text and 'B.' in text:
342+
text += '\nAnswer:'
343+
return text, images
344+
345+
def _build_prompt_mathvista(self, message):
346+
images = self._load_images(message)
347+
text = self._get_text(message)
348+
replacements = {
349+
'(A) ': 'A. ', '(B) ': 'B. ', '(C) ': 'C. ', '(D) ': 'D. ',
350+
'(E) ': 'E. ', '(F) ': 'F. ', '(G) ': 'G. ', '(H) ': 'H. ',
351+
'\nOptions:': '\nChoices:',
352+
'Hint: ': '',
353+
}
354+
for old, new in replacements.items():
355+
text = text.replace(old, new)
356+
if 'A.' in text and 'B.' in text:
357+
text += '\nAnswer:'
358+
return text, images

0 commit comments

Comments
 (0)