File size: 10,480 Bytes
af89935
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
---
license: apache-2.0
language:
- en
base_model:
- Qwen/Qwen2.5-VL-3B-Instruct
tags:
- R2R
- VLN
- Room-to-Room
- LVLM
---

# Qwen2.5-VL-3B-R2R-panoramic

**Qwen2.5-VL-3B-R2R-panoramic** is a Vision-and-Language Navigation (VLN) model fine-tuned from [Qwen2.5-VL-3B-Instruct](https://huggingface.co/Qwen/Qwen2.5-VL-3B-Instruct) on the [Room-to-Room (R2R)](https://bringmeaspoon.org/) dataset using the Matterport3D (MP3D) simulator. The model is trained using a panoramic action space, where the model recives a preprocessed panoramic image and a set of candidate views which each point towards a node in a Matterport3D simualtor environment.  

Only the LLM component is fine-tuned — the vision encoder and cross-modal projector are kept frozen.


## 🧠 Model Summary

- **Base Model**: Qwen2.5-VL-3B-Instruct
- **Dataset**: Room-to-Room (R2R) via the Matterport3D simulator.
- **Image Resolution**: 320x240 for candidate images and 960×240 for panoramic images.
- **Action Space**: Panoramic.

## 🧪 Training Setup

- **Frozen Modules**: Vision encoder and cross-modal projector  
- **Fine-Tuned Module**: LLM decoder (Qwen2.5)  
- **Optimizer**: AdamW  
- **Batch Size**: `1` (with gradient accumulation over each episode)  
- **Learning Rate**: `1e-5`  
- **Weight Decay**: `0.1`  
- **Precision**: `bfloat16`  
- **LR Scheduler**:  Linear scheduler with warmup (first 10% of steps)  
- **Hardware**: Trained on a single NVIDIA A100 80GB GPU  

Training was done using supervised learning for next-action prediction. The model was conditioned at each step with a system prompt, panoramic RGB image observations (960×240) of current view, as well as variable amount of candidate RGB iamges (320x240), and cumulative episode history including previosu panoramas. The model was trained offline (not in the MP3D simulator) using teacher-forcing on a preprocessed R2R dataset.


## 📦 Usage 
```python
import torch
from torch.utils.data import Dataset, DataLoader
from datasets import Dataset as DT
from transformers import Qwen2_5_VLForConditionalGeneration, AutoTokenizer, AutoProcessor
from PIL import Image

lass CustomDataset(Dataset):
    def __init__(self, data):
        self.text = data["text"]
        self.panoramas = data["panoramas"]
        self.candidates = data["candidates"]
        
    def __len__(self):
        return len(self.text)
    
    def __getitem__(self, index):
        return self.text[index], self.panoramas[index], self.candidates[index]

# TODO: make the collatefunctor work with batches
class CollateFunctor:
    # No batch, therefore no max length
    def __init__(self, processor, width, height):
        self.processor = processor
        self.width = width
        self.height = height

    def __call__(self, batch):
        text, panoramas, candidates = batch[0]
        label_start = processor.tokenizer("<|im_start|>assistant\nCandidate: ", return_tensors="pt").input_ids

        images = [Image.open(img) for img in panoramas]
        candidate_images = [Image.open(img) for img in candidates]
        #candidate_images = [Image.open(img).resize((self.width, self.height), Image.Resampling.LANCZOS) for img in candidates]
        images.extend(candidate_images)
        
        processed = processor(text=text, images=[images], return_tensors="pt")

        prompt_input_ids = processed["input_ids"]
        input_ids = torch.cat([prompt_input_ids, label_start], dim=1)
        
        attention_mask = torch.ones(1, input_ids.shape[1])
        processed["input_ids"] = input_ids
        processed["attention_mask"] = attention_mask
        
        return processed


def format_prompt(images_path, path_id, route_instruction, step_id, distance_traveled, candidates, processor, system_prompt):
    # should be in the order: panorama_history, current_panorama, candidates views from left to right
    images = os.listdir(images_path)
    panoramas = [os.path.join(images_path, img) for img in images if img.startswith("pano")]
    panoramas = sorted(panoramas, key=lambda x: int(x.split("_")[-1].split(".")[-2]))

    # these are probably sorted by default, however you might need to check
    candidate_images = [os.path.join(images_path, img) for img in images if img.startswith("pano") == False]
    candidate_images = sorted(candidate_images, key=lambda x: int(x.split("_")[-1].split(".")[0]))
    
    current_panorama = panoramas.pop(-1)

    # route instruction, current step, cumulative distance
    content = [
        {
            "type" : "text",
            "text" : f"Route instruction: {route_instruction}\nCurrent step: {step_id}\nCumulative Distance Traveled: {distance_traveled} meters\n\nPanorama Images from Previous Steps:"
        }
    ]

    # panorama from previous steps
    for i, img in enumerate(panoramas):
        content.append({
            "type" : "text",
            "text" : f"\n\tPanorama at step: {i}: "
        })
        content.append({
            "type" : "image",
            "image" : img
        })

    if len(panoramas) == 0:
        content[0]["text"] += f"[]"

    # current panorama
    content.append({
        "type" : "text",
        "text" : f"\n\nCurrent Panorama Image:\n\t"
    })

    content.append({
        "type" : "image",
        "image" : current_panorama
    })

    # candidate directions
    content.append({
        "type" : "text",
        "text" : "\n\nCandidate Directions:"
    })

    for i, candidate in enumerate(candidates):
        relative_angle = round(candidate["relative_angle"], 0)
        distance = round(candidate["distance"], 2)
        direction = "Left" if relative_angle < 0 else "Right"
        
        content.append({
            "type" : "text",
            "text" : f"\n\tCandidate: {i}:\n\t\tRelative angle: {abs(relative_angle)} degrees to the {direction}\n\t\tDistance: {distance} meters\n\t\tview: "
        })

        content.append({
            "type" : "image",
            "image" : candidate_images[i]
        })


    # adds candidate STOP and the select cnadidate view 
    content.append({
        "type" : "text",
        "text" : "\n\tCandidate: Stop\n\nNow, analyze the route instruction, your current position, and the available candidate directions. Select the candidate that best matches the instruction and helps you continue along the correct path. Answer on the format: Candidate: (and then the number)"
    })

    messages = [
        {"role" : "system", "content" : [{"type" : "text", "text" : system_prompt}]},
        {"role" : "user", "content" : content},
    ]

    text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)

    panoramas.extend([current_panorama])

    formatted_sample = {}
    formatted_sample["text"] = text
    formatted_sample["candidates"] = candidate_images
    formatted_sample["panoramas"] = panoramas

    formatted_data = [formatted_sample] 
    formatted_data = DT.from_list(formatted_data)
    return formatted_data


processor = AutoProcessor.from_pretrained("Vebbern/Qwen2.5-VL-3B-R2R-panoramic")
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
    "Vebbern/Qwen2.5-VL-3B-R2R-panoramic",
    torch_dtype=torch.bfloat16,
    attn_implementation="flash_attention_2",
    device_map="cuda"
)

# remember to set the correct image resolution (however a higher might still work as the vision encoder is not trained)
collate_fn = CollateFunctor(processor, 320, 240)

# Load mandatory system prompt
with open("system_prompt.txt", "r") as f:
    system_prompt = f.read()

path_id = 4332 # id for the R2R path
route_instruction = "Walk to the other end of the lobby and wait near the exit. "
images_path = f"./images/{path_id}"
step_id = 0
cumulative_distance = 0
candidates = {
    "0" : {
        "relative_angle" -60.62797609213225,
        "relative_direction": "Left",
        "distance": 2.3325929641723633
    },
    "1": {
            "relative_angle": -0.00397697185949581,
            "relative_direction": "Front",
            "distance": 4.637096405029297
        },
    "2": {
        "relative_angle": 25.24592108757226,
        "relative_direction": "Front",
        "distance": 3.3661904335021973
        }
}

prompt = format_prompt(images_path, path_id, route_instruction, step_id, cumulative_distance, candidates, processor, system_prompt)

dataset = CustomDataset(prompt)
data_loader = DataLoader(
    dataset,
    batch_size=1,
    collate_fn=collate_fn
)

# Run inference
for batch in data_loader:
    batch.to("cuda")
            
    outputs = model(**batch)
    argmax = torch.argmax(outputs.logits, dim=2)[0]
    model_prediction = processor.decode(argmax[-1]) # is -1 because it does not predict one more
    print(f"Predicted action: {model_prediction}")

```

> ⚠️ Sorry for the rough code — the goal here is to show how the system prompt and inputs should be structured for inference. The system prompt is included in the repo.


## 📊 Evaluation Results

The model was evaluated on the standard Room-to-Room (R2R) validation sets using the Matterport3D simulator. Performance is measured using the standard VLN (Vision-and-Language Navigation) metrics.

| Metric                  | Val Seen | Val Unseen | Test  |
|-------------------------|----------|------------|-------|
| Path Length (↓)         | 9.98     | 9.83      | 9.96 |
| Navigation Error (↓)    | 5.69     | 6.65       | 6.53  |
| Oracle Success Rate (↑) | 56%      | 46%        | 50%   |
| Success Rate (↑)        | 50%      | 38%        | 41%   |
| SPL (↑)                 | 47%      | 35%        | 38%   |

### 🧾 Metric Definitions
- **Navigation Error**: Mean distance from the goal when the agent stops.
- **Success Rate**: Percentage of episodes where the agent ends within 3 meters of the goal.
- **SPL (Success weighted by Path Length)**: Penalizes long or inefficient paths.
- **Oracle Success**: If the agent had stopped at its closest point to the goal.

### 📝 Remarks

This model performs far behind R2R State-of-the-art models, likely due to a combination of factors such as underlying model archtiecture, training strategy, and panoramic representation. 

## 🔁 Related Models
There also exists a low-level action space eqivalent of this model.
- **Low-Level Action Space Version**: [Qwen2.5-VL-3B-R2R-low-level](https://huggingface.co/Vebbern/Qwen2.5-VL-3B-R2R-low-level)

## 🪪 License

This model is licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).