Skip to content

Commit e6c87ff

Browse files
committed
Add glass-to-glass latency benchmark documentation and comparison image
- Introduced a new markdown file `benchmark.md` detailing the glass-to-glass latency measurement methodology, test configurations, and results for video streaming performance. - Added a new image file `comparison.png` for visual representation of benchmark results.
1 parent 68486da commit e6c87ff

12 files changed

Lines changed: 15610 additions & 14 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,22 @@ This VisionOS app and python library streams your Head + Wrist + Hand Tracking r
2929

3030
[![Star History Chart](https://api.star-history.com/svg?repos=improbable-ai/visionproteleop&type=date&legend=top-left)](https://www.star-history.com/#improbable-ai/visionproteleop&type=date&legend=top-left)
3131

32+
33+
## Benchmark Results
34+
35+
We performed comprehensive glass-to-glass latency measurements to evaluate the end-to-end performance of our video streaming system. The results show consistently low latency across all tested resolutions, with wired connections achieving **~20ms** at lower resolutions and wireless connections maintaining **~50-100ms** even at 4K.
36+
37+
For detailed methodology, test configurations, and complete results, see the **[Benchmark Documentation](docs/benchmark.md)**.
38+
39+
![](comparison.png)
40+
41+
3242
## How to Use
3343

3444
If you use this repository in your work, consider citing:
3545

3646
@software{park2024avp,
37-
title={Using Apple Vision Pro to Train and Control Robots},
47+
title={Using Apple Vision Pro to Train and Control Robots},`
3848
author={Park, Younghyo and Agrawal, Pulkit},
3949
year={2024},
4050
url = {https://github.com/Improbable-AI/VisionProTeleop},

Tracking Streamer/StatusView.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ struct StatusOverlay: View {
4545
@State private var webrtcConnected: Bool = false
4646
@State private var hidePreviewTask: Task<Void, Never>?
4747
@State private var showStatusPositionControls: Bool = false
48+
@State private var showExitConfirmation: Bool = false
4849

4950
init(hasFrames: Binding<Bool> = .constant(false), showVideoStatus: Bool = true, isMinimized: Binding<Bool> = .constant(false), showViewControls: Binding<Bool> = .constant(false), previewZDistance: Binding<Float?> = .constant(nil), previewActive: Binding<Bool> = .constant(false), userInteracted: Binding<Bool> = .constant(false), videoMinimized: Binding<Bool> = .constant(false), videoFixed: Binding<Bool> = .constant(false), previewStatusPosition: Binding<(x: Float, y: Float)?> = .constant(nil), previewStatusActive: Binding<Bool> = .constant(false)) {
5051
self._hasFrames = hasFrames
@@ -161,7 +162,7 @@ struct StatusOverlay: View {
161162
.buttonStyle(.plain)
162163

163164
Button {
164-
exit(0)
165+
showExitConfirmation = true
165166
} label: {
166167
ZStack {
167168
Circle()
@@ -173,10 +174,17 @@ struct StatusOverlay: View {
173174
}
174175
}
175176
.buttonStyle(.plain)
177+
.confirmationDialog("Are you sure you want to exit?", isPresented: $showExitConfirmation, titleVisibility: .visible) {
178+
Button("Exit", role: .destructive) {
179+
exit(0)
180+
}
181+
Button("Cancel", role: .cancel) {}
182+
}
176183
}
177184
.padding(30)
178185
.background(Color.black.opacity(0.6))
179186
.cornerRadius(36)
187+
.fixedSize()
180188
}
181189

182190
private var expandedView: some View {
@@ -206,7 +214,7 @@ struct StatusOverlay: View {
206214
Spacer()
207215

208216
Button {
209-
exit(0)
217+
showExitConfirmation = true
210218
} label: {
211219
ZStack {
212220
Circle()
@@ -218,6 +226,12 @@ struct StatusOverlay: View {
218226
}
219227
}
220228
.buttonStyle(.plain)
229+
.confirmationDialog("Are you sure you want to exit?", isPresented: $showExitConfirmation, titleVisibility: .visible) {
230+
Button("Exit", role: .destructive) {
231+
exit(0)
232+
}
233+
Button("Cancel", role: .cancel) {}
234+
}
221235
}
222236

223237
Divider()
@@ -853,6 +867,7 @@ struct StatusPreviewView: View {
853867
.padding(30)
854868
.background(Color.black.opacity(0.6))
855869
.cornerRadius(36)
870+
.fixedSize()
856871
.opacity(0.5) // 50% transparent
857872
}
858873
}

Tracking Streamer/Supporting files/Localizable.xcstrings

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,19 @@
3636
}
3737
}
3838
},
39+
"Are you sure you want to exit?" : {
40+
"comment" : "A confirmation dialog that appears when the user taps the exit button.",
41+
"isCommentAutoGenerated" : true
42+
},
3943
"Audio:" : {
4044

4145
},
4246
"Auto-Perpendicular" : {
4347

48+
},
49+
"Cancel" : {
50+
"comment" : "A button that cancels the exit confirmation dialog.",
51+
"isCommentAutoGenerated" : true
4452
},
4553
"Connected" : {
4654
"comment" : "The text that appears when the user's WebRTC connection is established.",

avp_stream/latency_test.py

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ def main():
8282
parser.add_argument("--resolution", type=str, default="640x480", help="Streaming resolution (WIDTHxHEIGHT)")
8383
parser.add_argument("--sweep", action="store_true", help="Sweep over common resolutions")
8484
parser.add_argument("--trials", type=int, default=1000, help="Number of trials per resolution in sweep mode")
85+
parser.add_argument("--warmup", type=int, default=100, help="Number of warmup frames before collecting data")
86+
parser.add_argument("--stereo", action="store_true", help="Enable stereo video streaming (doubles width)")
8587
args = parser.parse_args()
8688

8789
print(f"Connecting to Vision Pro at {args.ip}...")
@@ -97,14 +99,26 @@ def __init__(self):
9799
self.collecting = False
98100
self.target_trials = 0
99101
self.current_resolution = ""
102+
self.last_frame_time = 0
103+
self.warmup_frames = 0
104+
self.in_warmup = False
100105

101-
state = LatencyState()
106+
state = LatencyState()
102107

103108
def frame_callback(frame):
104109
# 1. Get current timestamp
105110
now = time.perf_counter()
106111
if state.sequence_id == 0:
107112
state.streamer.reset_benchmark_epoch(now)
113+
state.last_frame_time = now
114+
else:
115+
# Sleep to maintain target FPS (30fps = 33.33ms per frame)
116+
elapsed = now - state.last_frame_time
117+
target_frame_time = 1.0 / args.fps
118+
# if elapsed < target_frame_time:
119+
# time.sleep(target_frame_time - elapsed)
120+
now = time.perf_counter()
121+
state.last_frame_time = now
108122

109123
timestamp_ms = int((now - state.streamer._benchmark_epoch) * 1000)
110124

@@ -120,17 +134,32 @@ def frame_callback(frame):
120134
streamer.register_frame_callback(frame_callback)
121135

122136
# Define resolutions for sweep
123-
resolutions = [
124-
("240p", "426x240"),
125-
("360p", "640x360"),
126-
("720p", "1280x720"),
127-
("1080p", "1920x1080"),
128-
("2160p", "3840x2160")
129-
]
137+
if args.stereo:
138+
# Double the width for stereo mode
139+
resolutions = [
140+
("240p", "852x240"),
141+
("360p", "1280x360"),
142+
("720p", "2560x720"),
143+
("1080p", "3840x1080"),
144+
("2160p", "7680x2160")
145+
]
146+
else:
147+
resolutions = [
148+
("240p", "426x240"),
149+
("360p", "640x360"),
150+
("720p", "1280x720"),
151+
("1080p", "1920x1080"),
152+
("2160p", "3840x2160")
153+
]
130154

131155
if not args.sweep:
132156
# Single resolution mode
133-
streamer.start_video_streaming(device=None, size=args.resolution, fps=args.fps)
157+
resolution = args.resolution
158+
if args.stereo:
159+
# Double the width for stereo
160+
width, height = map(int, resolution.split('x'))
161+
resolution = f"{width * 2}x{height}"
162+
streamer.start_video_streaming(device=None, size=resolution, fps=args.fps, stereo_video=args.stereo)
134163
print("Streaming started. Measuring latency...")
135164
try:
136165
while True:
@@ -141,7 +170,7 @@ def frame_callback(frame):
141170
# Sweep mode
142171
# Start with the first resolution
143172
first_res_name, first_res_size = resolutions[0]
144-
streamer.start_video_streaming(device=None, size=first_res_size, fps=args.fps)
173+
streamer.start_video_streaming(device=None, size=first_res_size, fps=args.fps, stereo_video=args.stereo)
145174

146175
print(f"Starting sweep over {len(resolutions)} resolutions: {[r[0] for r in resolutions]}")
147176

@@ -151,7 +180,9 @@ def frame_callback(frame):
151180
"test_config": {
152181
"ip": args.ip,
153182
"fps": args.fps,
154-
"trials_per_resolution": args.trials
183+
"trials_per_resolution": args.trials,
184+
"warmup_frames": args.warmup,
185+
"stereo": args.stereo
155186
},
156187
"resolutions": {}
157188
}
@@ -169,6 +200,33 @@ def frame_callback(frame):
169200
state.latencies = []
170201
state.collecting = True
171202
state.target_trials = args.trials
203+
state.warmup_frames = args.warmup
204+
state.in_warmup = True
205+
206+
# Warmup phase
207+
print(f"🔥 Warming up ({args.warmup} frames)...")
208+
warmup_start_seq = state.sequence_id
209+
warmup_pbar = tqdm(total=args.warmup, desc="Warmup", unit="frames")
210+
211+
warmup_check_seq = warmup_start_seq
212+
warmup_collected = 0
213+
214+
while warmup_collected < args.warmup:
215+
event = streamer.wait_for_benchmark_event(warmup_check_seq, timeout=1.0)
216+
217+
if event:
218+
warmup_collected += 1
219+
warmup_pbar.update(1)
220+
warmup_check_seq += 1
221+
else:
222+
# Check if we should skip
223+
if state.sequence_id > warmup_check_seq + 100:
224+
warmup_pbar.write(f"⚠️ Skipping missing warmup sequence {warmup_check_seq}")
225+
warmup_check_seq += 1
226+
227+
warmup_pbar.close()
228+
state.in_warmup = False
229+
print(f"✅ Warmup complete. Starting data collection...")
172230

173231
# Collect samples
174232
# We need to capture the return events.

avp_stream/plot_benchmarks.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import json
2+
import argparse
3+
import matplotlib.pyplot as plt
4+
import numpy as np
5+
from pathlib import Path
6+
7+
def load_benchmark(filepath):
8+
"""Load a benchmark JSON file."""
9+
with open(filepath, 'r') as f:
10+
return json.load(f)
11+
12+
def plot_benchmarks(json_files, output_path=None):
13+
"""
14+
Plot multiple benchmark results comparing mean latency and jitter across resolutions.
15+
16+
Args:
17+
json_files: List of paths to benchmark JSON files
18+
output_path: Optional path to save the plot (if None, displays interactively)
19+
"""
20+
# Color palette for different benchmark files
21+
colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
22+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
23+
24+
# Resolution order and labels
25+
resolution_order = ['240p', '360p', '720p', '1080p', '2160p']
26+
resolution_labels = {
27+
'240p': '240p\n(426×240)',
28+
'360p': '360p\n(640×360)',
29+
'720p': '720p\n(1280×720)',
30+
'1080p': '1080p\n(1920×1080)',
31+
'2160p': '2160p\n(3840×2160)'
32+
}
33+
34+
# Load all benchmark data
35+
benchmarks = []
36+
for filepath in json_files:
37+
data = load_benchmark(filepath)
38+
label = Path(filepath).stem # Use filename without extension as label
39+
benchmarks.append({
40+
'label': label,
41+
'data': data
42+
})
43+
44+
# Create figure with single plot
45+
fig, ax2 = plt.subplots(1, 1, figsize=(10, 6))
46+
# fig.suptitle('Glass-to-Glass Latency Benchmark Comparison', fontsize=16, fontweight='bold')
47+
48+
x_positions = np.arange(len(resolution_order))
49+
50+
# Plot jitter (standard deviation) as line plot with shaded region
51+
for idx, benchmark in enumerate(benchmarks):
52+
means = []
53+
stds = []
54+
p95s = []
55+
p99s = []
56+
57+
for res in resolution_order:
58+
if res in benchmark['data']['resolutions']:
59+
res_data = benchmark['data']['resolutions'][res]
60+
if 'error' not in res_data:
61+
means.append(res_data['mean_ms'])
62+
stds.append(res_data['std_ms'])
63+
p95s.append(res_data.get('p95_ms', 0))
64+
p99s.append(res_data.get('p99_ms', 0))
65+
else:
66+
means.append(0)
67+
stds.append(0)
68+
p95s.append(0)
69+
p99s.append(0)
70+
else:
71+
means.append(0)
72+
stds.append(0)
73+
p95s.append(0)
74+
p99s.append(0)
75+
76+
means = np.array(means)
77+
stds = np.array(stds)
78+
79+
color = colors[idx % len(colors)]
80+
81+
# Plot mean line
82+
ax2.plot(x_positions, means, marker='o', linewidth=2,
83+
label=f'{benchmark["label"]}', color=color, markersize=8)
84+
85+
# Plot shaded region for ±1 std deviation (no legend entry)
86+
ax2.fill_between(x_positions, means - stds, means + stds,
87+
alpha=0.3, color=color)
88+
89+
ax2.set_xlabel('Resolution', fontsize=12, fontweight='bold')
90+
ax2.set_ylabel('Latency (ms)', fontsize=12, fontweight='bold')
91+
ax2.set_title('Glass-to-Glass Latency Test', fontsize=13, fontweight='bold', pad=10)
92+
ax2.set_xticks(x_positions)
93+
ax2.set_xticklabels([resolution_labels[r] for r in resolution_order])
94+
ax2.legend(loc='upper left', framealpha=0.9, fontsize=9)
95+
ax2.grid(True, alpha=0.3, linestyle='--')
96+
ax2.set_axisbelow(True)
97+
98+
# Add test configuration info as text
99+
if benchmarks:
100+
config = benchmarks[0]['data']['test_config']
101+
info_text = f"Test Config: {config['trials_per_resolution']} trials/resolution. For stereo tests, width is doubled."
102+
if 'warmup_frames' in config:
103+
info_text += f", {config['warmup_frames']} warmup frames"
104+
fig.text(0.5, 0.02, info_text, ha='center', fontsize=10, style='italic', color='gray')
105+
106+
plt.tight_layout(rect=[0, 0.04, 1, 0.96])
107+
108+
if output_path:
109+
plt.savefig(output_path, dpi=300, bbox_inches='tight')
110+
print(f"✅ Plot saved to: {output_path}")
111+
else:
112+
plt.show()
113+
114+
def main():
115+
parser = argparse.ArgumentParser(description="Plot multiple benchmark results for comparison")
116+
parser.add_argument('json_files', nargs='+', help='Paths to benchmark JSON files')
117+
parser.add_argument('--output', '-o', type=str, help='Output path for the plot (PNG/PDF)')
118+
args = parser.parse_args()
119+
120+
# Verify all files exist
121+
for filepath in args.json_files:
122+
if not Path(filepath).exists():
123+
print(f"❌ Error: File not found: {filepath}")
124+
return
125+
126+
print(f"📊 Plotting {len(args.json_files)} benchmark file(s)...")
127+
plot_benchmarks(args.json_files, args.output)
128+
129+
if __name__ == "__main__":
130+
main()

0 commit comments

Comments
 (0)