| """pi0 (Physical Intelligence / lerobot) inference runtime for Apple Silicon / MLX. |
| |
| Faithful port of the lerobot (openpi-derived) PI0Pytorch reference: |
| - prefill: PaliGemma LM (18 layers, bidirectional prefix) with RoPE-applied KV cache |
| - 10 Euler flow-matching steps; the 300M Gemma expert attends to the cached |
| prefix KV at every layer (head_dim 256 / 1 KV head on both sides) |
| - Gemma specifics: (1+w) RMSNorm, GeGLU (tanh), rope base 10000, language |
| embeddings scaled by sqrt(width); attention/softmax in fp32 |
| - verified against the PyTorch reference: cosine 1.00000 on full action chunks |
| |
| Usage: |
| from pi0_mlx import Pi0MLX |
| m = Pi0MLX.from_pretrained(".") |
| actions = m.predict(images=[cam0, cam1, cam2], |
| instruction="pick up the cube", state=[...]) |
| |
| Note: the tokenizer is loaded from google/paligemma-3b-pt-224 (gated; accept |
| the Gemma terms on Hugging Face and login first), matching upstream lerobot. |
| """ |
| import json |
| import math |
| from pathlib import Path |
|
|
| import mlx.core as mx |
| import mlx.nn as nn |
| import numpy as np |
|
|
| from mlx_vlm.models.paligemma.vision import VisionModel |
| from mlx_vlm.models.paligemma import VisionConfig |
|
|
| CHUNK = 50 |
| MAX_DIM = 32 |
| NUM_STEPS = 10 |
| MIN_PERIOD, MAX_PERIOD = 4e-3, 4.0 |
| N_LAYERS = 18 |
| HID = 2048 |
| EXP_HID = 1024 |
| N_HEADS = 8 |
| N_KV = 1 |
| HEAD_DIM = 256 |
| EPS = 1e-6 |
| EMBED_SCALE = HID ** 0.5 |
| IMG_SIZE = 224 |
| VIS_CFG = {"model_type": "siglip_vision_model", "hidden_size": 1152, "num_hidden_layers": 27, |
| "intermediate_size": 4304, "num_attention_heads": 16, "image_size": 224, |
| "patch_size": 14, "num_channels": 3, "layer_norm_eps": 1e-6} |
|
|
|
|
| def rms_norm_gemma(x, w): |
| x32 = x.astype(mx.float32) |
| out = x32 * mx.rsqrt(mx.mean(x32 * x32, axis=-1, keepdims=True) + EPS) |
| return (out * (1.0 + w.astype(mx.float32))).astype(x.dtype) |
|
|
|
|
| def apply_rope(x, positions, base=10000.0): |
| d_half = x.shape[-1] // 2 |
| dtype = x.dtype |
| x = x.astype(mx.float32) |
| freq_exp = (2.0 / x.shape[-1]) * mx.arange(d_half, dtype=mx.float32) |
| timescale = mx.power(base, freq_exp) |
| radians = positions[..., None].astype(mx.float32) / timescale[None, None, :] |
| radians = radians[..., None, :] |
| sin, cos = mx.sin(radians), mx.cos(radians) |
| x1, x2 = x[..., :d_half], x[..., d_half:] |
| return mx.concatenate([x1 * cos - x2 * sin, x2 * cos + x1 * sin], axis=-1).astype(dtype) |
|
|
|
|
| def make_att_2d_masks(pad_masks, att_masks): |
| cs = mx.cumsum(att_masks.astype(mx.int32), axis=1) |
| return (cs[:, None, :] <= cs[:, :, None]) & pad_masks[:, None, :] |
|
|
|
|
| def sinusoidal_time_emb(time, dim): |
| frac = np.linspace(0.0, 1.0, dim // 2, dtype=np.float64) |
| period = MIN_PERIOD * (MAX_PERIOD / MIN_PERIOD) ** frac |
| scale = 1.0 / period * 2 * np.pi |
| sin_in = scale[None, :] * np.asarray(time, dtype=np.float64)[:, None] |
| return mx.array(np.concatenate([np.sin(sin_in), np.cos(sin_in)], axis=1).astype(np.float32)) |
|
|
|
|
| def resize_with_pad(img_chw, size=IMG_SIZE): |
| """参照実装同等: アスペクト維持バイリニア縮小+左・上ゼロパディング。""" |
| from PIL import Image |
|
|
| c, h, w = img_chw.shape |
| ratio = max(w / size, h / size) |
| rh, rw = int(h / ratio), int(w / ratio) |
| pil = Image.fromarray((np.transpose(img_chw, (1, 2, 0)) * 255).clip(0, 255).astype(np.uint8)) |
| pil = pil.resize((rw, rh), Image.BILINEAR) |
| arr = np.asarray(pil).astype(np.float32) / 255.0 |
| out = np.zeros((size, size, 3), dtype=np.float32) |
| out[size - rh:, size - rw:, :] = arr |
| return out |
|
|
|
|
| class Pi0MLX: |
| P_LM = "model.paligemma_with_expert.paligemma.model.language_model." |
| P_EXP = "model.paligemma_with_expert.gemma_expert.model." |
| P_VIS = "model.paligemma_with_expert.paligemma.model.vision_tower." |
| P_PROJ = "model.paligemma_with_expert.paligemma.model.multi_modal_projector." |
| P_EMB = "model.paligemma_with_expert.paligemma.lm_head.weight" |
|
|
| def __init__(self, weights): |
| self.w = weights |
| self.vision = VisionModel(VisionConfig(**VIS_CFG)) |
| vis_w = {} |
| for k, v in weights.items(): |
| if k.startswith(self.P_VIS): |
| kk = k[len(self.P_VIS):] |
| if kk.endswith("patch_embedding.weight") and v.shape[-1] != 3: |
| v = v.transpose(0, 2, 3, 1) |
| vis_w[kk] = v |
| self.vision.load_weights(list(vis_w.items()), strict=False) |
| mx.eval(self.vision.parameters()) |
|
|
| @classmethod |
| def from_pretrained(cls, path): |
| path = Path(path) |
| m = cls(mx.load(str(path / "model.safetensors"))) |
| from transformers import AutoTokenizer |
|
|
| m.tokenizer = AutoTokenizer.from_pretrained("google/paligemma-3b-pt-224") |
| return m |
|
|
| def lm(self, i, name): |
| return self.w[f"{self.P_LM}layers.{i}.{name}"] |
|
|
| def exp(self, i, name): |
| return self.w[f"{self.P_EXP}layers.{i}.{name}"] |
|
|
| def _attn(self, mask2d, q, k, v): |
| B, Lk = k.shape[0], k.shape[1] |
| groups = N_HEADS // N_KV |
| k = mx.repeat(k[:, :, :, None, :], groups, axis=3).reshape(B, Lk, N_HEADS, HEAD_DIM) |
| v = mx.repeat(v[:, :, :, None, :], groups, axis=3).reshape(B, Lk, N_HEADS, HEAD_DIM) |
| q32 = q.astype(mx.float32).transpose(0, 2, 1, 3) |
| k32 = k.astype(mx.float32).transpose(0, 2, 1, 3) |
| att = (q32 @ k32.transpose(0, 1, 3, 2)) * (HEAD_DIM ** -0.5) |
| att = mx.where(mask2d[:, None, :, :], att, mx.finfo(mx.float32).min) |
| probs = mx.softmax(att, axis=-1).astype(v.dtype) |
| out = probs @ v.transpose(0, 2, 1, 3) |
| return out.transpose(0, 2, 1, 3).reshape(B, -1, N_HEADS * HEAD_DIM) |
|
|
| def _layer(self, get, i, h, mask2d, pos, cache=None, fill=False): |
| hn = rms_norm_gemma(h, get(i, "input_layernorm.weight")) |
| B, L = hn.shape[:2] |
| q = (hn @ get(i, "self_attn.q_proj.weight").T).reshape(B, L, -1, HEAD_DIM) |
| k = (hn @ get(i, "self_attn.k_proj.weight").T).reshape(B, L, -1, HEAD_DIM) |
| v = (hn @ get(i, "self_attn.v_proj.weight").T).reshape(B, L, -1, HEAD_DIM) |
| q = apply_rope(q, pos) |
| k = apply_rope(k, pos) |
| if fill: |
| cache[i] = (k, v) |
| elif cache is not None: |
| k = mx.concatenate([cache[i][0], k], axis=1) |
| v = mx.concatenate([cache[i][1], v], axis=1) |
| att = self._attn(mask2d, q, k, v) |
| out = att @ get(i, "self_attn.o_proj.weight").T + h |
| res = out |
| on = rms_norm_gemma(out, get(i, "post_attention_layernorm.weight")) |
| gate = on @ get(i, "mlp.gate_proj.weight").T |
| up = on @ get(i, "mlp.up_proj.weight").T |
| return (nn.gelu_approx(gate) * up) @ get(i, "mlp.down_proj.weight").T + res |
|
|
| def embed_prefix(self, imgs224, tokens, lang_mask): |
| embs, pads, atts = [], [], [] |
| vdtype = self.vision.vision_model.embeddings.patch_embedding.weight.dtype |
| for img in imgs224: |
| feat = self.vision(img.astype(vdtype)) |
| feat = feat[0] if isinstance(feat, tuple) else feat |
| if feat.ndim == 2: |
| feat = feat[None] |
| feat = feat @ self.w[self.P_PROJ + "linear.weight"].T + self.w[self.P_PROJ + "linear.bias"] |
| embs.append(feat.astype(mx.bfloat16)) |
| pads.append(mx.ones(feat.shape[:2], dtype=mx.bool_)) |
| atts += [0] * feat.shape[1] |
| lang = (self.w[self.P_EMB][tokens].astype(mx.float32) * EMBED_SCALE).astype(mx.bfloat16) |
| embs.append(lang) |
| pads.append(lang_mask.astype(mx.bool_)) |
| atts += [0] * lang.shape[1] |
| embs = mx.concatenate(embs, axis=1) |
| pads = mx.concatenate(pads, axis=1) |
| atts = mx.array(atts, dtype=mx.int32)[None, :] |
| return embs, pads, mx.broadcast_to(atts, (embs.shape[0], atts.shape[1])) |
|
|
| def embed_suffix(self, state32, x_t, time): |
| state_emb = state32 @ self.w["model.state_proj.weight"].T + self.w["model.state_proj.bias"] |
| act = x_t @ self.w["model.action_in_proj.weight"].T + self.w["model.action_in_proj.bias"] |
| t_emb = sinusoidal_time_emb(time, EXP_HID).astype(act.dtype) |
| at = mx.concatenate([act, mx.broadcast_to(t_emb[:, None, :], act.shape)], axis=2) |
| at = at @ self.w["model.action_time_mlp_in.weight"].T + self.w["model.action_time_mlp_in.bias"] |
| at = nn.silu(at) |
| at = at @ self.w["model.action_time_mlp_out.weight"].T + self.w["model.action_time_mlp_out.bias"] |
| embs = mx.concatenate([state_emb[:, None, :], at], axis=1).astype(mx.bfloat16) |
| pads = mx.ones(embs.shape[:2], dtype=mx.bool_) |
| atts = mx.array([1, 1] + [0] * (CHUNK - 1), dtype=mx.int32)[None, :] |
| return embs, pads, mx.broadcast_to(atts, (embs.shape[0], atts.shape[1])) |
|
|
| def sample_actions(self, imgs224, tokens, lang_mask, state32, noise=None): |
| if noise is None: |
| noise = mx.random.normal((state32.shape[0], CHUNK, MAX_DIM)) |
| prefix, pads, atts = self.embed_prefix(imgs224, tokens, lang_mask) |
| mask2d = make_att_2d_masks(pads, atts) |
| pos = mx.cumsum(pads.astype(mx.int32), axis=1) - 1 |
| cache = {} |
| h = prefix |
| for i in range(N_LAYERS): |
| h = self._layer(self.lm, i, h, mask2d, pos, cache=cache, fill=True) |
| P = pads.shape[1] |
| offset = mx.sum(pads.astype(mx.int32), axis=-1)[:, None] |
| x_t = noise |
| dt = -1.0 / NUM_STEPS |
| for step in range(NUM_STEPS): |
| t = 1.0 + step * dt |
| suffix, s_pads, s_atts = self.embed_suffix(state32, x_t, [t] * x_t.shape[0]) |
| L = s_pads.shape[1] |
| mask_full = mx.concatenate( |
| [mx.broadcast_to(pads[:, None, :], (s_pads.shape[0], L, P)), |
| make_att_2d_masks(s_pads, s_atts)], axis=2) |
| pos_s = offset + mx.cumsum(s_pads.astype(mx.int32), axis=1) - 1 |
| h = suffix |
| for i in range(N_LAYERS): |
| h = self._layer(self.exp, i, h, mask_full, pos_s, cache=cache, fill=False) |
| h = rms_norm_gemma(h, self.w[self.P_EXP + "norm.weight"]) |
| out = h[:, -CHUNK:].astype(mx.float32) |
| v_t = out @ self.w["model.action_out_proj.weight"].T + self.w["model.action_out_proj.bias"] |
| x_t = x_t + dt * v_t |
| mx.eval(x_t) |
| return x_t |
|
|
| def predict(self, images, instruction, state, action_dim=None, noise=None): |
| imgs224 = [] |
| for im in images: |
| arr = np.asarray(im).astype(np.float32) |
| if arr.max() > 1.5: |
| arr = arr / 255.0 |
| hwc = resize_with_pad(np.transpose(arr, (2, 0, 1))) * 2.0 - 1.0 |
| imgs224.append(mx.array(hwc[None])) |
| enc = self.tokenizer(instruction.rstrip("\n") + "\n", padding="max_length", |
| max_length=48, return_tensors="np") |
| state = np.asarray(state, dtype=np.float32)[None] |
| action_dim = action_dim or state.shape[1] |
| state32 = np.zeros((1, MAX_DIM), dtype=np.float32) |
| state32[:, : state.shape[1]] = state |
| chunk = self.sample_actions(imgs224, mx.array(enc["input_ids"]), |
| mx.array(enc["attention_mask"]).astype(mx.bool_), |
| mx.array(state32), noise=noise) |
| return np.array(chunk[0, :, :action_dim]) |
|
|
|
|
| if __name__ == "__main__": |
| import argparse |
|
|
| ap = argparse.ArgumentParser() |
| ap.add_argument("--images", nargs="+", required=True) |
| ap.add_argument("--instruction", required=True) |
| ap.add_argument("--state", default="0,0,0,0,0,0") |
| ap.add_argument("--out", default="actions.npy") |
| args = ap.parse_args() |
| from PIL import Image |
|
|
| model = Pi0MLX.from_pretrained(Path(__file__).parent) |
| imgs = [np.asarray(Image.open(p).convert("RGB")) for p in args.images] |
| state = [float(x) for x in args.state.split(",")] |
| actions = model.predict(imgs, args.instruction, state) |
| np.save(args.out, actions) |
| print(f"action chunk {actions.shape} -> {args.out}") |
| print("first action:", actions[0].round(4)) |
|
|