robomind-vla / app.py
mitvho09's picture
RoboMind VLA: vision-language reward model for robot locomotion (built with Codex)
321ba64 verified
Raw
History Blame Contribute Delete
22.6 kB
"""
RoboMind VLA — Task 9: app.py
FastAPI web UI for the RoboMind VLA reward judge.
Runs on Modal GPU with a public URL.
Usage:
modal deploy app.py
Then visit the public URL printed in the output.
"""
import json
import os
import tempfile
from typing import List
import modal
app_image = (
modal.Image.debian_slim(python_version="3.11")
.apt_install("ffmpeg")
.pip_install(
"torch==2.4.0",
"torchvision==0.19.0",
"transformers==4.40.0",
"peft==0.11.1",
"accelerate==0.30.1",
"pillow",
"sentencepiece",
"huggingface_hub",
"fastapi==0.115.6",
"uvicorn",
"pydantic<2.13",
"numpy<2",
"opencv-python-headless",
"python-multipart",
"librosa==0.10.2",
"soundfile",
)
.run_commands(
"python -c \""
"import os, sys; "
"d = os.path.join(sys.prefix, 'lib/python3.11/site-packages/flash_attn'); "
"os.makedirs(d, exist_ok=True); "
"open(os.path.join(d, '__init__.py'), 'w').write(''); "
"open(os.path.join(d, 'flash_attn_interface.py'), 'w').write("
"'def flash_attn_func(*a, **kw): raise NotImplementedError\\n"
"def flash_attn_varlen_func(*a, **kw): raise NotImplementedError\\n'); "
"print('flash_attn stub created')\""
)
.add_local_file("hybrid_judge.py", "/root/hybrid_judge.py")
)
app = modal.App("robomind-gradio", image=app_image)
volume = modal.Volume.from_name("robomind-data", create_if_missing=True)
ADAPTER_REPO = "mitvho09/robomind-minicpm-loco-lora"
INSTRUCTION_PROMPT = (
"You are RoboMind VLA, a vision-language reward model for robot locomotion. "
"You are shown keyframes from a MuJoCo locomotion rollout. "
"The robot was commanded to \"walk forward\". Analyze the rollout and "
"respond with ONLY a JSON object with these exact keys: timestep_range, "
"phase, command, command_followed, stability, fall_risk, gait_quality, "
"predicted_reward, anomaly, explanation."
)
METADATA_PATH = "/data/rollouts/metadata.jsonl"
HTML_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RoboMind VLA — Locomotion Reward Judge</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
.container{max-width:960px;margin:0 auto;padding:2rem 1rem}
h1{font-size:2rem;margin-bottom:.5rem;color:#38bdf8}
.subtitle{color:#94a3b8;margin-bottom:2rem}
.tabs{display:flex;gap:.5rem;margin-bottom:1.5rem}
.tab{padding:.6rem 1.2rem;background:#1e293b;border:1px solid #334155;border-radius:8px;cursor:pointer;color:#94a3b8;transition:.2s}
.tab.active{background:#334155;color:#38bdf8;border-color:#38bdf8}
.tab-content{display:none}
.tab-content.active{display:block}
.upload-area{border:2px dashed #334155;border-radius:12px;padding:2rem;text-align:center;transition:.2s;cursor:pointer}
.upload-area:hover{border-color:#38bdf8}
.upload-area input[type=file]{display:none}
.upload-area label{cursor:pointer;color:#94a3b8;font-size:1.1rem}
.btn{background:#0ea5e9;color:white;border:none;padding:.8rem 1.5rem;border-radius:8px;font-size:1rem;cursor:pointer;margin-top:1rem;transition:.2s}
.btn:hover{background:#0284c7}
.btn:disabled{background:#334155;color:#64748b;cursor:not-allowed}
.result{margin-top:1.5rem;background:#1e293b;border:1px solid #334155;border-radius:12px;padding:1.5rem;white-space:pre-wrap;font-family:'Fira Code',monospace;font-size:.9rem;overflow-x:auto;max-height:500px;overflow-y:auto}
.raw{margin-top:1rem;color:#94a3b8;font-size:.85rem;white-space:pre-wrap}
.hybrid{margin-top:1rem;background:#0c1929;border:1px solid #0ea5e9;border-radius:12px;padding:1.2rem}
.hybrid h3{color:#38bdf8;margin-bottom:.5rem;font-size:1rem}
.hybrid-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.5rem}
.hybrid-item{background:#1e293b;padding:.5rem;border-radius:6px}
.hybrid-item .label{color:#64748b;font-size:.75rem;text-transform:uppercase}
.hybrid-item .value{color:#e2e8f0;font-size:1.1rem;font-weight:bold}
.hybrid-item .value.good{color:#22c55e}
.hybrid-item .value.warn{color:#f59e0b}
.hybrid-item .value.bad{color:#ef4444}
.url-input{width:100%;padding:.8rem;background:#1e293b;border:1px solid #334155;border-radius:8px;color:#e2e8f0;font-size:1rem;margin-top:.5rem}
.spinner{display:inline-block;width:20px;height:20px;border:3px solid #334155;border-top-color:#38bdf8;border-radius:50%;animation:spin .6s linear infinite;margin-right:.5rem;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.preview{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem}
.preview img{max-width:120px;max-height:120px;border-radius:8px;border:1px solid #334155}
</style>
</head>
<body>
<div class="container">
<h1>RoboMind VLA</h1>
<p class="subtitle">Humanoid Locomotion Reward Judge</p>
<div class="tabs">
<div class="tab active" onclick="switchTab(0)">Image Upload</div>
<div class="tab" onclick="switchTab(1)">Video Upload</div>
<div class="tab" onclick="switchTab(2)">URL</div>
</div>
<div class="tab-content active" id="tab0">
<div class="upload-area" id="img-drop">
<label>Click or drag up to 6 keyframe images here</label><br>
<input type="file" id="img-input" accept="image/*" multiple>
</div>
<div class="preview" id="img-preview"></div>
<button class="btn" id="img-btn" onclick="judgeImages()" disabled>Judge Rollout</button>
<div class="hybrid" id="img-hybrid" style="display:none"></div>
<div class="result" id="img-result" style="display:none"></div>
<div class="raw" id="img-raw" style="display:none"></div>
</div>
<div class="tab-content" id="tab1">
<div class="upload-area" id="vid-drop">
<label>Click or drag a rollout video here</label><br>
<input type="file" id="vid-input" accept="video/*">
</div>
<p id="vid-name" style="margin-top:.5rem;color:#94a3b8"></p>
<button class="btn" id="vid-btn" onclick="judgeVideo()" disabled>Judge Rollout</button>
<div class="hybrid" id="vid-hybrid" style="display:none"></div>
<div class="result" id="vid-result" style="display:none"></div>
<div class="raw" id="vid-raw" style="display:none"></div>
</div>
<div class="tab-content" id="tab2">
<input class="url-input" id="url-input" placeholder="https://example.com/rollout.mp4">
<button class="btn" id="url-btn" onclick="judgeUrl()">Judge Rollout</button>
<div class="hybrid" id="url-hybrid" style="display:none"></div>
<div class="result" id="url-result" style="display:none"></div>
<div class="raw" id="url-raw" style="display:none"></div>
</div>
</div>
<script>
function switchTab(i){
document.querySelectorAll('.tab').forEach((t,j)=>t.classList.toggle('active',j===i));
document.querySelectorAll('.tab-content').forEach((t,j)=>t.classList.toggle('active',j===i));
}
const imgInput=document.getElementById('img-input');
const imgDrop=document.getElementById('img-drop');
const imgPreview=document.getElementById('img-preview');
imgDrop.onclick=()=>imgInput.click();
imgInput.onchange=()=>{
const files=[...imgInput.files];
imgPreview.innerHTML='';
files.forEach(f=>{
const img=document.createElement('img');
img.src=URL.createObjectURL(f);
imgPreview.appendChild(img);
});
document.getElementById('img-btn').disabled=files.length===0;
};
imgDrop.ondragover=e=>{e.preventDefault();imgDrop.style.borderColor='#38bdf8'};
imgDrop.ondragleave=()=>{imgDrop.style.borderColor='#334155'};
imgDrop.ondrop=e=>{
e.preventDefault();imgDrop.style.borderColor='#334155';
imgInput.files=e.dataTransfer.files;imgInput.onchange();
};
const vidInput=document.getElementById('vid-input');
const vidDrop=document.getElementById('vid-drop');
vidDrop.onclick=()=>vidInput.click();
vidInput.onchange=()=>{
document.getElementById('vid-name').textContent=vidInput.files[0]?.name||'';
document.getElementById('vid-btn').disabled=!vidInput.files.length;
};
vidDrop.ondragover=e=>{e.preventDefault();vidDrop.style.borderColor='#38bdf8'};
vidDrop.ondragleave=()=>{vidDrop.style.borderColor='#334155'};
vidDrop.ondrop=e=>{
e.preventDefault();vidDrop.style.borderColor='#334155';
vidInput.files=e.dataTransfer.files;vidInput.onchange();
};
function showHybrid(containerId, hybrid) {
const el = document.getElementById(containerId);
if (!hybrid) { el.style.display='none'; return; }
const reward = hybrid.predicted_reward;
const cls = reward >= 0.7 ? 'good' : reward >= 0.3 ? 'warn' : 'bad';
el.innerHTML = `
<h3>Hybrid Score (VLM + Physics)</h3>
<div class="hybrid-grid">
<div class="hybrid-item"><div class="label">Final Reward</div><div class="value ${cls}">${reward.toFixed(3)}</div></div>
<div class="hybrid-item"><div class="label">VLM Reward</div><div class="value">${hybrid.vlm_reward.toFixed(3)}</div></div>
<div class="hybrid-item"><div class="label">Rule Reward</div><div class="value">${hybrid.rule_reward.toFixed(3)}</div></div>
<div class="hybrid-item"><div class="label">Confidence</div><div class="value">${hybrid.confidence.toFixed(2)}</div></div>
<div class="hybrid-item"><div class="label">Stability</div><div class="value">${hybrid.stability}</div></div>
<div class="hybrid-item"><div class="label">Gait Quality</div><div class="value">${hybrid.gait_quality.toFixed(3)}</div></div>
</div>
${hybrid.anomaly ? '<p style="color:#f59e0b;margin-top:.5rem">Anomaly: '+hybrid.anomaly+'</p>' : ''}
${hybrid.sound_analysis && !hybrid.sound_analysis.error ? '<div style="margin-top:.8rem;padding:.5rem;background:#1e293b;border-radius:8px"><strong style="color:#38bdf8">Audio Analysis</strong><br>'+
'Fall detected: '+(hybrid.sound_analysis.has_fall ? '<span style="color:#ef4444">YES</span>' : 'No')+'<br>'+
'Impacts: '+hybrid.sound_analysis.impact_count+'<br>'+
'Gait rhythm: '+(hybrid.sound_analysis.has_rhythmic_gait ? '<span style="color:#22c55e">Regular</span>' : 'Irregular')+'<br>'+
'Motor strain: '+(hybrid.sound_analysis.has_motor_strain ? '<span style="color:#f59e0b">High</span>' : 'Normal')+
'</div>' : ''}
`;
el.style.display='block';
}
async function judgeImages(){
const btn=document.getElementById('img-btn');
const res=document.getElementById('img-result');
const raw=document.getElementById('img-raw');
btn.disabled=true;btn.innerHTML='<span class="spinner"></span>Analyzing...';
res.style.display='none';raw.style.display='none';
const fd=new FormData();
[...imgInput.files].forEach(f=>fd.append('files',f));
try{
const r=await fetch('/judge/images',{method:'POST',body:fd});
const d=await r.json();
res.textContent=JSON.stringify(d.parsed,null,2);res.style.display='block';
showHybrid('img-hybrid', d.hybrid);
raw.textContent=d.raw;raw.style.display='block';
}catch(e){res.textContent='Error: '+e.message;res.style.display='block';}
btn.disabled=false;btn.textContent='Judge Rollout';
}
async function judgeVideo(){
const btn=document.getElementById('vid-btn');
const res=document.getElementById('vid-result');
const raw=document.getElementById('vid-raw');
btn.disabled=true;btn.innerHTML='<span class="spinner"></span>Analyzing...';
res.style.display='none';raw.style.display='none';
const fd=new FormData();
fd.append('file',vidInput.files[0]);
try{
const r=await fetch('/judge/video',{method:'POST',body:fd});
const d=await r.json();
res.textContent=JSON.stringify(d.parsed,null,2);res.style.display='block';
showHybrid('vid-hybrid', d.hybrid);
raw.textContent=d.raw;raw.style.display='block';
}catch(e){res.textContent='Error: '+e.message;res.style.display='block';}
btn.disabled=false;btn.textContent='Judge Rollout';
}
async function judgeUrl(){
const btn=document.getElementById('url-btn');
const res=document.getElementById('url-result');
const raw=document.getElementById('url-raw');
btn.disabled=true;btn.innerHTML='<span class="spinner"></span>Analyzing...';
res.style.display='none';raw.style.display='none';
const url=document.getElementById('url-input').value;
try{
const r=await fetch('/judge/url',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
const d=await r.json();
res.textContent=JSON.stringify(d.parsed,null,2);res.style.display='block';
showHybrid('url-hybrid', d.hybrid);
raw.textContent=d.raw;raw.style.display='block';
}catch(e){res.textContent='Error: '+e.message;res.style.display='block';}
btn.disabled=false;btn.textContent='Judge Rollout';
}
</script>
</body>
</html>"""
def _extract_keyframes(video_path: str, n_frames: int = 6):
import cv2
from PIL import Image
cap = cv2.VideoCapture(video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
if total <= 0:
cap.release()
return []
indices = [int(i * total / n_frames) for i in range(n_frames)]
frames = []
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ret, frame = cap.read()
if ret:
frames.append(Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)))
cap.release()
return frames
_model_cache = {}
def _get_model():
if "model" in _model_cache:
return _model_cache["model"], _model_cache["tokenizer"]
import torch
from transformers import AutoModel, AutoTokenizer
from peft import PeftModel
from huggingface_hub import login
hf_token = os.environ.get("HF_TOKEN")
if hf_token:
login(token=hf_token)
print("[robomind] loading model...")
tokenizer = AutoTokenizer.from_pretrained(
"openbmb/MiniCPM-V-2_6", trust_remote_code=True
)
base_model = AutoModel.from_pretrained(
"openbmb/MiniCPM-V-2_6",
trust_remote_code=True,
torch_dtype=torch.bfloat16,
device_map="auto",
)
model = PeftModel.from_pretrained(base_model, ADAPTER_REPO)
model.eval()
_model_cache["model"] = model
_model_cache["tokenizer"] = tokenizer
print("[robomind] model loaded")
return model, tokenizer
def _judge_images(model, tokenizer, images, n_images: int = 6):
n = min(len(images), n_images)
image_tokens = "\\n".join(f"<image_{k:02d}>" for k in range(n))
user_content = f"{image_tokens}\\n{INSTRUCTION_PROMPT}"
response = model.chat(
image=images[:n],
msgs=[{"role": "user", "content": user_content}],
tokenizer=tokenizer,
max_new_tokens=512,
)
return response if isinstance(response, str) else str(response)
def _parse_response(response: str) -> dict:
import re
response = response.strip()
# Try to find JSON blocks — prefer the last complete one
json_blocks = list(re.finditer(r'\{[^{}]*\}', response, re.DOTALL))
if json_blocks:
for block in reversed(json_blocks):
try:
return json.loads(block.group())
except json.JSONDecodeError:
continue
# Fallback: try entire response
try:
return json.loads(response)
except json.JSONDecodeError:
pass
return {"raw_response": response}
def _load_metadata():
"""Load rollout metadata for hybrid scoring."""
import csv
meta = {}
if os.path.exists(METADATA_PATH):
with open(METADATA_PATH) as f:
for line in f:
r = json.loads(line.strip())
key = (r["env"], r["tier"], r["episode_id"])
meta[key] = r
return meta
def _lookup_metadata(video_name: str, metadata: dict):
"""Try to find metadata for a video by parsing its filename.
Tries exact match first, then fuzzy match on env+tier+episode.
"""
import re
# Exact match
m = re.match(r"(\w+)_(\w+)_ep(\d+)\.mp4", video_name)
if m:
env, tier, ep_id = m.group(1), m.group(2), int(m.group(3))
key = (env, tier, ep_id)
if key in metadata:
return metadata[key]
# Fuzzy match: try all entries and find closest
for key, entry in metadata.items():
env, tier, ep_id = key
if f"{env}_{tier}_ep{ep_id}" in video_name:
return entry
return None
def _compute_hybrid(parsed: dict, metadata_entry: dict = None, metadata: dict = None, sound_analysis: dict = None):
"""Run hybrid judge combining VLM + rule-based scoring."""
import sys
if "/root" not in sys.path:
sys.path.insert(0, "/root")
from hybrid_judge import hybrid_judge, hybrid_to_dict
if metadata_entry:
env = metadata_entry["env"]
all_metadata = metadata or {}
env_rets = [v["return"] for v in all_metadata.values() if v["env"] == env]
min_ret = min(env_rets) if env_rets else 0
max_ret = max(env_rets) if env_rets else 1
score = hybrid_judge(
vlm_parsed=parsed,
ep_return=metadata_entry["return"],
min_return=min_ret,
max_return=max_ret,
fell=metadata_entry.get("fell", False),
num_steps=metadata_entry.get("num_steps", 0),
tier=metadata_entry.get("tier", "unknown"),
env=metadata_entry.get("env", "unknown"),
)
else:
score = hybrid_judge(vlm_parsed=parsed)
result = hybrid_to_dict(score)
if sound_analysis:
result["sound_analysis"] = sound_analysis
if sound_analysis.get("has_fall"):
confidence = sound_analysis.get("fall_confidence", 0.0)
penalty = confidence * 0.3
result["predicted_reward"] = max(0.0, result["predicted_reward"] - penalty)
result["anomaly"] = (result.get("anomaly") or "") + f" [audio: fall detected, conf={confidence:.2f}]"
return result
@app.function(
image=app_image,
gpu="A100-40GB",
volumes={"/data": volume},
secrets=[modal.Secret.from_name("huggingface-secret")],
timeout=3600,
)
@modal.asgi_app()
def serve():
from fastapi import FastAPI, UploadFile, File, Request
from fastapi.responses import HTMLResponse, JSONResponse
from typing import List
web_app = FastAPI()
metadata = _load_metadata()
print(f"[robomind] loaded {len(metadata)} metadata entries")
@web_app.get("/", response_class=HTMLResponse)
async def index():
return HTML_PAGE
@web_app.post("/judge/images")
async def judge_images(files: List[UploadFile] = File(...)):
from PIL import Image
import io
images = []
for f in files:
data = await f.read()
images.append(Image.open(io.BytesIO(data)).convert("RGB"))
model, tokenizer = _get_model()
response = _judge_images(model, tokenizer, images)
parsed = _parse_response(response)
hybrid = _compute_hybrid(parsed, metadata=metadata)
return JSONResponse({"parsed": parsed, "hybrid": hybrid, "raw": response})
@web_app.post("/judge/video")
async def judge_video(file: UploadFile = File(...)):
data = await file.read()
original_name = file.filename or "unknown.mp4"
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
tmp.write(data)
tmp.close()
frames = _extract_keyframes(tmp.name)
sound_result = None
try:
from robomind.sound import SoundAnalyzer
analyzer = SoundAnalyzer()
sa = analyzer.analyze_video(tmp.name)
sound_result = {
"has_fall": sa.has_fall,
"fall_confidence": round(sa.fall_confidence, 3),
"has_impact": sa.has_impact,
"impact_count": sa.impact_count,
"has_motor_strain": sa.has_motor_strain,
"has_rhythmic_gait": sa.has_rhythmic_gait,
"gait_quality": round(sa.gait_quality, 3),
"explanation": sa.explanation,
}
except Exception as e:
sound_result = {"error": str(e)}
os.unlink(tmp.name)
if not frames:
return JSONResponse({"error": "Failed to extract frames"}, status_code=400)
model, tokenizer = _get_model()
response = _judge_images(model, tokenizer, frames)
parsed = _parse_response(response)
meta_entry = _lookup_metadata(original_name, metadata)
hybrid = _compute_hybrid(parsed, meta_entry, metadata, sound_result)
return JSONResponse({"parsed": parsed, "hybrid": hybrid, "raw": response})
@web_app.post("/judge/url")
async def judge_url(request: Request):
import urllib.request
body = await request.json()
url = body.get("url", "").strip()
if not url:
return JSONResponse({"error": "No URL provided"}, status_code=400)
from urllib.parse import urlparse
url_path = urlparse(url).path
original_name = os.path.basename(url_path) or "download.mp4"
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
try:
urllib.request.urlretrieve(url, tmp.name)
except Exception as e:
os.unlink(tmp.name)
return JSONResponse({"error": f"Download failed: {e}"}, status_code=400)
frames = _extract_keyframes(tmp.name)
sound_result = None
try:
from robomind.sound import SoundAnalyzer
analyzer = SoundAnalyzer()
sa = analyzer.analyze_video(tmp.name)
sound_result = {
"has_fall": sa.has_fall,
"fall_confidence": round(sa.fall_confidence, 3),
"has_impact": sa.has_impact,
"impact_count": sa.impact_count,
"has_motor_strain": sa.has_motor_strain,
"has_rhythmic_gait": sa.has_rhythmic_gait,
"gait_quality": round(sa.gait_quality, 3),
"explanation": sa.explanation,
}
except Exception as e:
sound_result = {"error": str(e)}
os.unlink(tmp.name)
if not frames:
return JSONResponse({"error": "Failed to extract frames"}, status_code=400)
model, tokenizer = _get_model()
response = _judge_images(model, tokenizer, frames)
parsed = _parse_response(response)
meta_entry = _lookup_metadata(original_name, metadata)
hybrid = _compute_hybrid(parsed, meta_entry, metadata, sound_result)
return JSONResponse({"parsed": parsed, "hybrid": hybrid, "raw": response})
return web_app
@app.local_entrypoint()
def main():
serve.remote()