Commit ·
1454d47
0
Parent(s):
RoboTryOn demo
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +46 -0
- .gitignore +3 -0
- README.md +16 -0
- app.py +135 -0
- configs/image/infer.yaml +32 -0
- configs/video/infer.yaml +35 -0
- core.py +194 -0
- packages.txt +1 -0
- requirements.txt +14 -0
- robotryon/__init__.py +0 -0
- robotryon/captions.py +146 -0
- robotryon/common/__init__.py +0 -0
- robotryon/common/config.py +115 -0
- robotryon/embodiment/__init__.py +25 -0
- robotryon/embodiment/base.py +144 -0
- robotryon/embodiment/camera.py +103 -0
- robotryon/embodiment/mujoco_embodiment.py +320 -0
- robotryon/embodiment/retarget.py +122 -0
- robotryon/embodiment/spec.py +201 -0
- robotryon/idm/__init__.py +5 -0
- robotryon/idm/data.py +319 -0
- robotryon/idm/dit.py +143 -0
- robotryon/idm/labeling.py +99 -0
- robotryon/idm/model.py +327 -0
- robotryon/idm/wrist_correction.py +229 -0
- robotryon/image/__init__.py +14 -0
- robotryon/image/ar_keyframes.py +161 -0
- robotryon/image/dataset.py +227 -0
- robotryon/image/episodes.py +173 -0
- robotryon/image/fill_channels.py +92 -0
- robotryon/image/model.py +138 -0
- robotryon/image/sampler.py +121 -0
- robotryon/image/stereo_ids.py +34 -0
- robotryon/image/text.py +56 -0
- robotryon/image/training.py +48 -0
- robotryon/masks/__init__.py +14 -0
- robotryon/masks/augment.py +131 -0
- robotryon/masks/hull.py +53 -0
- robotryon/masks/reference.py +29 -0
- robotryon/skeleton/__init__.py +8 -0
- robotryon/skeleton/augment.py +75 -0
- robotryon/skeleton/render.py +75 -0
- robotryon/skeleton/topology.py +90 -0
- robotryon/video/__init__.py +15 -0
- robotryon/video/chunks.py +164 -0
- robotryon/video/conditioning.py +150 -0
- robotryon/video/dataset.py +167 -0
- robotryon/video/io.py +52 -0
- robotryon/video/pipeline.py +271 -0
- robotryon/video/rope4d.py +129 -0
.gitattributes
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
examples/gr1_stir_pot/episode/video_ego_view.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
examples/gr1_stir_pot/input.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
examples/openarm_box_convey/episode/mask_left.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
examples/openarm_box_convey/episode/object_mask_left.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
examples/openarm_box_convey/episode/video_left.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
examples/openarm_box_convey/input.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
examples/openarm_box_convey/reference.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
examples/rby1_bottle_pour/episode/mask_left.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
examples/rby1_bottle_pour/episode/video_left.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
examples/rby1_bottle_pour/input.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
examples/rby1_bottle_pour/reference.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
prep_assets.py
|
| 3 |
+
assets/
|
README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: RoboTryOn
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
| 8 |
+
python_version: "3.10"
|
| 9 |
+
app_file: app.py
|
| 10 |
+
startup_duration_timeout: 1h
|
| 11 |
+
pinned: false
|
| 12 |
+
short_description: Human videos to dexterous robot videos via virtual try-on
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
Demo of [RoboTryOn](https://github.com/ryan-seungyong-lee/RoboTryOn) ([paper](https://arxiv.org/abs/2611.05163)).
|
| 16 |
+
`robotryon/`, `scripts/infer_video.py` and `configs/` are copies of the code repository.
|
app.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
|
| 4 |
+
import gradio as gr
|
| 5 |
+
import spaces
|
| 6 |
+
|
| 7 |
+
from core import HERE, Demo
|
| 8 |
+
|
| 9 |
+
DEMO = Demo(load_models=os.environ.get("ROBOTRYON_UI_ONLY") != "1")
|
| 10 |
+
CLIPS = DEMO.manifest["clips"]
|
| 11 |
+
EMB_KEY = {e["name"]: e["key"] for e in DEMO.manifest["embodiments"]}
|
| 12 |
+
EMB_NAME = {v: k for k, v in EMB_KEY.items()}
|
| 13 |
+
CLIP_LABEL = {c["key"]: f'{c["name"]} ({c["source"]})' for c in CLIPS}
|
| 14 |
+
CLIP_KEY = {v: k for k, v in CLIP_LABEL.items()}
|
| 15 |
+
CODE = "https://github.com/ryan-seungyong-lee/RoboTryOn"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _p(rel):
|
| 19 |
+
return str(HERE / rel)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _sel(clip_label, emb_name):
|
| 23 |
+
return CLIP_KEY[clip_label], EMB_KEY[emb_name]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def stage1_duration(clip_label, emb_name, *_):
|
| 27 |
+
return 20 + 25 * DEMO.episode(*_sel(clip_label, emb_name))["num_keyframes"] # A100: ~34 s per keyframe
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def stage2_duration(clip_label, emb_name, *_):
|
| 31 |
+
return 40 + 60 * DEMO.episode(*_sel(clip_label, emb_name))["num_clips"] # A100: ~60 s per clip
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@spaces.GPU(duration=stage1_duration)
|
| 35 |
+
def _stage1(clip_label, emb_name, progress=gr.Progress(track_tqdm=True)):
|
| 36 |
+
clip, emb = _sel(clip_label, emb_name)
|
| 37 |
+
kf_dir, _ = DEMO.translate(clip, emb, tempfile.mkdtemp(prefix="keyframes_"))
|
| 38 |
+
return str(kf_dir / "grid.jpg"), (clip, emb, str(kf_dir))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@spaces.GPU(duration=stage2_duration, size="xlarge") # LTX-2.5 needs more than a half H200
|
| 42 |
+
def _stage2(clip_label, emb_name, kf_dir, progress=gr.Progress(track_tqdm=True)):
|
| 43 |
+
clip, emb = _sel(clip_label, emb_name)
|
| 44 |
+
return str(DEMO.synthesize(clip, emb, kf_dir, tempfile.mkdtemp(prefix="video_")))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def stage1(clip_label, emb_name):
|
| 48 |
+
return _stage1(clip_label, emb_name)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def stage2(clip_label, emb_name, state):
|
| 52 |
+
if not state or tuple(state[:2]) != _sel(clip_label, emb_name):
|
| 53 |
+
raise gr.Error("Run stage 1 (keyframe translation) for this video and embodiment first.")
|
| 54 |
+
return _stage2(clip_label, emb_name, state[2])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def pick_clip(evt: gr.SelectData):
|
| 58 |
+
return CLIP_LABEL[CLIPS[evt.index]["key"]]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def show_clip(clip_label, emb_name, state):
|
| 62 |
+
clip = DEMO.clips[CLIP_KEY[clip_label]]
|
| 63 |
+
keep = state and tuple(state[:2]) == _sel(clip_label, emb_name)
|
| 64 |
+
return (_p(clip["video"]), clip["caption"],
|
| 65 |
+
gr.update(selected_index=[c["key"] for c in CLIPS].index(clip["key"])),
|
| 66 |
+
*((gr.update(), gr.update()) if keep else (None, None)))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def show_embodiment(clip_label, emb_name, state):
|
| 70 |
+
keep = state and tuple(state[:2]) == _sel(clip_label, emb_name)
|
| 71 |
+
return (_p(DEMO.embodiments[EMB_KEY[emb_name]]["reference"]),
|
| 72 |
+
*((gr.update(), gr.update()) if keep else (None, None)))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def load_example(clip_label, emb_name):
|
| 76 |
+
clip, emb = _sel(clip_label, emb_name)
|
| 77 |
+
ex = next(e for e in DEMO.manifest["examples"] if e["clip"] == clip and e["embodiment"] == emb)
|
| 78 |
+
return _p(ex["grid"]), _p(ex["video"]), (clip, emb, _p(ex["keyframes"]))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
CSS = """
|
| 82 |
+
#clips .thumbnail-item { aspect-ratio: 16 / 9 !important; }
|
| 83 |
+
#clips .thumbnail-item img { object-fit: contain !important; }
|
| 84 |
+
#clips .caption-label { font-size: 12px; }
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
first = CLIPS[0]
|
| 88 |
+
with gr.Blocks(title="RoboTryOn", css=CSS) as demo:
|
| 89 |
+
gr.Markdown(
|
| 90 |
+
"# RoboTryOn\n"
|
| 91 |
+
"**Synthesizing Dexterous Robot Demonstrations from Human Videos via Virtual Try-On** · "
|
| 92 |
+
f"[Paper](https://arxiv.org/abs/2611.05163) · "
|
| 93 |
+
f"[Project page](https://ryan-seungyong-lee.github.io/RoboTryOn) · [Code]({CODE})\n\n"
|
| 94 |
+
"The input videos are preprocessed in advance (hand reconstruction, retargeting to each "
|
| 95 |
+
"robot hand, arm masks) due to the time limits of this Space. "
|
| 96 |
+
f"To run RoboTryOn on your own videos, see the [code]({CODE}).")
|
| 97 |
+
state = gr.State(None)
|
| 98 |
+
|
| 99 |
+
gr.Markdown("### 1. Human video")
|
| 100 |
+
gallery = gr.Gallery([(_p(c["thumb"]), c["name"]) for c in CLIPS], columns=9,
|
| 101 |
+
rows=1, height=120, object_fit="contain", allow_preview=False,
|
| 102 |
+
selected_index=0, show_label=False, elem_id="clips")
|
| 103 |
+
with gr.Row():
|
| 104 |
+
human = gr.Video(_p(first["video"]), label="Human video", interactive=False, height=220,
|
| 105 |
+
scale=1)
|
| 106 |
+
with gr.Column(scale=2):
|
| 107 |
+
clip_dd = gr.Dropdown(list(CLIP_LABEL.values()), value=CLIP_LABEL[first["key"]],
|
| 108 |
+
label="Selected video")
|
| 109 |
+
caption = gr.Textbox(first["caption"], label="Task", interactive=False, lines=3)
|
| 110 |
+
|
| 111 |
+
gr.Markdown("### 2. Embodiment")
|
| 112 |
+
with gr.Row():
|
| 113 |
+
emb_radio = gr.Radio(list(EMB_KEY), value=next(iter(EMB_KEY)), label="Target embodiment")
|
| 114 |
+
reference = gr.Image(_p(DEMO.manifest["embodiments"][0]["reference"]),
|
| 115 |
+
label="Reference", interactive=False, height=180)
|
| 116 |
+
|
| 117 |
+
gr.Markdown("### 3. Generate")
|
| 118 |
+
with gr.Row():
|
| 119 |
+
b1 = gr.Button("Stage 1: Keyframe translation", variant="primary")
|
| 120 |
+
b2 = gr.Button("Stage 2: Video synthesis (uses the stage-1 keyframes)", variant="primary")
|
| 121 |
+
grid = gr.Image(label="Keyframes: human (top) and robot (bottom)", type="filepath")
|
| 122 |
+
video = gr.Video(label="Robot video")
|
| 123 |
+
|
| 124 |
+
gr.Markdown("### Examples (precomputed)")
|
| 125 |
+
gr.Examples([[CLIP_LABEL[e["clip"]], EMB_NAME[e["embodiment"]]] for e in DEMO.manifest["examples"]],
|
| 126 |
+
inputs=[clip_dd, emb_radio], outputs=[grid, video, state], fn=load_example,
|
| 127 |
+
run_on_click=True, cache_examples=False)
|
| 128 |
+
|
| 129 |
+
gallery.select(pick_clip, None, clip_dd)
|
| 130 |
+
clip_dd.change(show_clip, [clip_dd, emb_radio, state], [human, caption, gallery, grid, video])
|
| 131 |
+
emb_radio.change(show_embodiment, [clip_dd, emb_radio, state], [reference, grid, video])
|
| 132 |
+
b1.click(stage1, [clip_dd, emb_radio], [grid, state])
|
| 133 |
+
b2.click(stage2, [clip_dd, emb_radio, state], video)
|
| 134 |
+
|
| 135 |
+
demo.queue().launch()
|
configs/image/infer.yaml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Autoregressive keyframe translation (paper Eq. 3.2). See scripts/infer_keyframes.py.
|
| 2 |
+
|
| 3 |
+
model:
|
| 4 |
+
base: black-forest-labs/FLUX.2-klein-base-9B
|
| 5 |
+
checkpoint: null # trained checkpoint dir (FFT: has transformer/; LoRA: has pytorch_lora_weights.safetensors)
|
| 6 |
+
|
| 7 |
+
episodes: [] # preprocessed human episode directories (with keyframes.json)
|
| 8 |
+
reference: {} # {view: robot-only reference image}; a single path is used for every view
|
| 9 |
+
output_dir: runs/keyframes
|
| 10 |
+
|
| 11 |
+
views: auto # auto (joint stereo when the episode has two views) | stereo | mono
|
| 12 |
+
ref_skeleton: false # false: reuse the previous keyframe's latent as reference tokens
|
| 13 |
+
# true: decode it, paint the current skeleton, re-encode
|
| 14 |
+
steps: 30
|
| 15 |
+
guidance_scale: 3.5 # true CFG against the empty prompt
|
| 16 |
+
seed: 2024 # keyframe t uses seed + t
|
| 17 |
+
|
| 18 |
+
image:
|
| 19 |
+
long_edge: 1024
|
| 20 |
+
aspect: [16, 9]
|
| 21 |
+
crop_anchor: bottom
|
| 22 |
+
|
| 23 |
+
masks: # inference dilation = (dilate_min + dilate_max) // 2 + bonus = 58 px
|
| 24 |
+
dilate_min: 3
|
| 25 |
+
dilate_max: 71
|
| 26 |
+
bonus: 21
|
| 27 |
+
obj_erode: 9 # px, erode the object mask before subtracting it from the hole
|
| 28 |
+
|
| 29 |
+
caption: # per-keyframe hand-state prompt (robotryon.captions)
|
| 30 |
+
cuts: # closure (rad) / dorsumness (deg) edges -> open|half-closed|closed fist, palm|edge-on|back
|
| 31 |
+
closure: [0.95, 1.15]
|
| 32 |
+
facing: [49.0, 121.0]
|
configs/video/infer.yaml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Video synthesis from translated robot keyframes and the human skeleton flow (paper App. B.4).
|
| 2 |
+
model:
|
| 3 |
+
base: Lightricks/LTX-2.5-Diffusers
|
| 4 |
+
transformer_subfolder: transformer_full
|
| 5 |
+
lora: outputs/video/stage2/checkpoint-001000 # directory with pytorch_lora_weights.safetensors
|
| 6 |
+
attention_backend: native
|
| 7 |
+
|
| 8 |
+
input:
|
| 9 |
+
episode: null # human episode dir: meta.json, keypoints_<view>.npz, keyframes.json
|
| 10 |
+
keyframes_dir: null # image-model output for this episode: keyframe_<view>_<frame:06d>.png + keyframes.json
|
| 11 |
+
views: null # default: all views in meta.json
|
| 12 |
+
stereo: true # generate two views jointly in one sequence; false = one view at a time
|
| 13 |
+
|
| 14 |
+
output:
|
| 15 |
+
dir: outputs/video/infer
|
| 16 |
+
|
| 17 |
+
text:
|
| 18 |
+
template: "{caption}"
|
| 19 |
+
embodiment: null # fills {embodiment} in the template (target robot)
|
| 20 |
+
|
| 21 |
+
fps: 20
|
| 22 |
+
resolution: [1024, 576] # output, per view
|
| 23 |
+
skeleton_size: [512, 288] # as in training: reference downscale 1 at 512x288, 2 at 1024x576
|
| 24 |
+
mask_human: true # true: skeleton flow on black | false: skeleton drawn on the unmasked human video
|
| 25 |
+
frames_range: [49, 145] # clip length limits (8k+1)
|
| 26 |
+
seed: 0 # clip i uses seed + i
|
| 27 |
+
|
| 28 |
+
two_stage:
|
| 29 |
+
mode: same_res # same_res: 8 steps + 3 refine steps at 1024x576
|
| 30 |
+
# upsample: 8 steps at 512x288 -> latent upsampler x2 -> 3 steps at 1024x576
|
| 31 |
+
|
| 32 |
+
skeleton:
|
| 33 |
+
draw:
|
| 34 |
+
thickness: 2
|
| 35 |
+
radius: 3
|
core.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The two demo stages, run with the RoboTryOn code as released (``robotryon/``,
|
| 2 |
+
``scripts/infer_video.py``, ``configs/``).
|
| 3 |
+
|
| 4 |
+
The human clips are preprocessed in advance for every embodiment (hand reconstruction,
|
| 5 |
+
retargeting to the robot hand, arm masks, keyframe selection) and their prompts are encoded in
|
| 6 |
+
advance, so neither the embodiment models nor the text encoders are needed here.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import importlib.util
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import subprocess
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import cv2
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
from huggingface_hub import snapshot_download
|
| 20 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 21 |
+
|
| 22 |
+
from robotryon.common.config import load_config
|
| 23 |
+
from robotryon.image.ar_keyframes import KeyframeTranslator
|
| 24 |
+
from robotryon.image.episodes import Episode, FrameReader, load_view_frame
|
| 25 |
+
from robotryon.image.model import load_pipeline as load_image_pipeline
|
| 26 |
+
|
| 27 |
+
HERE = Path(__file__).resolve().parent
|
| 28 |
+
ASSETS = HERE / "assets"
|
| 29 |
+
_spec = importlib.util.spec_from_file_location("infer_video", HERE / "scripts" / "infer_video.py")
|
| 30 |
+
iv = importlib.util.module_from_spec(_spec)
|
| 31 |
+
_spec.loader.exec_module(iv)
|
| 32 |
+
|
| 33 |
+
CKPT_REPO = "RyanL22/RoboTryOn"
|
| 34 |
+
ASSETS_REPO = "RyanL22/RoboTryOn-demo-assets" # dataset: preprocessed clips, encoded prompts, examples
|
| 35 |
+
FLUX_REPO = "black-forest-labs/FLUX.2-klein-base-9B"
|
| 36 |
+
LTX_REPO = "Lightricks/LTX-2.5-Diffusers"
|
| 37 |
+
# base-model revisions the demo was tested with (later LTX revisions add components)
|
| 38 |
+
FLUX_REVISION = "32773329fbe7e81a90ef971740e8ba4b0364ecf3"
|
| 39 |
+
LTX_REVISION = "a6de4b5354f078db24d9cf4778c14846788aea3d"
|
| 40 |
+
FLUX_FILES = ["model_index.json", "scheduler/*", "transformer/*", "vae/*"]
|
| 41 |
+
LTX_FILES = ["model_index.json", "scheduler/*", "transformer_full/*", "vae/*", "audio_vae/*",
|
| 42 |
+
"vocoder/*", "connectors/*", "diffusion_decoder/*", "duration_head/*",
|
| 43 |
+
"ltx-2.5-22b-distilled-lora-450-bf16.safetensors"]
|
| 44 |
+
IMAGE_CONFIG = HERE / "configs" / "image" / "infer.yaml"
|
| 45 |
+
VIDEO_CONFIG = HERE / "configs" / "video" / "infer.yaml"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def load_manifest() -> dict:
|
| 49 |
+
if not (ASSETS / "manifest.json").exists():
|
| 50 |
+
snapshot_download(ASSETS_REPO, repo_type="dataset", local_dir=ASSETS)
|
| 51 |
+
return json.loads((ASSETS / "manifest.json").read_text())
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _dir(env: str, repo: str, files: list[str] | None, revision: str | None = None) -> str:
|
| 55 |
+
return os.environ.get(env) or snapshot_download(repo, allow_patterns=files, revision=revision)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def human_frames(ep: Episode, cfg, frames: list[int], view: str | None = None) -> list[np.ndarray]:
|
| 59 |
+
"""The human frames at ``frames``, cropped and resized as the image model sees them."""
|
| 60 |
+
c, reader = cfg.image, FrameReader()
|
| 61 |
+
return [load_view_frame(reader, ep, view or ep.views[0], t, int(c.long_edge),
|
| 62 |
+
tuple(c.aspect) if c.get("aspect") else None,
|
| 63 |
+
c.get("crop_anchor", "bottom")).image for t in frames]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def keyframe_grid(human: list[np.ndarray], robot: list[np.ndarray], frames: list[int],
|
| 67 |
+
cols: int = 4, width: int = 384) -> Image.Image:
|
| 68 |
+
"""Human keyframes above their translations, ``cols`` keyframes per row pair."""
|
| 69 |
+
h = round(width * human[0].shape[0] / human[0].shape[1])
|
| 70 |
+
rows = (len(frames) + cols - 1) // cols
|
| 71 |
+
n = min(cols, len(frames))
|
| 72 |
+
pad = 4
|
| 73 |
+
grid = Image.new("RGB", (n * width + (n + 1) * pad, rows * (2 * h + pad) + (rows + 1) * pad),
|
| 74 |
+
(255, 255, 255))
|
| 75 |
+
draw = ImageDraw.Draw(grid)
|
| 76 |
+
font = ImageFont.load_default(size=18)
|
| 77 |
+
for i, (a, b, t) in enumerate(zip(human, robot, frames)):
|
| 78 |
+
x = pad + (i % cols) * (width + pad)
|
| 79 |
+
y = pad + (i // cols) * (2 * h + 2 * pad)
|
| 80 |
+
grid.paste(Image.fromarray(a).resize((width, h), Image.LANCZOS), (x, y))
|
| 81 |
+
grid.paste(Image.fromarray(b).resize((width, h), Image.LANCZOS), (x, y + h + pad))
|
| 82 |
+
draw.text((x + 6, y + 4), f"frame {t}", fill=(255, 255, 255), font=font,
|
| 83 |
+
stroke_width=2, stroke_fill=(0, 0, 0))
|
| 84 |
+
return grid
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def match_brightness(image: np.ndarray, human: np.ndarray, mask: np.ndarray,
|
| 88 |
+
margin: int = 15) -> np.ndarray:
|
| 89 |
+
"""Match the keyframe's lightness (mean and spread of L in Lab) to the human frame.
|
| 90 |
+
|
| 91 |
+
Only the background outside the human-arm mask (grown by ``margin`` px) is compared, so the
|
| 92 |
+
robot itself does not drive the statistics; the same correction is applied to every pixel.
|
| 93 |
+
"""
|
| 94 |
+
if human.shape[:2] != image.shape[:2]:
|
| 95 |
+
human = cv2.resize(human, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_AREA)
|
| 96 |
+
if mask.shape[:2] != image.shape[:2]:
|
| 97 |
+
mask = cv2.resize(mask, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_NEAREST)
|
| 98 |
+
k = np.ones((2 * margin + 1, 2 * margin + 1), np.uint8)
|
| 99 |
+
bg = cv2.dilate((mask > 0).astype(np.uint8), k) == 0
|
| 100 |
+
if bg.sum() < 1000:
|
| 101 |
+
return image
|
| 102 |
+
lab = cv2.cvtColor(image, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 103 |
+
ref = cv2.cvtColor(human, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 104 |
+
src_l, ref_l = lab[..., 0][bg], ref[..., 0][bg]
|
| 105 |
+
gain = ref_l.std() / max(src_l.std(), 1e-3)
|
| 106 |
+
lab[..., 0] = (lab[..., 0] - src_l.mean()) * gain + ref_l.mean()
|
| 107 |
+
return cv2.cvtColor(np.clip(lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def h264(src: Path, dst: Path) -> Path:
|
| 111 |
+
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(src), "-c:v", "libx264",
|
| 112 |
+
"-crf", "18", "-pix_fmt", "yuv420p", str(dst)], check=True)
|
| 113 |
+
return dst
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class Demo:
|
| 117 |
+
"""Both models, kept on the CPU between calls and moved to the GPU for one stage."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, load_models: bool = True):
|
| 120 |
+
self.manifest = load_manifest()
|
| 121 |
+
self.clips = {c["key"]: c for c in self.manifest["clips"]}
|
| 122 |
+
self.embodiments = {e["key"]: e for e in self.manifest["embodiments"]}
|
| 123 |
+
self.episodes = self.manifest["episodes"]
|
| 124 |
+
if not load_models:
|
| 125 |
+
return
|
| 126 |
+
self.prompts = torch.load(ASSETS / "prompts.pt")
|
| 127 |
+
self.negative = torch.load(ASSETS / "negative_prompt.pt")
|
| 128 |
+
self.ckpt = Path(_dir("ROBOTRYON_CKPT", CKPT_REPO, ["image/*", "video/*"]))
|
| 129 |
+
self.flux = _dir("FLUX_DIR", FLUX_REPO, FLUX_FILES, FLUX_REVISION)
|
| 130 |
+
self.ltx = _dir("LTX_DIR", LTX_REPO, LTX_FILES, LTX_REVISION)
|
| 131 |
+
self.image = load_image_pipeline(self.flux, self.ckpt / "image", device="cpu",
|
| 132 |
+
text_encoder=False)
|
| 133 |
+
self.video = iv.load_pipeline(self.video_config([]), views=1, device="cpu")
|
| 134 |
+
|
| 135 |
+
def episode(self, clip: str, emb: str) -> dict:
|
| 136 |
+
return self.episodes[f"{emb}/{clip}"]
|
| 137 |
+
|
| 138 |
+
def image_config(self, overrides: list[str]):
|
| 139 |
+
return load_config(IMAGE_CONFIG, [f"model.base={self.flux}",
|
| 140 |
+
f"checkpoint={self.ckpt / 'image'}", *overrides])
|
| 141 |
+
|
| 142 |
+
def video_config(self, overrides: list[str]):
|
| 143 |
+
return load_config(VIDEO_CONFIG, [f"model.base={self.ltx}",
|
| 144 |
+
f"model.lora={self.ckpt / 'video'}", *overrides])
|
| 145 |
+
|
| 146 |
+
def translate(self, clip: str, emb: str, out_root: str | Path) -> tuple[Path, Image.Image]:
|
| 147 |
+
"""Stage 1: keyframe translation. Returns the keyframe directory and the grid."""
|
| 148 |
+
e = self.episode(clip, emb)
|
| 149 |
+
cfg = self.image_config([f"output_dir={out_root}", *e["image_overrides"]])
|
| 150 |
+
ep_dir = HERE / e["dir"]
|
| 151 |
+
ep = Episode.load(ep_dir)
|
| 152 |
+
ref = np.asarray(Image.open(HERE / self.embodiments[emb]["reference"]).convert("RGB"))
|
| 153 |
+
self.image.to("cuda")
|
| 154 |
+
try:
|
| 155 |
+
tr = KeyframeTranslator(self.image, cfg)
|
| 156 |
+
tr._prompts.update({k: tuple(x.to("cuda") for x in self.prompts[k])
|
| 157 |
+
for k in e["prompts"]})
|
| 158 |
+
kf_dir = tr.translate_episode(ep_dir, {v: ref for v in ep.views}, out_root)
|
| 159 |
+
finally:
|
| 160 |
+
self.image.to("cpu")
|
| 161 |
+
torch.cuda.empty_cache()
|
| 162 |
+
frames = json.loads((kf_dir / "keyframes.json").read_text())["indices"]
|
| 163 |
+
if self.clips[clip]["source"] != "self-recorded":
|
| 164 |
+
# downloaded human videos (VITRA, EgoDex): the generated keyframes drift in overall
|
| 165 |
+
# brightness, so match each one to its human frame before stage 2 reads it
|
| 166 |
+
for v in ep.views:
|
| 167 |
+
human = human_frames(ep, cfg, frames, v)
|
| 168 |
+
for t, h in zip(frames, human):
|
| 169 |
+
f = kf_dir / f"keyframe_{v}_{t:06d}.png"
|
| 170 |
+
mask = np.asarray(Image.open(kf_dir / f"mask_{v}_{t:06d}.png").convert("L"))
|
| 171 |
+
img = np.asarray(Image.open(f).convert("RGB"))
|
| 172 |
+
Image.fromarray(match_brightness(img, h, mask)).save(f)
|
| 173 |
+
view = ep.views[0]
|
| 174 |
+
robot = [np.asarray(Image.open(kf_dir / f"keyframe_{view}_{t:06d}.png").convert("RGB"))
|
| 175 |
+
for t in frames]
|
| 176 |
+
grid = keyframe_grid(human_frames(ep, cfg, frames), robot, frames)
|
| 177 |
+
grid.save(kf_dir / "grid.jpg", quality=92)
|
| 178 |
+
return kf_dir, grid
|
| 179 |
+
|
| 180 |
+
def synthesize(self, clip: str, emb: str, kf_dir: str | Path, out_dir: str | Path) -> Path:
|
| 181 |
+
"""Stage 2: video synthesis from the stage-1 keyframes. Returns an h264 mp4."""
|
| 182 |
+
e = self.episode(clip, emb)
|
| 183 |
+
cfg = self.video_config([f"input.episode={HERE / e['dir']}",
|
| 184 |
+
f"input.keyframes_dir={kf_dir}", f"output.dir={out_dir}"])
|
| 185 |
+
pe, pm = torch.load(HERE / self.clips[clip]["caption_embeds"])
|
| 186 |
+
text = (pe, pm, *self.negative)
|
| 187 |
+
self.video.to("cuda")
|
| 188 |
+
try:
|
| 189 |
+
out = iv.synthesize(cfg, self.video, text)
|
| 190 |
+
finally:
|
| 191 |
+
self.video.to("cpu")
|
| 192 |
+
torch.cuda.empty_cache()
|
| 193 |
+
view = json.loads((out / "meta.json").read_text())["views"][0]
|
| 194 |
+
return h264(out / f"video_{view}.mp4", out / "robot.mp4")
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
ffmpeg
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.8.0
|
| 2 |
+
torchvision==0.23.0
|
| 3 |
+
diffusers @ git+https://github.com/huggingface/diffusers@90b4e34e79a86ec5e7f2437634fe95ecd2108796
|
| 4 |
+
transformers==5.15.0
|
| 5 |
+
peft==0.20.0
|
| 6 |
+
accelerate==1.14.0
|
| 7 |
+
safetensors
|
| 8 |
+
huggingface_hub
|
| 9 |
+
numpy<2
|
| 10 |
+
scipy
|
| 11 |
+
opencv-python-headless
|
| 12 |
+
pillow
|
| 13 |
+
pyyaml
|
| 14 |
+
tqdm
|
robotryon/__init__.py
ADDED
|
File without changes
|
robotryon/captions.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text prompts: per-hand state words and the embodiment phrase.
|
| 2 |
+
|
| 3 |
+
Image model. A keyframe's prompt states two facts per hand that a 2D skeleton conveys weakly at
|
| 4 |
+
the sizes hands occupy in the frame -- how closed the hand is and which side faces the camera::
|
| 5 |
+
|
| 6 |
+
Match the hand pose to the drawn skeleton.
|
| 7 |
+
Left hand: open, palm toward camera. Right hand: closed fist, back of hand toward camera.
|
| 8 |
+
|
| 9 |
+
``closure`` is the MCP + PIP flexion averaged over the four fingers (radians);
|
| 10 |
+
``dorsumness`` is 180 minus the angle between the knuckle-plane normal and the ray from the hand
|
| 11 |
+
to the camera (degrees, high = back of the hand toward the lens). Both are read from the
|
| 12 |
+
3D hand in the camera frame -- the reconstructed human hand, or the robot hand from forward
|
| 13 |
+
kinematics, with the same formula -- and cut into words (``HandStateCuts``). A hand that is not
|
| 14 |
+
tracked is ``not visible``.
|
| 15 |
+
|
| 16 |
+
Each episode stores both values per view and frame in ``hand_state_<view>.npz`` (``closure``,
|
| 17 |
+
``dorsumness``, ``(T, 2)`` float32, NaN where a hand is not tracked), written by the preprocessing
|
| 18 |
+
for human clips (reconstructed hands) and robot episodes (forward kinematics), so training and
|
| 19 |
+
inference read the same quantity.
|
| 20 |
+
|
| 21 |
+
Video model. The prompt is the task caption (``text.template``).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from dataclasses import dataclass
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
INSTRUCTION = "Match the hand pose to the drawn skeleton."
|
| 31 |
+
CLOSURE = ("open", "half-closed", "closed fist")
|
| 32 |
+
FACING = ("palm toward camera", "edge-on", "back of hand toward camera")
|
| 33 |
+
NOT_VISIBLE = "not visible"
|
| 34 |
+
|
| 35 |
+
# MediaPipe / MANO 21-joint layout: (MCP, PIP, DIP, tip) per finger.
|
| 36 |
+
_CHAINS = {"index": (5, 6, 7, 8), "middle": (9, 10, 11, 12), "ring": (13, 14, 15, 16),
|
| 37 |
+
"little": (17, 18, 19, 20)}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _angle(a, b, c) -> float:
|
| 41 |
+
"""Flexion at b: 0 when a-b-c is straight."""
|
| 42 |
+
u, v = a - b, c - b
|
| 43 |
+
u = u / max(np.linalg.norm(u), 1e-9)
|
| 44 |
+
v = v / max(np.linalg.norm(v), 1e-9)
|
| 45 |
+
return float(np.pi - np.arccos(np.clip(np.dot(u, v), -1.0, 1.0)))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def hand_fields(j21: np.ndarray, hand: str) -> tuple[float, float]:
|
| 49 |
+
"""``(21, 3)`` joints of one hand in the camera frame (camera at the origin, looking down +z)
|
| 50 |
+
-> ``(closure, dorsumness)``; NaN when the hand is not finite."""
|
| 51 |
+
j = np.asarray(j21, np.float64)
|
| 52 |
+
if not np.isfinite(j).all():
|
| 53 |
+
return float("nan"), float("nan")
|
| 54 |
+
w = j[0]
|
| 55 |
+
closure = np.mean([_angle(w, j[a], j[b]) + _angle(j[a], j[b], j[c])
|
| 56 |
+
for a, b, c, _ in _CHAINS.values()])
|
| 57 |
+
n = np.cross(j[5] - w, j[17] - w)
|
| 58 |
+
ray = -j.mean(0) # hand -> camera
|
| 59 |
+
nn, nr = np.linalg.norm(n), np.linalg.norm(ray)
|
| 60 |
+
if nn < 1e-9 or nr < 1e-9:
|
| 61 |
+
return float(closure), float("nan")
|
| 62 |
+
d = 180.0 - float(np.degrees(np.arccos(np.clip(np.dot(n, ray) / (nn * nr), -1.0, 1.0))))
|
| 63 |
+
# the normal leaves through the palm on the right hand and the back on the left
|
| 64 |
+
return float(closure), (d if hand == "left" else 180.0 - d)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class HandStateCuts:
|
| 69 |
+
"""Edges that turn ``closure`` / ``dorsumness`` into words (lower, upper)."""
|
| 70 |
+
closure: tuple[float, float] = (0.95, 1.15)
|
| 71 |
+
facing: tuple[float, float] = (49.0, 121.0)
|
| 72 |
+
|
| 73 |
+
@classmethod
|
| 74 |
+
def from_config(cls, cfg) -> "HandStateCuts":
|
| 75 |
+
cfg = cfg or {}
|
| 76 |
+
return cls(tuple(float(x) for x in cfg.get("closure", cls.closure)),
|
| 77 |
+
tuple(float(x) for x in cfg.get("facing", cls.facing)))
|
| 78 |
+
|
| 79 |
+
def words(self, closure: float, dorsumness: float) -> str:
|
| 80 |
+
if not (np.isfinite(closure) and np.isfinite(dorsumness)):
|
| 81 |
+
return NOT_VISIBLE
|
| 82 |
+
ci = int(closure >= self.closure[0]) + int(closure >= self.closure[1])
|
| 83 |
+
fi = int(dorsumness >= self.facing[0]) + int(dorsumness >= self.facing[1])
|
| 84 |
+
return f"{CLOSURE[ci]}, {FACING[fi]}"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def hand_state_prompt(left: str, right: str) -> str:
|
| 88 |
+
"""The image prompt from the two hands' words."""
|
| 89 |
+
return f"{INSTRUCTION} Left hand: {left}. Right hand: {right}."
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def all_prompts() -> list[str]:
|
| 93 |
+
"""Every prompt training can produce: each pair of hand descriptions, the hand-dropout prompt
|
| 94 |
+
(the instruction alone) and the empty (unconditional) prompt."""
|
| 95 |
+
cells = [f"{c}, {f}" for c in CLOSURE for f in FACING] + [NOT_VISIBLE]
|
| 96 |
+
return [hand_state_prompt(a, b) for a in cells for b in cells] + [INSTRUCTION, ""]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def sample_prompt(words: tuple[str, str], rng: np.random.Generator, p_uncond: float,
|
| 100 |
+
p_drop: float) -> str:
|
| 101 |
+
"""Training prompt: empty with ``p_uncond``, hand words dropped with ``p_drop``."""
|
| 102 |
+
u = float(rng.random())
|
| 103 |
+
if u < p_uncond:
|
| 104 |
+
return ""
|
| 105 |
+
if u < p_uncond + p_drop:
|
| 106 |
+
return INSTRUCTION
|
| 107 |
+
return hand_state_prompt(*words)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def hand_state_arrays(joints3d: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 111 |
+
"""``(T, 2, 21|17, 3)`` camera-frame joints and ``(T, 2)`` validity -> ``closure`` and
|
| 112 |
+
``dorsumness`` ``(T, 2)`` float32 (NaN where invalid)."""
|
| 113 |
+
from .embodiment.retarget import to21
|
| 114 |
+
j = to21(np.asarray(joints3d, np.float64))
|
| 115 |
+
T = j.shape[0]
|
| 116 |
+
clo = np.full((T, 2), np.nan, np.float32)
|
| 117 |
+
dor = np.full((T, 2), np.nan, np.float32)
|
| 118 |
+
for t in range(T):
|
| 119 |
+
for h, name in enumerate(("left", "right")):
|
| 120 |
+
if valid[t, h]:
|
| 121 |
+
clo[t, h], dor[t, h] = hand_fields(j[t, h], name)
|
| 122 |
+
return clo, dor
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def save_hand_state(path, closure: np.ndarray, dorsumness: np.ndarray) -> None:
|
| 126 |
+
np.savez_compressed(path, closure=closure.astype(np.float32), dorsumness=dorsumness.astype(np.float32))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def load_hand_state(path) -> tuple[np.ndarray, np.ndarray]:
|
| 130 |
+
z = np.load(path)
|
| 131 |
+
return z["closure"], z["dorsumness"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def words_at(states: list[tuple[np.ndarray, np.ndarray]], t: int,
|
| 135 |
+
cuts: HandStateCuts) -> tuple[str, str]:
|
| 136 |
+
"""Words of both hands at frame ``t`` from one or more views' hand states (averaged)."""
|
| 137 |
+
out = []
|
| 138 |
+
for h in range(2):
|
| 139 |
+
vals = [(c[t, h], d[t, h]) for c, d in states
|
| 140 |
+
if t < len(c) and np.isfinite(c[t, h]) and np.isfinite(d[t, h])]
|
| 141 |
+
if not vals:
|
| 142 |
+
out.append(NOT_VISIBLE)
|
| 143 |
+
continue
|
| 144 |
+
cl, dn = np.mean(np.asarray(vals, np.float64), axis=0)
|
| 145 |
+
out.append(cuts.words(float(cl), float(dn)))
|
| 146 |
+
return out[0], out[1]
|
robotryon/common/__init__.py
ADDED
|
File without changes
|
robotryon/common/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YAML configs with dotted command-line overrides.
|
| 2 |
+
|
| 3 |
+
Every script takes ``--config path.yaml`` followed by any number of ``key.sub=value``
|
| 4 |
+
overrides, e.g.::
|
| 5 |
+
|
| 6 |
+
python scripts/train_image.py --config configs/image/klein9b_lora.yaml \
|
| 7 |
+
train.max_steps=20 data.root=/path/to/data
|
| 8 |
+
|
| 9 |
+
A config may name a parent with ``_base_: other.yaml`` (relative to itself); the child is merged
|
| 10 |
+
on top of it. Values are parsed with YAML rules, so ``1e-5``, ``true``, ``[1, 2]`` and ``null``
|
| 11 |
+
work as expected on the command line.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import copy
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Sequence
|
| 20 |
+
|
| 21 |
+
import yaml
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class Config(dict):
|
| 25 |
+
"""A dict whose keys are also attributes (recursively)."""
|
| 26 |
+
|
| 27 |
+
def __getattr__(self, key: str) -> Any:
|
| 28 |
+
try:
|
| 29 |
+
return self[key]
|
| 30 |
+
except KeyError as e:
|
| 31 |
+
raise AttributeError(key) from e
|
| 32 |
+
|
| 33 |
+
def __setattr__(self, key: str, value: Any) -> None:
|
| 34 |
+
self[key] = value
|
| 35 |
+
|
| 36 |
+
def get_path(self, dotted: str, default: Any = None) -> Any:
|
| 37 |
+
node: Any = self
|
| 38 |
+
for k in dotted.split("."):
|
| 39 |
+
if not isinstance(node, dict) or k not in node:
|
| 40 |
+
return default
|
| 41 |
+
node = node[k]
|
| 42 |
+
return node
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> dict:
|
| 45 |
+
return _plain(self)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _wrap(x: Any) -> Any:
|
| 49 |
+
if isinstance(x, dict):
|
| 50 |
+
return Config({k: _wrap(v) for k, v in x.items()})
|
| 51 |
+
if isinstance(x, list):
|
| 52 |
+
return [_wrap(v) for v in x]
|
| 53 |
+
return x
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _plain(x: Any) -> Any:
|
| 57 |
+
if isinstance(x, dict):
|
| 58 |
+
return {k: _plain(v) for k, v in x.items()}
|
| 59 |
+
if isinstance(x, list):
|
| 60 |
+
return [_plain(v) for v in x]
|
| 61 |
+
return x
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def merge(base: dict, over: dict) -> dict:
|
| 65 |
+
out = copy.deepcopy(base)
|
| 66 |
+
for k, v in over.items():
|
| 67 |
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
| 68 |
+
out[k] = merge(out[k], v)
|
| 69 |
+
else:
|
| 70 |
+
out[k] = copy.deepcopy(v)
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _set_dotted(d: dict, dotted: str, value: Any) -> None:
|
| 75 |
+
keys = dotted.split(".")
|
| 76 |
+
for k in keys[:-1]:
|
| 77 |
+
d = d.setdefault(k, {})
|
| 78 |
+
d[keys[-1]] = value
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _parse_value(raw: str) -> Any:
|
| 82 |
+
v = yaml.safe_load(raw)
|
| 83 |
+
if isinstance(v, str): # YAML 1.1 reads "1e-5" as a string; accept it as a float
|
| 84 |
+
try:
|
| 85 |
+
return float(v)
|
| 86 |
+
except ValueError:
|
| 87 |
+
pass
|
| 88 |
+
return v
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def load_config(path: str | Path, overrides: Sequence[str] = ()) -> Config:
|
| 92 |
+
path = Path(path)
|
| 93 |
+
cfg = yaml.safe_load(path.read_text()) or {}
|
| 94 |
+
base = cfg.pop("_base_", None)
|
| 95 |
+
if base is not None:
|
| 96 |
+
cfg = merge(load_config(path.parent / base).to_dict(), cfg)
|
| 97 |
+
for item in overrides:
|
| 98 |
+
if "=" not in item:
|
| 99 |
+
raise ValueError(f"override must be key=value, got {item!r}")
|
| 100 |
+
key, raw = item.split("=", 1)
|
| 101 |
+
_set_dotted(cfg, key, _parse_value(raw))
|
| 102 |
+
return _wrap(cfg)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def parse_args(description: str = "", argv: Sequence[str] | None = None) -> Config:
|
| 106 |
+
"""``--config file.yaml [key=value ...]`` -> config."""
|
| 107 |
+
p = argparse.ArgumentParser(description=description)
|
| 108 |
+
p.add_argument("--config", required=True, help="YAML config file")
|
| 109 |
+
p.add_argument("overrides", nargs="*", help="dotted overrides, e.g. train.lr=1e-5")
|
| 110 |
+
a = p.parse_args(argv)
|
| 111 |
+
return load_config(a.config, a.overrides)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def save_config(cfg: dict, path: str | Path) -> None:
|
| 115 |
+
Path(path).write_text(yaml.safe_dump(_plain(cfg), sort_keys=False))
|
robotryon/embodiment/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Robot embodiments: kinematics, cameras, silhouettes and hand retargeting from a YAML spec.
|
| 2 |
+
|
| 3 |
+
``load_embodiment("my_robot.yaml")`` returns a :class:`MujocoEmbodiment`; see
|
| 4 |
+
``configs/embodiments/gr1.yaml`` for every field.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .base import OFFSCREEN_TOLERANCE, Embodiment
|
| 8 |
+
from .camera import CameraModel
|
| 9 |
+
from .spec import EmbodimentSpec, load_spec, parse_spec
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_embodiment(path_or_name):
|
| 13 |
+
"""Embodiment from a YAML path, or from ``configs/embodiments/<name>.yaml`` (relative to
|
| 14 |
+
the working directory) when given a bare name."""
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from .mujoco_embodiment import MujocoEmbodiment # mujoco is an optional dependency
|
| 18 |
+
p = Path(path_or_name)
|
| 19 |
+
if not p.exists() and p.suffix == "":
|
| 20 |
+
p = Path("configs/embodiments") / f"{path_or_name}.yaml"
|
| 21 |
+
return MujocoEmbodiment(load_spec(p))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
__all__ = ["Embodiment", "CameraModel", "EmbodimentSpec", "OFFSCREEN_TOLERANCE",
|
| 25 |
+
"load_embodiment", "load_spec", "parse_spec"]
|
robotryon/embodiment/base.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The interface every robot is used through.
|
| 2 |
+
|
| 3 |
+
The generative models never see joint angles, only images, a mask of the arm and hand, and a
|
| 4 |
+
17-keypoint hand skeleton. What a robot must provide is therefore small:
|
| 5 |
+
|
| 6 |
+
* ``fk(q)``: wrist pose and the 17 hand keypoints of each hand in the robot base frame;
|
| 7 |
+
* ``camera_pose(q, view)`` and ``project(points, view)``: the head camera(s), to draw the
|
| 8 |
+
skeleton of a robot frame and to place a human hand recorded from the same viewpoint;
|
| 9 |
+
* ``silhouette(q, view)``: the rendered robot, which together with SAM3 gives the robot mask;
|
| 10 |
+
* ``retarget_hand(human_kp3d)``: the robot hand posed like a reconstructed human hand.
|
| 11 |
+
|
| 12 |
+
Arrays over hands always use the order ``(left, right)``; a hand the robot does not have is NaN.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from abc import ABC, abstractmethod
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
from robotryon.skeleton.topology import NUM_HANDS, NUM_KEYPOINTS
|
| 22 |
+
|
| 23 |
+
from .spec import HANDS, EmbodimentSpec
|
| 24 |
+
|
| 25 |
+
# A keypoint off screen is still drawn (OpenCV clips the bone) unless it lies further than
|
| 26 |
+
# this many frame sizes outside the image, where the projection is degenerate.
|
| 27 |
+
OFFSCREEN_TOLERANCE = 3.0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Embodiment(ABC):
|
| 31 |
+
spec: EmbodimentSpec
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def name(self) -> str:
|
| 35 |
+
return self.spec.name
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def views(self) -> list[str]:
|
| 39 |
+
return self.spec.views
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def joint_names(self) -> list[str | None]:
|
| 43 |
+
"""Layout of the recorded joint vector ``q`` (``None`` = slot not in the model)."""
|
| 44 |
+
return self.spec.joints
|
| 45 |
+
|
| 46 |
+
# --- kinematics ----------------------------------------------------------------------
|
| 47 |
+
@abstractmethod
|
| 48 |
+
def fk(self, q) -> dict[str, dict[str, np.ndarray]]:
|
| 49 |
+
"""Forward kinematics in the base frame, metres.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
q: joint vector in ``joint_names`` order, or a ``{joint: value}`` dict.
|
| 53 |
+
Returns:
|
| 54 |
+
``{hand: {"wrist_pos": (3,), "wrist_rot": (3, 3), "keypoints": (17, 3)}}`` for each
|
| 55 |
+
hand the robot has (``"left"``/``"right"``); keypoint 0 is the wrist and
|
| 56 |
+
``wrist_rot`` columns are the wrist frame's axes in the base frame.
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def arm_joint_slices(self) -> dict[str, slice]:
|
| 61 |
+
"""Slice of the joint/action vector holding each arm's joints (YAML ``arm_joints``)."""
|
| 62 |
+
return {h: slice(*r) for h, r in self.spec.arm_joints.items()}
|
| 63 |
+
|
| 64 |
+
def hand_keypoints(self, q) -> np.ndarray:
|
| 65 |
+
"""``(2, 17, 3)`` keypoints of ``fk(q)`` in (left, right) order, NaN for a missing hand."""
|
| 66 |
+
out, f = empty_hands(), self.fk(q)
|
| 67 |
+
for hi, h in enumerate(HANDS):
|
| 68 |
+
if h in f:
|
| 69 |
+
out[hi] = f[h]["keypoints"]
|
| 70 |
+
return out
|
| 71 |
+
|
| 72 |
+
@abstractmethod
|
| 73 |
+
def camera_pose(self, q, view: str) -> np.ndarray:
|
| 74 |
+
"""4x4 camera-to-base pose of ``view`` (optical frame) at joints ``q``.
|
| 75 |
+
|
| 76 |
+
``q=None`` uses the default pose (useful for fixed or stereo-relative quantities).
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
@abstractmethod
|
| 80 |
+
def silhouette(self, q, view: str, image_size=None) -> np.ndarray:
|
| 81 |
+
"""Rendered robot arm+hand mask, bool ``(H, W)``."""
|
| 82 |
+
|
| 83 |
+
@abstractmethod
|
| 84 |
+
def retarget_hand(self, human_kp3d: np.ndarray, valid=None, prev: dict | None = None):
|
| 85 |
+
"""Robot hand posed like a human hand (vector retargeting, see ``retarget``).
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
human_kp3d: ``(2, 21|17, 3)`` human joints (MediaPipe order) in any frame.
|
| 89 |
+
valid: ``(2,)`` bool, hands to retarget (default: finite ones).
|
| 90 |
+
prev: the joints returned for the previous frame of the clip; they warm-start the
|
| 91 |
+
optimization and its smoothing term (None for the first frame).
|
| 92 |
+
Returns:
|
| 93 |
+
``(2, 17, 3)`` robot keypoints in the same frame (NaN for skipped hands) and a
|
| 94 |
+
``{joint: value}`` dict of the retargeted robot hand joints.
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
# --- cameras -------------------------------------------------------------------------
|
| 98 |
+
def camera(self, view: str):
|
| 99 |
+
return self.spec.cameras[view].intrinsics
|
| 100 |
+
|
| 101 |
+
def stereo_extrinsic(self, view_a: str, view_b: str) -> np.ndarray:
|
| 102 |
+
"""4x4 transform from ``view_b``'s camera frame to ``view_a``'s."""
|
| 103 |
+
Ta, Tb = self.camera_pose(None, view_a), self.camera_pose(None, view_b)
|
| 104 |
+
return np.linalg.inv(Ta) @ Tb
|
| 105 |
+
|
| 106 |
+
def project(self, points, view: str, q=None, frame: str = "base", image_size=None):
|
| 107 |
+
"""``(..., 3)`` points -> ``(..., 2)`` pixels and ``(...)`` depth-ok mask.
|
| 108 |
+
|
| 109 |
+
``frame="base"`` needs ``q`` (the camera may move with the head);
|
| 110 |
+
``frame="camera"`` takes points already in ``view``'s optical frame.
|
| 111 |
+
"""
|
| 112 |
+
pts = np.asarray(points, np.float64)
|
| 113 |
+
if frame == "base":
|
| 114 |
+
T = np.linalg.inv(self.camera_pose(q, view))
|
| 115 |
+
pts = pts @ T[:3, :3].T + T[:3, 3]
|
| 116 |
+
elif frame != "camera":
|
| 117 |
+
raise ValueError(f"frame must be 'base' or 'camera', got {frame!r}")
|
| 118 |
+
return self.camera(view).project(pts, image_size)
|
| 119 |
+
|
| 120 |
+
def keypoints_2d(self, points, view: str, image_size, q=None, frame: str = "base"):
|
| 121 |
+
"""Project ``(2, 17, 3)`` keypoints into the skeleton format.
|
| 122 |
+
|
| 123 |
+
Returns ``kp`` float32 ``(2, 17, 2)`` (0 where invisible) and ``vis`` bool ``(2, 17)``:
|
| 124 |
+
in front of the camera and within ``OFFSCREEN_TOLERANCE`` frames of the image.
|
| 125 |
+
"""
|
| 126 |
+
px, ok = self.project(points, view, q=q, frame=frame, image_size=image_size)
|
| 127 |
+
W, H = image_size
|
| 128 |
+
m = OFFSCREEN_TOLERANCE
|
| 129 |
+
u, v = px[..., 0], px[..., 1]
|
| 130 |
+
with np.errstate(invalid="ignore"):
|
| 131 |
+
vis = ok & (u > -m * W) & (u < (1 + m) * W) & (v > -m * H) & (v < (1 + m) * H)
|
| 132 |
+
kp = np.where(vis[..., None], px, 0.0).astype(np.float32)
|
| 133 |
+
return kp, vis
|
| 134 |
+
|
| 135 |
+
def keypoints_in_view(self, q, view: str, image_size):
|
| 136 |
+
"""Projected skeleton ``(kp, vis)`` of the robot at joints ``q`` (see keypoints_2d)."""
|
| 137 |
+
return self.keypoints_2d(self.hand_keypoints(q), view, image_size, q=q)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def empty_hands(d: int = 3) -> np.ndarray:
|
| 141 |
+
return np.full((NUM_HANDS, NUM_KEYPOINTS, d), np.nan)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
__all__ = ["Embodiment", "HANDS", "OFFSCREEN_TOLERANCE", "empty_hands"]
|
robotryon/embodiment/camera.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Camera intrinsics: projection of camera-frame points to pixels and back to rays.
|
| 2 |
+
|
| 3 |
+
A camera is calibrated at one resolution (``width`` x ``height``); every method takes the
|
| 4 |
+
image size it should work at and rescales the intrinsics, so the same calibration serves a
|
| 5 |
+
video stored at any resolution with the same aspect. Points are in the OpenCV optical frame
|
| 6 |
+
(x right, y down, z forward). Two lens models are supported:
|
| 7 |
+
|
| 8 |
+
* ``pinhole``: OpenCV plumb-bob distortion ``[k1, k2, p1, p2, k3]`` (empty = rectified).
|
| 9 |
+
* ``fisheye``: OpenCV equidistant model ``[k1, k2, k3, k4]`` (zeros = ideal equidistant).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
|
| 16 |
+
import cv2
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class CameraModel:
|
| 22 |
+
width: int
|
| 23 |
+
height: int
|
| 24 |
+
fx: float
|
| 25 |
+
fy: float
|
| 26 |
+
cx: float
|
| 27 |
+
cy: float
|
| 28 |
+
model: str = "pinhole"
|
| 29 |
+
dist: tuple = ()
|
| 30 |
+
# Additive pixel offset applied after projection (a fitted principal-point residual),
|
| 31 |
+
# given at the calibration resolution.
|
| 32 |
+
pp_offset: tuple = (0.0, 0.0)
|
| 33 |
+
# Points closer than this to the lens are inside the robot's own head and are not drawn.
|
| 34 |
+
min_depth: float = 0.15
|
| 35 |
+
extra: dict = field(default_factory=dict)
|
| 36 |
+
|
| 37 |
+
def __post_init__(self):
|
| 38 |
+
if self.model not in ("pinhole", "fisheye"):
|
| 39 |
+
raise ValueError(f"unknown camera model {self.model!r}")
|
| 40 |
+
|
| 41 |
+
@classmethod
|
| 42 |
+
def from_dict(cls, d: dict) -> "CameraModel":
|
| 43 |
+
known = {"width", "height", "fx", "fy", "cx", "cy", "model", "dist", "pp_offset",
|
| 44 |
+
"min_depth"}
|
| 45 |
+
kw = {k: d[k] for k in known if k in d}
|
| 46 |
+
kw["dist"] = tuple(float(x) for x in d.get("dist", ()) or ())
|
| 47 |
+
kw["pp_offset"] = tuple(float(x) for x in d.get("pp_offset", (0.0, 0.0)))
|
| 48 |
+
return cls(**kw, extra={k: v for k, v in d.items() if k not in known})
|
| 49 |
+
|
| 50 |
+
# --- intrinsics at a given resolution -------------------------------------------------
|
| 51 |
+
def _scale(self, image_size):
|
| 52 |
+
if image_size is None:
|
| 53 |
+
return 1.0, 1.0
|
| 54 |
+
return image_size[0] / self.width, image_size[1] / self.height
|
| 55 |
+
|
| 56 |
+
def K(self, image_size=None) -> np.ndarray:
|
| 57 |
+
sx, sy = self._scale(image_size)
|
| 58 |
+
return np.array([[self.fx * sx, 0.0, self.cx * sx],
|
| 59 |
+
[0.0, self.fy * sy, self.cy * sy],
|
| 60 |
+
[0.0, 0.0, 1.0]])
|
| 61 |
+
|
| 62 |
+
def D(self) -> np.ndarray:
|
| 63 |
+
n = 4 if self.model == "fisheye" else 5
|
| 64 |
+
d = np.zeros(n)
|
| 65 |
+
d[:len(self.dist)] = self.dist
|
| 66 |
+
return d
|
| 67 |
+
|
| 68 |
+
def offset(self, image_size=None) -> np.ndarray:
|
| 69 |
+
sx, sy = self._scale(image_size)
|
| 70 |
+
return np.array([self.pp_offset[0] * sx, self.pp_offset[1] * sy])
|
| 71 |
+
|
| 72 |
+
# --- projection ------------------------------------------------------------------------
|
| 73 |
+
def project(self, pts_cam: np.ndarray, image_size=None):
|
| 74 |
+
"""``(..., 3)`` camera-frame points -> ``(..., 2)`` pixels and ``(...)`` depth-ok mask.
|
| 75 |
+
|
| 76 |
+
Points behind ``min_depth`` get NaN pixels.
|
| 77 |
+
"""
|
| 78 |
+
pts = np.asarray(pts_cam, np.float64)
|
| 79 |
+
shape = pts.shape[:-1]
|
| 80 |
+
flat = pts.reshape(-1, 3)
|
| 81 |
+
ok = np.isfinite(flat).all(1) & (flat[:, 2] > self.min_depth)
|
| 82 |
+
px = np.full((flat.shape[0], 2), np.nan)
|
| 83 |
+
if ok.any():
|
| 84 |
+
obj = flat[ok].reshape(-1, 1, 3)
|
| 85 |
+
K, D = self.K(image_size), self.D()
|
| 86 |
+
if self.model == "fisheye":
|
| 87 |
+
uv, _ = cv2.fisheye.projectPoints(obj, np.zeros(3), np.zeros(3), K, D)
|
| 88 |
+
else:
|
| 89 |
+
uv, _ = cv2.projectPoints(obj, np.zeros(3), np.zeros(3), K, D)
|
| 90 |
+
px[ok] = uv.reshape(-1, 2) + self.offset(image_size)
|
| 91 |
+
return px.reshape(shape + (2,)), ok.reshape(shape)
|
| 92 |
+
|
| 93 |
+
def normalized(self, pixels: np.ndarray, image_size=None) -> np.ndarray:
|
| 94 |
+
"""``(..., 2)`` pixels -> ``(..., 2)`` undistorted normalized coordinates (x/z, y/z)."""
|
| 95 |
+
uv = np.asarray(pixels, np.float64)
|
| 96 |
+
shape = uv.shape[:-1]
|
| 97 |
+
uv = (uv.reshape(-1, 2) - self.offset(image_size)).reshape(-1, 1, 2)
|
| 98 |
+
K, D = self.K(image_size), self.D()
|
| 99 |
+
if self.model == "fisheye":
|
| 100 |
+
n = cv2.fisheye.undistortPoints(uv, K, D)
|
| 101 |
+
else:
|
| 102 |
+
n = cv2.undistortPoints(uv, K, D)
|
| 103 |
+
return n.reshape(shape + (2,))
|
robotryon/embodiment/mujoco_embodiment.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MuJoCo implementation of :class:`Embodiment`, generic over the YAML description.
|
| 2 |
+
|
| 3 |
+
The robot model (MJCF, or URDF which MuJoCo also parses) is supplied by the user; nothing
|
| 4 |
+
robot-specific is coded here. Forward kinematics is ``mj_kinematics`` on a pose built from
|
| 5 |
+
the recorded joint vector: unlisted joints stay at ``qpos0``, coupled passive joints are
|
| 6 |
+
filled from their sources and ``fixed_joints`` are applied last. A head camera mounted on a
|
| 7 |
+
model frame therefore follows the recorded head joints.
|
| 8 |
+
|
| 9 |
+
Pin ``mujoco==3.11.0``: with 3.12 site-based camera poses silently collapse (every point
|
| 10 |
+
lands at camera depth ~0), so a different version raises unless
|
| 11 |
+
``ROBOTRYON_ALLOW_ANY_MUJOCO=1``.
|
| 12 |
+
|
| 13 |
+
Rendering (``silhouette``) needs an OpenGL backend; set ``MUJOCO_GL=egl`` (GPU) or
|
| 14 |
+
``MUJOCO_GL=osmesa`` (CPU) before the first import of mujoco.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
import cv2
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
from . import retarget as RT
|
| 25 |
+
from .base import Embodiment, empty_hands
|
| 26 |
+
from .spec import HANDS, EmbodimentSpec, load_spec
|
| 27 |
+
|
| 28 |
+
MUJOCO_PIN = "3.11.0"
|
| 29 |
+
_SIL_HIDDEN_GROUP = 5
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _import_mujoco():
|
| 33 |
+
import mujoco
|
| 34 |
+
if mujoco.__version__ != MUJOCO_PIN and os.environ.get("ROBOTRYON_ALLOW_ANY_MUJOCO") != "1":
|
| 35 |
+
raise RuntimeError(
|
| 36 |
+
f"mujoco {mujoco.__version__} != {MUJOCO_PIN}: newer versions break site-based "
|
| 37 |
+
f"camera poses without an error. Install mujoco=={MUJOCO_PIN} or set "
|
| 38 |
+
f"ROBOTRYON_ALLOW_ANY_MUJOCO=1.")
|
| 39 |
+
return mujoco
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class MujocoEmbodiment(Embodiment):
|
| 43 |
+
def __init__(self, spec: EmbodimentSpec | str):
|
| 44 |
+
self.spec = load_spec(spec) if isinstance(spec, (str, os.PathLike)) else spec
|
| 45 |
+
mj = self._mj = _import_mujoco()
|
| 46 |
+
if not os.path.exists(self.spec.model_path):
|
| 47 |
+
raise FileNotFoundError(f"robot model not found: {self.spec.model_path}")
|
| 48 |
+
self.model = mj.MjModel.from_xml_path(self.spec.model_path)
|
| 49 |
+
self.data = mj.MjData(self.model)
|
| 50 |
+
|
| 51 |
+
names = set(n for n in self.spec.joints if n) | set(self.spec.fixed_joints)
|
| 52 |
+
names |= {c[0] for c in self.spec.coupling} | {c[2] for c in self.spec.coupling}
|
| 53 |
+
names |= {j for js in self.spec.retarget.joints.values() for j in js}
|
| 54 |
+
self._adr = {n: self._joint_adr(n) for n in names}
|
| 55 |
+
self._rt_bounds = {h: self._joint_limits(js) for h, js in self.spec.retarget.joints.items()}
|
| 56 |
+
self._rt_palm: dict[str, float] = {}
|
| 57 |
+
self._kp = {h: [(*self._frame(n), off) for n, off in
|
| 58 |
+
zip(self.spec.keypoints[h], self.spec.keypoint_offsets[h])]
|
| 59 |
+
for h in self.spec.hands}
|
| 60 |
+
# the wrist pose: the explicit wrist_frame, else keypoint 0 (with its offset)
|
| 61 |
+
self._wrist = {h: (*self._frame(self.spec.wrist_frame[h]),
|
| 62 |
+
self.spec.keypoint_offsets[h][0]
|
| 63 |
+
if self.spec.wrist_frame[h] == self.spec.keypoints[h][0] else None)
|
| 64 |
+
for h in self.spec.hands}
|
| 65 |
+
self._mount = {v: (self._frame(c.mount.frame) if c.mount.frame else None)
|
| 66 |
+
for v, c in self.spec.cameras.items()}
|
| 67 |
+
self._renderers: dict = {}
|
| 68 |
+
|
| 69 |
+
# --- name resolution -----------------------------------------------------------------
|
| 70 |
+
def _joint_adr(self, name: str) -> int:
|
| 71 |
+
jid = self._mj.mj_name2id(self.model, self._mj.mjtObj.mjOBJ_JOINT, name)
|
| 72 |
+
if jid < 0:
|
| 73 |
+
raise ValueError(f"joint {name!r} not in {self.spec.model_path}")
|
| 74 |
+
return int(self.model.jnt_qposadr[jid])
|
| 75 |
+
|
| 76 |
+
def _joint_limits(self, names) -> tuple[np.ndarray, np.ndarray]:
|
| 77 |
+
"""``(lo, hi)`` of each joint: the model's limits ([-pi, pi] when unlimited), narrowed
|
| 78 |
+
by ``retarget.limits``."""
|
| 79 |
+
lo, hi = [], []
|
| 80 |
+
for n in names:
|
| 81 |
+
jid = self._mj.mj_name2id(self.model, self._mj.mjtObj.mjOBJ_JOINT, n)
|
| 82 |
+
r = self.model.jnt_range[jid]
|
| 83 |
+
ok = bool(self.model.jnt_limited[jid]) and r[1] > r[0]
|
| 84 |
+
a, b = (float(r[0]), float(r[1])) if ok else (-np.pi, np.pi)
|
| 85 |
+
if n in self.spec.retarget.limits:
|
| 86 |
+
ua, ub = self.spec.retarget.limits[n]
|
| 87 |
+
a, b = max(a, ua), min(b, ub)
|
| 88 |
+
if b <= a:
|
| 89 |
+
raise ValueError(f"retarget.limits for {n} [{ua}, {ub}] leaves no range "
|
| 90 |
+
f"inside the model limits")
|
| 91 |
+
lo.append(a)
|
| 92 |
+
hi.append(b)
|
| 93 |
+
return np.asarray(lo), np.asarray(hi)
|
| 94 |
+
|
| 95 |
+
def _frame(self, name: str) -> tuple[str, int]:
|
| 96 |
+
"""``site:x`` / ``body:x`` / ``x`` (site first, then body) -> (kind, id)."""
|
| 97 |
+
kind, _, bare = name.rpartition(":")
|
| 98 |
+
T = self._mj.mjtObj
|
| 99 |
+
for k in ([kind] if kind else ["site", "body"]):
|
| 100 |
+
oid = self._mj.mj_name2id(self.model, T.mjOBJ_SITE if k == "site" else T.mjOBJ_BODY,
|
| 101 |
+
bare)
|
| 102 |
+
if oid >= 0:
|
| 103 |
+
return k, oid
|
| 104 |
+
raise ValueError(f"frame {name!r} not in {self.spec.model_path}")
|
| 105 |
+
|
| 106 |
+
def _pos(self, f):
|
| 107 |
+
p = (self.data.site_xpos if f[0] == "site" else self.data.xpos)[f[1]].copy()
|
| 108 |
+
if len(f) > 2 and f[2] is not None: # point fixed in the frame
|
| 109 |
+
p = p + self._rot(f) @ f[2]
|
| 110 |
+
return p
|
| 111 |
+
|
| 112 |
+
def _rot(self, f):
|
| 113 |
+
m = self.data.site_xmat if f[0] == "site" else self.data.xmat
|
| 114 |
+
return m[f[1]].reshape(3, 3).copy()
|
| 115 |
+
|
| 116 |
+
# --- kinematics ----------------------------------------------------------------------
|
| 117 |
+
def joint_dict(self, q) -> dict[str, float]:
|
| 118 |
+
"""Joint vector (``spec.joints`` order) or dict -> complete ``{joint: value}``."""
|
| 119 |
+
if q is None:
|
| 120 |
+
d = {}
|
| 121 |
+
elif isinstance(q, dict):
|
| 122 |
+
d = {k: float(v) for k, v in q.items()}
|
| 123 |
+
else:
|
| 124 |
+
q = np.asarray(q, np.float64).reshape(-1)
|
| 125 |
+
if len(q) != len(self.spec.joints):
|
| 126 |
+
raise ValueError(f"expected {len(self.spec.joints)} joints, got {len(q)}")
|
| 127 |
+
d = {n: float(v) * self.spec.joint_scale.get(n, 1.0) for n, v in zip(self.spec.joints, q) if n}
|
| 128 |
+
for joint, ratio, src in self.spec.coupling:
|
| 129 |
+
if src in d:
|
| 130 |
+
d[joint] = ratio * d[src]
|
| 131 |
+
d.update(self.spec.fixed_joints)
|
| 132 |
+
return d
|
| 133 |
+
|
| 134 |
+
def set_state(self, q) -> None:
|
| 135 |
+
self.data.qpos[:] = self.model.qpos0
|
| 136 |
+
for n, v in self.joint_dict(q).items():
|
| 137 |
+
adr = self._adr.get(n)
|
| 138 |
+
if adr is None:
|
| 139 |
+
adr = self._adr[n] = self._joint_adr(n)
|
| 140 |
+
self.data.qpos[adr] = v
|
| 141 |
+
self._mj.mj_kinematics(self.model, self.data)
|
| 142 |
+
|
| 143 |
+
def _hand_points(self) -> np.ndarray:
|
| 144 |
+
"""Keypoints of the current state, base frame, with the per-finger length scale.
|
| 145 |
+
|
| 146 |
+
The scale pulls each finger's distal points toward its knuckle
|
| 147 |
+
(``p <- p_knuckle + s * (p - p_knuckle)``), a fitted correction for model fingers that
|
| 148 |
+
render longer than the real hand.
|
| 149 |
+
"""
|
| 150 |
+
out = empty_hands()
|
| 151 |
+
for hi, h in enumerate(HANDS):
|
| 152 |
+
if h not in self._kp:
|
| 153 |
+
continue
|
| 154 |
+
p = np.stack([self._pos(f) for f in self._kp[h]])
|
| 155 |
+
for finger, (k, distal) in RT.CHAINS17.items():
|
| 156 |
+
s = self.spec.finger_scale.get(finger)
|
| 157 |
+
if s is not None:
|
| 158 |
+
p[list(distal)] = p[k] + s * (p[list(distal)] - p[k])
|
| 159 |
+
out[hi] = p
|
| 160 |
+
return out
|
| 161 |
+
|
| 162 |
+
def fk(self, q) -> dict[str, dict[str, np.ndarray]]:
|
| 163 |
+
self.set_state(q)
|
| 164 |
+
kp = self._hand_points()
|
| 165 |
+
return {h: {"wrist_pos": self._pos(self._wrist[h]), "wrist_rot": self._rot(self._wrist[h]),
|
| 166 |
+
"keypoints": kp[HANDS.index(h)]}
|
| 167 |
+
for h in HANDS if h in self._wrist}
|
| 168 |
+
|
| 169 |
+
def hand_keypoints(self, q) -> np.ndarray:
|
| 170 |
+
self.set_state(q)
|
| 171 |
+
return self._hand_points()
|
| 172 |
+
|
| 173 |
+
def _camera_pose_now(self, view: str) -> np.ndarray:
|
| 174 |
+
m = self.spec.cameras[view].mount
|
| 175 |
+
if m.extrinsic is not None:
|
| 176 |
+
return m.extrinsic.copy()
|
| 177 |
+
f = self._mount[view]
|
| 178 |
+
R_frame = self._rot(f)
|
| 179 |
+
T = np.eye(4)
|
| 180 |
+
T[:3, :3] = R_frame @ m.rotation.T
|
| 181 |
+
T[:3, 3] = self._pos(f) + R_frame @ m.translation
|
| 182 |
+
return T
|
| 183 |
+
|
| 184 |
+
def camera_pose(self, q, view: str) -> np.ndarray:
|
| 185 |
+
self.set_state(q)
|
| 186 |
+
return self._camera_pose_now(view)
|
| 187 |
+
|
| 188 |
+
def keypoints_in_view(self, q, view: str, image_size):
|
| 189 |
+
"""One FK pass -> projected skeleton ``kp (2,17,2)``, ``vis (2,17)`` of a robot frame."""
|
| 190 |
+
self.set_state(q)
|
| 191 |
+
T = np.linalg.inv(self._camera_pose_now(view))
|
| 192 |
+
pts = self._hand_points() @ T[:3, :3].T + T[:3, 3]
|
| 193 |
+
return self.keypoints_2d(pts, view, image_size, frame="camera")
|
| 194 |
+
|
| 195 |
+
# --- rendering -----------------------------------------------------------------------
|
| 196 |
+
def _renderer(self, view: str, image_size, scale: float):
|
| 197 |
+
key = (view, tuple(image_size), scale)
|
| 198 |
+
if key in self._renderers:
|
| 199 |
+
return self._renderers[key]
|
| 200 |
+
mj, model = self._mj, self.model
|
| 201 |
+
inc, exc = tuple(self.spec.silhouette_bodies), tuple(self.spec.silhouette_exclude)
|
| 202 |
+
for g in range(model.ngeom):
|
| 203 |
+
body = mj.mj_id2name(model, mj.mjtObj.mjOBJ_BODY, int(model.geom_bodyid[g])) or ""
|
| 204 |
+
show = body.startswith(inc) and not (exc and body.startswith(exc))
|
| 205 |
+
model.geom_group[g] = 0 if show else _SIL_HIDDEN_GROUP
|
| 206 |
+
# Render a symmetric wide pinhole, then remap each output pixel through the real
|
| 207 |
+
# camera's (distorted, off-centre) ray so the silhouette matches the projection.
|
| 208 |
+
W, H = image_size
|
| 209 |
+
w, h = max(8, round(W * scale)), max(8, round(H * scale))
|
| 210 |
+
cam = self.camera(view)
|
| 211 |
+
u, v = np.meshgrid((np.arange(w) + 0.5) * W / w - 0.5, (np.arange(h) + 0.5) * H / h - 0.5)
|
| 212 |
+
n = cam.normalized(np.stack([u, v], -1), image_size)
|
| 213 |
+
f = cam.K(image_size)[1, 1] * h / H
|
| 214 |
+
ext = np.abs(n.reshape(-1, 2)).max(0) * 1.03
|
| 215 |
+
Wp, Hp = int(2 * np.ceil(ext[0] * f)) + 2, int(2 * np.ceil(ext[1] * f)) + 2
|
| 216 |
+
mx = (n[..., 0] * f + Wp / 2.0 - 0.5).astype(np.float32)
|
| 217 |
+
my = (n[..., 1] * f + Hp / 2.0 - 0.5).astype(np.float32)
|
| 218 |
+
model.vis.global_.offwidth = max(int(model.vis.global_.offwidth), Wp)
|
| 219 |
+
model.vis.global_.offheight = max(int(model.vis.global_.offheight), Hp)
|
| 220 |
+
model.vis.map.znear = 0.001
|
| 221 |
+
model.vis.quality.offsamples = 0 # MSAA would blend segmentation ids
|
| 222 |
+
r = mj.Renderer(model, height=Hp, width=Wp)
|
| 223 |
+
r.enable_segmentation_rendering()
|
| 224 |
+
opt = mj.MjvOption()
|
| 225 |
+
opt.geomgroup[:] = 0
|
| 226 |
+
opt.geomgroup[0] = 1
|
| 227 |
+
opt.sitegroup[:] = 0
|
| 228 |
+
mcam = mj.MjvCamera()
|
| 229 |
+
mj.mjv_defaultCamera(mcam)
|
| 230 |
+
self._renderers[key] = (r, opt, mcam, mx, my, f, Hp)
|
| 231 |
+
return self._renderers[key]
|
| 232 |
+
|
| 233 |
+
def silhouette(self, q, view: str, image_size=None, scale: float = 0.5) -> np.ndarray:
|
| 234 |
+
cam = self.camera(view)
|
| 235 |
+
image_size = tuple(image_size or (cam.width, cam.height))
|
| 236 |
+
r, opt, mcam, mx, my, f, Hp = self._renderer(view, image_size, scale)
|
| 237 |
+
self.set_state(q)
|
| 238 |
+
T = self._camera_pose_now(view)
|
| 239 |
+
r.update_scene(self.data, camera=mcam, scene_option=opt)
|
| 240 |
+
near, far = 0.02, 6.0
|
| 241 |
+
half_h = near * (Hp / 2.0) / f
|
| 242 |
+
for gc in r.scene.camera:
|
| 243 |
+
gc.pos[:] = T[:3, 3]
|
| 244 |
+
gc.forward[:] = T[:3, 2]
|
| 245 |
+
gc.up[:] = -T[:3, 1]
|
| 246 |
+
gc.frustum_center = 0.0
|
| 247 |
+
gc.frustum_bottom, gc.frustum_top = -half_h, half_h
|
| 248 |
+
gc.frustum_near, gc.frustum_far = near, far
|
| 249 |
+
gc.orthographic = 0
|
| 250 |
+
seg = r.render()[:, :, 0]
|
| 251 |
+
sil = cv2.remap((seg >= 0).astype(np.uint8), mx, my, cv2.INTER_NEAREST,
|
| 252 |
+
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
| 253 |
+
return cv2.resize(sil, image_size, interpolation=cv2.INTER_NEAREST) > 0
|
| 254 |
+
|
| 255 |
+
# --- retargeting ---------------------------------------------------------------------
|
| 256 |
+
def _hand_vectors(self, hand: str, x: np.ndarray):
|
| 257 |
+
"""Retargeting vectors (wrist frame) of one robot hand at hand-joint values ``x``."""
|
| 258 |
+
self.set_state(dict(zip(self.spec.retarget.joints[hand], x)))
|
| 259 |
+
return RT.local_vectors(self._hand_points()[HANDS.index(hand)])
|
| 260 |
+
|
| 261 |
+
def _palm(self, hand: str) -> float:
|
| 262 |
+
if hand not in self._rt_palm:
|
| 263 |
+
lo, hi = self._rt_bounds[hand]
|
| 264 |
+
self.set_state(dict(zip(self.spec.retarget.joints[hand], np.clip(0.0, lo, hi))))
|
| 265 |
+
self._rt_palm[hand] = RT.palm_size(self._hand_points()[HANDS.index(hand)])
|
| 266 |
+
return self._rt_palm[hand]
|
| 267 |
+
|
| 268 |
+
def _solve_hand(self, hand: str, h17: np.ndarray, x_prev=None):
|
| 269 |
+
"""AnyTeleop vector retargeting of one hand: hand joints (model order) or None."""
|
| 270 |
+
from scipy.optimize import least_squares
|
| 271 |
+
|
| 272 |
+
rt = self.spec.retarget
|
| 273 |
+
target = RT.local_vectors(h17)
|
| 274 |
+
if target is None:
|
| 275 |
+
return None
|
| 276 |
+
scale = rt.scaling if rt.scaling is not None else self._palm(hand) / max(RT.palm_size(h17), 1e-6)
|
| 277 |
+
target = target * scale
|
| 278 |
+
lo, hi = self._rt_bounds[hand]
|
| 279 |
+
x0 = np.clip(np.zeros(len(lo)) if x_prev is None else np.asarray(x_prev, np.float64), lo, hi)
|
| 280 |
+
x0 = np.clip(x0, lo + 1e-9, hi - 1e-9)
|
| 281 |
+
w = np.sqrt(rt.smoothing)
|
| 282 |
+
|
| 283 |
+
def residual(x):
|
| 284 |
+
v = self._hand_vectors(hand, x)
|
| 285 |
+
r = np.zeros(target.size) if v is None else (v - target).ravel()
|
| 286 |
+
return r if x_prev is None else np.concatenate([r, w * (x - x0)])
|
| 287 |
+
|
| 288 |
+
sol = least_squares(residual, x0, bounds=(lo, hi), loss="huber", f_scale=rt.huber_delta,
|
| 289 |
+
diff_step=1e-4, max_nfev=200)
|
| 290 |
+
return sol.x
|
| 291 |
+
|
| 292 |
+
def retarget_hand(self, human_kp3d, valid=None, prev=None):
|
| 293 |
+
j21 = RT.to21(human_kp3d)
|
| 294 |
+
if valid is None:
|
| 295 |
+
valid = np.isfinite(j21).all(axis=(-1, -2))
|
| 296 |
+
rt = self.spec.retarget
|
| 297 |
+
q = {}
|
| 298 |
+
for hi, h in enumerate(HANDS):
|
| 299 |
+
joints = rt.joints.get(h)
|
| 300 |
+
if not valid[hi] or not joints:
|
| 301 |
+
continue
|
| 302 |
+
x_prev = None
|
| 303 |
+
if prev and all(n in prev for n in joints):
|
| 304 |
+
x_prev = [prev[n] for n in joints]
|
| 305 |
+
x = self._solve_hand(h, RT.human17(j21[hi]), x_prev)
|
| 306 |
+
if x is not None:
|
| 307 |
+
q.update({n: float(v) for n, v in zip(joints, x)})
|
| 308 |
+
out = empty_hands()
|
| 309 |
+
if not q:
|
| 310 |
+
return out, q
|
| 311 |
+
robot = self.hand_keypoints(q)
|
| 312 |
+
for hi, h in enumerate(HANDS):
|
| 313 |
+
if valid[hi] and all(n in q for n in rt.joints.get(h) or [None]):
|
| 314 |
+
placed = RT.place_on_human(robot[hi], j21[hi], rt.bone_match)
|
| 315 |
+
if placed is not None:
|
| 316 |
+
out[hi] = placed
|
| 317 |
+
return out, q
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
__all__ = ["MujocoEmbodiment", "MUJOCO_PIN"]
|
robotryon/embodiment/retarget.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retarget a reconstructed human hand onto a robot hand (AnyTeleop vector retargeting).
|
| 2 |
+
|
| 3 |
+
The skeleton that conditions the generative models must look like the target robot's hand,
|
| 4 |
+
so a human hand is not drawn directly. Instead (Appendix B.2.2):
|
| 5 |
+
|
| 6 |
+
1. Both hands are expressed in a wrist frame (origin at the wrist, x toward the knuckle
|
| 7 |
+
centroid, z along the knuckle-plane normal) built identically from the human joints and
|
| 8 |
+
from the robot's 17 keypoints.
|
| 9 |
+
2. Following the vector retargeting of AnyTeleop (Qin et al., 2023), the robot hand joints are
|
| 10 |
+
optimized so that the robot's wrist-to-fingertip and wrist-to-middle-joint vectors match the
|
| 11 |
+
human's, scaled by the ratio of the palm sizes, within the joint limits of the robot model,
|
| 12 |
+
with a small penalty on the change from the previous frame (``VECTORS17``,
|
| 13 |
+
``MujocoEmbodiment.retarget_hand``).
|
| 14 |
+
3. Robot forward kinematics at those joints gives the robot hand's 17 keypoints. Bone
|
| 15 |
+
matching stretches each robot finger to the human's knuckle distance and finger length
|
| 16 |
+
while keeping the robot's directions (the robot's own curl), so the fingertips reach what
|
| 17 |
+
the human touches.
|
| 18 |
+
4. The result is placed at the human wrist frame, in the human's camera frame.
|
| 19 |
+
|
| 20 |
+
The frame helpers and the vector set live here; the optimization needs FK and is done by the
|
| 21 |
+
embodiment.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
from robotryon.skeleton.topology import MEDIAPIPE21_TO_17
|
| 29 |
+
|
| 30 |
+
# MediaPipe / MANO 21-joint layout: wrist, then four joints per finger, proximal to distal.
|
| 31 |
+
CHAINS21 = {"thumb": (1, 2, 3, 4), "index": (5, 6, 7, 8), "middle": (9, 10, 11, 12),
|
| 32 |
+
"ring": (13, 14, 15, 16), "little": (17, 18, 19, 20)}
|
| 33 |
+
# 17-keypoint layout (robotryon.skeleton.topology): (knuckle, distal points) per finger.
|
| 34 |
+
CHAINS17 = {"thumb": (1, (2, 3, 4)), "index": (5, (6, 7)), "middle": (8, (9, 10)),
|
| 35 |
+
"ring": (11, (12, 13)), "little": (14, (15, 16))}
|
| 36 |
+
FINGERS4 = ("index", "middle", "ring", "little")
|
| 37 |
+
|
| 38 |
+
# Robot wrist frame from the 17-keypoint layout: wrist, index knuckle, little knuckle.
|
| 39 |
+
WRIST17, INDEX17, LITTLE17 = 0, 5, 14
|
| 40 |
+
|
| 41 |
+
# Retargeting vectors as (origin, target) pairs of the 17-keypoint layout: wrist to every
|
| 42 |
+
# fingertip and wrist to the joint before it (thumb distal, index/middle/ring/little middle).
|
| 43 |
+
TIPS17 = tuple(distal[-1] for _, distal in CHAINS17.values())
|
| 44 |
+
MIDS17 = (3, 6, 9, 12, 15)
|
| 45 |
+
VECTORS17 = tuple((WRIST17, t) for t in TIPS17) + tuple((WRIST17, m) for m in MIDS17)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def to21(kp: np.ndarray) -> np.ndarray:
|
| 49 |
+
"""``(..., 17|21, 3)`` -> 21 joints. Missing DIP joints are put midway between PIP and
|
| 50 |
+
tip, which leaves every finger length and direction used below unchanged."""
|
| 51 |
+
kp = np.asarray(kp, np.float64)
|
| 52 |
+
if kp.shape[-2] == 21:
|
| 53 |
+
return kp
|
| 54 |
+
out = np.zeros(kp.shape[:-2] + (21, 3))
|
| 55 |
+
out[..., MEDIAPIPE21_TO_17, :] = kp
|
| 56 |
+
for pip, dip, tip in ((6, 7, 8), (10, 11, 12), (14, 15, 16), (18, 19, 20)):
|
| 57 |
+
out[..., dip, :] = 0.5 * (out[..., pip, :] + out[..., tip, :])
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def wrist_frame(w, imcp, lmcp):
|
| 62 |
+
"""Right-handed frame (columns = axes) at the wrist, or None when degenerate."""
|
| 63 |
+
x = (imcp + lmcp) * 0.5 - w
|
| 64 |
+
n = np.cross(imcp - w, lmcp - w)
|
| 65 |
+
nx, nn = np.linalg.norm(x), np.linalg.norm(n)
|
| 66 |
+
if nx < 1e-9 or nn < 1e-9:
|
| 67 |
+
return None
|
| 68 |
+
x, n = x / nx, n / nn
|
| 69 |
+
return np.stack([x, np.cross(n, x), n], axis=1)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def palm_size(p17: np.ndarray) -> float:
|
| 73 |
+
"""Mean wrist-to-knuckle distance of the four fingers (17-keypoint layout)."""
|
| 74 |
+
return float(np.mean([np.linalg.norm(p17[CHAINS17[f][0]] - p17[WRIST17]) for f in FINGERS4]))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def local_vectors(p17: np.ndarray):
|
| 78 |
+
"""``(17, 3)`` keypoints -> ``(len(VECTORS17), 3)`` vectors in the hand's wrist frame,
|
| 79 |
+
or None when the frame is degenerate."""
|
| 80 |
+
R = wrist_frame(p17[WRIST17], p17[INDEX17], p17[LITTLE17])
|
| 81 |
+
if R is None:
|
| 82 |
+
return None
|
| 83 |
+
o, t = zip(*VECTORS17)
|
| 84 |
+
return (p17[list(t)] - p17[list(o)]) @ R
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def human17(j21: np.ndarray) -> np.ndarray:
|
| 88 |
+
"""``(21, 3)`` human joints -> the 17-keypoint layout."""
|
| 89 |
+
return np.asarray(j21, np.float64)[list(MEDIAPIPE21_TO_17)]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def bone_match(local17: np.ndarray, j21: np.ndarray) -> np.ndarray:
|
| 93 |
+
"""Stretch robot finger segments (wrist-frame 17 keypoints) to the human's lengths.
|
| 94 |
+
|
| 95 |
+
Each knuckle is moved along its own direction to the human wrist-to-knuckle distance, then
|
| 96 |
+
the rest of the finger is scaled about the knuckle so knuckle-to-tip matches the human's.
|
| 97 |
+
Directions stay the robot's.
|
| 98 |
+
"""
|
| 99 |
+
out = local17.copy()
|
| 100 |
+
for f, (k, distal) in CHAINS17.items():
|
| 101 |
+
hm, ht = CHAINS21[f][0], CHAINS21[f][-1]
|
| 102 |
+
r_palm, h_palm = np.linalg.norm(local17[k]), np.linalg.norm(j21[hm] - j21[0])
|
| 103 |
+
if r_palm > 1e-6 and h_palm > 1e-6:
|
| 104 |
+
out[k] = local17[k] * (h_palm / r_palm)
|
| 105 |
+
r_fin = np.linalg.norm(local17[distal[-1]] - local17[k])
|
| 106 |
+
h_fin = np.linalg.norm(j21[ht] - j21[hm])
|
| 107 |
+
s = h_fin / r_fin if (r_fin > 1e-6 and h_fin > 1e-6) else 1.0
|
| 108 |
+
for i in distal:
|
| 109 |
+
out[i] = out[k] + s * (local17[i] - local17[k])
|
| 110 |
+
return out
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def place_on_human(robot17: np.ndarray, j21: np.ndarray, match_bones: bool = True):
|
| 114 |
+
"""Move robot keypoints (any frame) onto the human wrist frame; None if degenerate."""
|
| 115 |
+
Rr = wrist_frame(robot17[WRIST17], robot17[INDEX17], robot17[LITTLE17])
|
| 116 |
+
Rh = wrist_frame(j21[0], j21[5], j21[17])
|
| 117 |
+
if Rr is None or Rh is None:
|
| 118 |
+
return None
|
| 119 |
+
local = (robot17 - robot17[WRIST17]) @ Rr
|
| 120 |
+
if match_bones:
|
| 121 |
+
local = bone_match(local, j21)
|
| 122 |
+
return j21[0] + local @ Rh.T
|
robotryon/embodiment/spec.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embodiment description parsed from YAML (see ``configs/embodiments/gr1.yaml``).
|
| 2 |
+
|
| 3 |
+
Everything that differs between robots lives here as data: the kinematic model file, the
|
| 4 |
+
layout of the recorded joint vector, passive joints coupled to actuated ones, the link that
|
| 5 |
+
stands for each of the 17 hand keypoints, the cameras, the links that make up the robot's
|
| 6 |
+
silhouette, and which hand joints the human-hand retargeting moves. Parsing is pure Python
|
| 7 |
+
so configs can be validated without a physics engine.
|
| 8 |
+
|
| 9 |
+
Strings may contain ``{s}``, which is replaced by each hand's side token (``hands.sides``), so
|
| 10 |
+
a mirror-symmetric robot is described once for both hands.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import yaml
|
| 21 |
+
|
| 22 |
+
from robotryon.skeleton.topology import NUM_KEYPOINTS
|
| 23 |
+
|
| 24 |
+
from .camera import CameraModel
|
| 25 |
+
|
| 26 |
+
HANDS = ("left", "right")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class CameraMount:
|
| 31 |
+
"""Where a camera sits: on a model frame that moves with the robot, or fixed in the base.
|
| 32 |
+
|
| 33 |
+
``frame`` is ``site:<name>``/``body:<name>`` (or a bare name, resolved site first);
|
| 34 |
+
``rotation`` maps the frame's axes to the optical frame (``p_opt = R @ p_frame``);
|
| 35 |
+
``translation`` offsets the lens origin in the mount frame (e.g. a stereo baseline).
|
| 36 |
+
``extrinsic`` instead gives a fixed 4x4 camera-to-base pose.
|
| 37 |
+
"""
|
| 38 |
+
frame: str | None = None
|
| 39 |
+
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
| 40 |
+
translation: np.ndarray = field(default_factory=lambda: np.zeros(3))
|
| 41 |
+
extrinsic: np.ndarray | None = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class CameraSpec:
|
| 46 |
+
intrinsics: CameraModel
|
| 47 |
+
mount: CameraMount
|
| 48 |
+
video_key: str | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class RetargetSpec:
|
| 53 |
+
"""Vector retargeting of a human hand onto the robot hand (``robotryon.embodiment.retarget``).
|
| 54 |
+
|
| 55 |
+
``joints``: per hand, the actuated hand joints the optimization moves; coupled joints follow
|
| 56 |
+
their source through ``coupling`` and the limits come from the robot model.
|
| 57 |
+
``scaling``: factor on the human vectors, or None for the ratio of the palm sizes.
|
| 58 |
+
``smoothing``: weight of the squared change from the previous frame's joints.
|
| 59 |
+
``huber_delta``: Huber threshold (m) of the vector residuals.
|
| 60 |
+
``limits``: ``{joint: (lo, hi)}`` narrowing the model's joint limits for the retargeting, e.g.
|
| 61 |
+
to keep finger joints out of hyperextension the model allows but the real hand never uses.
|
| 62 |
+
"""
|
| 63 |
+
joints: dict[str, list[str]] = field(default_factory=dict)
|
| 64 |
+
limits: dict[str, tuple[float, float]] = field(default_factory=dict)
|
| 65 |
+
scaling: float | None = None
|
| 66 |
+
smoothing: float = 4e-3
|
| 67 |
+
huber_delta: float = 0.02
|
| 68 |
+
bone_match: bool = True
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass
|
| 72 |
+
class EmbodimentSpec:
|
| 73 |
+
name: str
|
| 74 |
+
model_path: str
|
| 75 |
+
joints: list[str | None]
|
| 76 |
+
joint_scale: dict[str, float] # joint -> model value / recorded value
|
| 77 |
+
arm_joints: dict[str, tuple[int, int]] # hand -> [start, stop) in the joint vector
|
| 78 |
+
coupling: list[tuple[str, float, str]] # (joint, ratio, source joint)
|
| 79 |
+
fixed_joints: dict[str, float]
|
| 80 |
+
keypoints: dict[str, list[str]] # hand -> 17 frame names
|
| 81 |
+
keypoint_offsets: dict[str, list] # hand -> 17 offsets (m, in the frame) or None
|
| 82 |
+
wrist_frame: dict[str, str] # hand -> frame giving the wrist pose
|
| 83 |
+
finger_scale: dict[str, float]
|
| 84 |
+
cameras: dict[str, CameraSpec]
|
| 85 |
+
silhouette_bodies: list[str]
|
| 86 |
+
silhouette_exclude: list[str]
|
| 87 |
+
retarget: RetargetSpec = field(default_factory=RetargetSpec)
|
| 88 |
+
raw: dict = field(default_factory=dict)
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def hands(self) -> list[str]:
|
| 92 |
+
return [h for h in HANDS if h in self.keypoints]
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def views(self) -> list[str]:
|
| 96 |
+
return list(self.cameras)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _fmt(x: Any, s: str) -> Any:
|
| 100 |
+
return x.replace("{s}", s) if isinstance(x, str) else x
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _per_hand(value, hand: str):
|
| 104 |
+
"""``value`` or ``value[hand]`` when it is given per hand."""
|
| 105 |
+
if isinstance(value, dict) and hand in value:
|
| 106 |
+
return value[hand]
|
| 107 |
+
return value
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def parse_spec(cfg: dict, base_dir: str | Path | None = None) -> EmbodimentSpec:
|
| 111 |
+
hands_cfg = cfg.get("hands", {})
|
| 112 |
+
sides = hands_cfg.get("sides", {"left": "left", "right": "right"})
|
| 113 |
+
|
| 114 |
+
keypoints, keypoint_offsets, wrist_frame = {}, {}, {}
|
| 115 |
+
for hand, tok in sides.items():
|
| 116 |
+
if hand not in HANDS:
|
| 117 |
+
raise ValueError(f"hands.sides keys must be left/right, got {hand!r}")
|
| 118 |
+
entries = (hands_cfg.get(hand) or {}).get("keypoints") or hands_cfg.get("keypoints")
|
| 119 |
+
if entries is None:
|
| 120 |
+
raise ValueError(f"no keypoints given for the {hand} hand")
|
| 121 |
+
if len(entries) != NUM_KEYPOINTS:
|
| 122 |
+
raise ValueError(f"{hand} hand needs {NUM_KEYPOINTS} keypoints, got {len(entries)}")
|
| 123 |
+
# an entry is a frame name, or {frame: name, offset: [x, y, z]} for a point fixed in it
|
| 124 |
+
names = [_fmt(e["frame"] if isinstance(e, dict) else e, tok) for e in entries]
|
| 125 |
+
keypoint_offsets[hand] = [np.asarray(_per_hand(e["offset"], hand), np.float64).reshape(3)
|
| 126 |
+
if isinstance(e, dict) and e.get("offset") is not None else None
|
| 127 |
+
for e in entries]
|
| 128 |
+
keypoints[hand] = names
|
| 129 |
+
wf = (hands_cfg.get(hand) or {}).get("wrist_frame") or hands_cfg.get("wrist_frame")
|
| 130 |
+
wrist_frame[hand] = _fmt(wf, tok) if wf else names[0]
|
| 131 |
+
|
| 132 |
+
coupling = []
|
| 133 |
+
for c in cfg.get("coupling", []) or []:
|
| 134 |
+
templated = "{s}" in c["joint"] or "{s}" in c["source"]
|
| 135 |
+
for tok in (sides.values() if templated else [""]):
|
| 136 |
+
coupling.append((_fmt(c["joint"], tok), float(c["ratio"]), _fmt(c["source"], tok)))
|
| 137 |
+
|
| 138 |
+
cameras = {}
|
| 139 |
+
for view, c in (cfg.get("cameras") or {}).items():
|
| 140 |
+
m = c.get("mount", {}) or {}
|
| 141 |
+
ext = c.get("extrinsic")
|
| 142 |
+
mount = CameraMount(
|
| 143 |
+
frame=m.get("frame"),
|
| 144 |
+
rotation=np.asarray(m.get("rotation", np.eye(3)), np.float64).reshape(3, 3),
|
| 145 |
+
translation=np.asarray(m.get("translation", [0, 0, 0]), np.float64).reshape(3),
|
| 146 |
+
extrinsic=None if ext is None else np.asarray(ext, np.float64).reshape(4, 4))
|
| 147 |
+
if mount.frame is None and mount.extrinsic is None:
|
| 148 |
+
raise ValueError(f"camera {view!r} needs mount.frame or extrinsic")
|
| 149 |
+
cameras[view] = CameraSpec(CameraModel.from_dict(c["intrinsics"]), mount,
|
| 150 |
+
c.get("video_key"))
|
| 151 |
+
|
| 152 |
+
rt = cfg.get("retarget", {}) or {}
|
| 153 |
+
if "drivers" in rt:
|
| 154 |
+
raise ValueError("retarget.drivers (percentile retargeting) was replaced by vector "
|
| 155 |
+
"retargeting: list the hand joints under retarget.joints instead")
|
| 156 |
+
rt_joints = {hand: [_fmt(j, tok) for j in (_per_hand(rt.get("joints") or [], hand) or [])]
|
| 157 |
+
for hand, tok in sides.items()}
|
| 158 |
+
limits = {}
|
| 159 |
+
for j, r in (rt.get("limits") or {}).items():
|
| 160 |
+
for hand, tok in sides.items():
|
| 161 |
+
rr = _per_hand(r, hand)
|
| 162 |
+
limits[_fmt(j, tok)] = (float(rr[0]), float(rr[1]))
|
| 163 |
+
scaling = rt.get("scaling", "auto")
|
| 164 |
+
retarget = RetargetSpec(
|
| 165 |
+
joints=rt_joints,
|
| 166 |
+
limits=limits,
|
| 167 |
+
scaling=None if scaling in (None, "auto") else float(scaling),
|
| 168 |
+
smoothing=float(rt.get("smoothing", 4e-3)),
|
| 169 |
+
huber_delta=float(rt.get("huber_delta", 0.02)),
|
| 170 |
+
bone_match=bool(rt.get("bone_match", True)))
|
| 171 |
+
|
| 172 |
+
model_path = str(cfg["model_path"])
|
| 173 |
+
if base_dir is not None and not Path(model_path).is_absolute():
|
| 174 |
+
model_path = str(Path(base_dir) / model_path)
|
| 175 |
+
sil = cfg.get("silhouette", {}) or {}
|
| 176 |
+
toks = list(sides.values())
|
| 177 |
+
expand = lambda xs: sorted({_fmt(x, t) for x in xs or [] for t in toks}) # noqa: E731
|
| 178 |
+
return EmbodimentSpec(
|
| 179 |
+
name=str(cfg.get("name", "robot")),
|
| 180 |
+
model_path=model_path,
|
| 181 |
+
joints=[j["name"] if isinstance(j, dict) else j for j in cfg.get("joints", [])],
|
| 182 |
+
joint_scale={j["name"]: float(j["scale"]) for j in cfg.get("joints", [])
|
| 183 |
+
if isinstance(j, dict) and "scale" in j},
|
| 184 |
+
arm_joints={h: (int(r[0]), int(r[1])) for h, r in (cfg.get("arm_joints") or {}).items()},
|
| 185 |
+
coupling=coupling,
|
| 186 |
+
fixed_joints={k: float(v) for k, v in (cfg.get("fixed_joints") or {}).items()},
|
| 187 |
+
keypoints=keypoints,
|
| 188 |
+
keypoint_offsets=keypoint_offsets,
|
| 189 |
+
wrist_frame=wrist_frame,
|
| 190 |
+
finger_scale={k: float(v) for k, v in (hands_cfg.get("finger_scale") or {}).items()},
|
| 191 |
+
cameras=cameras,
|
| 192 |
+
silhouette_bodies=expand(sil.get("bodies")),
|
| 193 |
+
silhouette_exclude=expand(sil.get("exclude")),
|
| 194 |
+
retarget=retarget,
|
| 195 |
+
raw=cfg,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def load_spec(path: str | Path) -> EmbodimentSpec:
|
| 200 |
+
path = Path(path)
|
| 201 |
+
return parse_spec(yaml.safe_load(path.read_text()), base_dir=path.parent)
|
robotryon/idm/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inverse dynamics model: training data, model, action labelling and wrist correction.
|
| 2 |
+
|
| 3 |
+
Torch-dependent modules (``model``, ``data``, ``labeling``) are imported explicitly by the
|
| 4 |
+
scripts; ``wrist_correction`` only needs numpy/scipy.
|
| 5 |
+
"""
|
robotryon/idm/data.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
#
|
| 4 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
# you may not use this file except in compliance with the License.
|
| 6 |
+
# You may obtain a copy of the License at
|
| 7 |
+
#
|
| 8 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
#
|
| 10 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
# See the License for the specific language governing permissions and
|
| 14 |
+
# limitations under the License.
|
| 15 |
+
#
|
| 16 |
+
# Adapted from GR00T-Dreams (Apache-2.0): gr00t/data/dataset.py (LeRobot v2.1 reader),
|
| 17 |
+
# gr00t/data/transform/{video,state_action,mirror}.py, gr00t/model/transforms_idm.py and the
|
| 18 |
+
# multi-embodiment mixture. Rewritten as one compact pipeline; the eval-mode image path and
|
| 19 |
+
# the q99 normalisation are numerically identical to the original.
|
| 20 |
+
"""IDM training data: LeRobot v2.1 datasets of robot videos with joint actions.
|
| 21 |
+
|
| 22 |
+
Each embodiment is declared in the config (no robot list in code)::
|
| 23 |
+
|
| 24 |
+
name: openarm_rh56f1 # also the key of its statistics in the checkpoint metadata
|
| 25 |
+
embodiment_id: 29 # row of the per-embodiment action encoder/decoder
|
| 26 |
+
root: /path/to/lerobot_dataset
|
| 27 |
+
views: [camera_ego_left, camera_ego_right] # LeRobot video keys; one key = mono
|
| 28 |
+
view_ids: [0, 1, 2, 3] # view-embedding id of [t0 v0, t0 v1, t1 v0, t1 v1]
|
| 29 |
+
action_groups: [neck_joints, left_arm_joints, ...] # modality.json groups, raw order
|
| 30 |
+
hand_groups: [left_hand_joints, right_hand_joints] # smoothed with the short SG window
|
| 31 |
+
norm: q99 # or q99_const (near-constant joints -> 0)
|
| 32 |
+
video_size: [512, 288] # dataset frame size; labelled videos are resized to it
|
| 33 |
+
mirror: {prob, view_swap, swap, sign} # optional left/right reflection augmentation
|
| 34 |
+
|
| 35 |
+
A sample is frames (t, t+K) of every view and the chunk a_{t:t+K}, normalised to [-1, 1] with
|
| 36 |
+
the dataset's q01/q99 and zero-padded to the model's action_dim.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
import json
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
|
| 44 |
+
import numpy as np
|
| 45 |
+
import torch
|
| 46 |
+
from torch.utils.data import Dataset
|
| 47 |
+
|
| 48 |
+
IMAGE_SIZE = 224
|
| 49 |
+
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 50 |
+
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 51 |
+
CONST_EPS = 1e-3
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ----------------------------------------------------------------------------- normalisation
|
| 55 |
+
def normalize(x: torch.Tensor, q01: torch.Tensor, q99: torch.Tensor, mode: str) -> torch.Tensor:
|
| 56 |
+
q01, q99 = q01.to(x.dtype), q99.to(x.dtype)
|
| 57 |
+
if mode == "q99": # constant dims (q01 == q99) pass through
|
| 58 |
+
live = q01 != q99
|
| 59 |
+
out = x.clone()
|
| 60 |
+
elif mode == "q99_const": # near-constant dims are mapped to 0
|
| 61 |
+
live = (q99 - q01) > CONST_EPS
|
| 62 |
+
out = torch.zeros_like(x)
|
| 63 |
+
else:
|
| 64 |
+
raise ValueError(f"unknown norm mode {mode!r}")
|
| 65 |
+
out[..., live] = 2 * (x[..., live] - q01[live]) / (q99[live] - q01[live]) - 1
|
| 66 |
+
return out.clamp(-1, 1)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def unnormalize(x: torch.Tensor, q01: torch.Tensor, q99: torch.Tensor, mode: str) -> torch.Tensor:
|
| 70 |
+
q01, q99 = q01.to(x.dtype), q99.to(x.dtype)
|
| 71 |
+
out = (x + 1) / 2 * (q99 - q01) + q01
|
| 72 |
+
if mode == "q99_const":
|
| 73 |
+
out = torch.where((q99 - q01) > CONST_EPS, out, ((q01 + q99) / 2).expand_as(x))
|
| 74 |
+
return out
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def group_stats(stats: dict, groups: list[str]) -> tuple[torch.Tensor, torch.Tensor]:
|
| 78 |
+
"""Concatenate the per-group q01/q99 (metadata["statistics"]["action"]) in group order."""
|
| 79 |
+
q01 = np.concatenate([stats[g]["q01"] for g in groups])
|
| 80 |
+
q99 = np.concatenate([stats[g]["q99"] for g in groups])
|
| 81 |
+
return torch.tensor(q01.tolist()), torch.tensor(q99.tolist())
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ----------------------------------------------------------------------------- images
|
| 85 |
+
def preprocess_frames(frames: np.ndarray, train: bool = False, fov_range=None,
|
| 86 |
+
color_jitter=None) -> np.ndarray:
|
| 87 |
+
"""(N, H, W, 3) uint8 -> (N, 3, 224, 224) float32, ImageNet-normalised.
|
| 88 |
+
|
| 89 |
+
All N frames (every view and both timesteps of one sample) share one random draw, so the
|
| 90 |
+
stereo geometry and the temporal pair stay consistent. Train-time augmentation: field of
|
| 91 |
+
view (random zoom-in crop or zoom-out black padding at a random offset) and colour jitter.
|
| 92 |
+
"""
|
| 93 |
+
import torchvision.transforms.v2 as T
|
| 94 |
+
import torchvision.transforms.v2.functional as TF
|
| 95 |
+
|
| 96 |
+
x = torch.from_numpy(np.ascontiguousarray(frames)).to(torch.float32).div(255.0).permute(0, 3, 1, 2)
|
| 97 |
+
if train and fov_range is not None:
|
| 98 |
+
x = _random_fov(x, *fov_range)
|
| 99 |
+
x = TF.resize(x, [IMAGE_SIZE, IMAGE_SIZE], interpolation=T.InterpolationMode.BILINEAR,
|
| 100 |
+
antialias=True)
|
| 101 |
+
if train and color_jitter is not None:
|
| 102 |
+
x = T.ColorJitter(*color_jitter)(x)
|
| 103 |
+
u8 = (x.permute(0, 2, 3, 1) * 255).to(torch.uint8).numpy()
|
| 104 |
+
# Channels-last memory (a transposed view, no copy): cuDNN picks its convolution kernel by
|
| 105 |
+
# layout, and this is the layout the IDM was trained and evaluated with.
|
| 106 |
+
return ((u8.astype(np.float32) / 255.0 - _MEAN) / _STD).transpose(0, 3, 1, 2)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _random_fov(x: torch.Tensor, lo: float, hi: float) -> torch.Tensor:
|
| 110 |
+
import torchvision.transforms.v2.functional as TF
|
| 111 |
+
|
| 112 |
+
H, W = x.shape[-2:]
|
| 113 |
+
s = float(torch.empty(1).uniform_(lo, hi))
|
| 114 |
+
if s <= 1.0:
|
| 115 |
+
h, w = max(1, round(H * s)), max(1, round(W * s))
|
| 116 |
+
top, left = int(torch.randint(0, H - h + 1, (1,))), int(torch.randint(0, W - w + 1, (1,)))
|
| 117 |
+
return x[..., top:top + h, left:left + w]
|
| 118 |
+
h, w = round(H * s), round(W * s)
|
| 119 |
+
top, left = int(torch.randint(0, h - H + 1, (1,))), int(torch.randint(0, w - W + 1, (1,)))
|
| 120 |
+
return TF.pad(x, [left, top, w - W - left, h - H - top], fill=0.0)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def model_inputs(images: np.ndarray, spec: dict, horizon: int, action_dim: int) -> dict:
|
| 124 |
+
"""The per-sample fields every batch needs besides the action target."""
|
| 125 |
+
n_native = native_dim(spec)
|
| 126 |
+
mask = np.zeros((horizon, action_dim), dtype=bool)
|
| 127 |
+
mask[:, :n_native] = True
|
| 128 |
+
view_ids = list(spec["view_ids"])
|
| 129 |
+
assert len(view_ids) == len(images), f"view_ids {view_ids} for {len(images)} images"
|
| 130 |
+
return dict(images=images, view_ids=np.asarray(view_ids, np.int64), n_images=len(images),
|
| 131 |
+
embodiment_id=int(spec["embodiment_id"]), actions_mask=mask)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def collate(samples: list[dict]) -> dict:
|
| 135 |
+
out = {}
|
| 136 |
+
for k in samples[0]:
|
| 137 |
+
vals = [s[k] for s in samples]
|
| 138 |
+
cat = k in ("images", "view_ids")
|
| 139 |
+
out[k] = torch.from_numpy(np.concatenate(vals) if cat else np.stack(vals)) \
|
| 140 |
+
if isinstance(vals[0], np.ndarray) else torch.tensor(vals)
|
| 141 |
+
return out
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ----------------------------------------------------------------------------- LeRobot reader
|
| 145 |
+
class LeRobotMeta:
|
| 146 |
+
"""meta/{info,modality,episodes,stats} of a LeRobot v2.1 dataset, reduced to what the IDM
|
| 147 |
+
needs: per-group column slices of the action, video paths and episode lengths."""
|
| 148 |
+
|
| 149 |
+
def __init__(self, root: str | Path):
|
| 150 |
+
self.root = Path(root)
|
| 151 |
+
m = self.root / "meta"
|
| 152 |
+
self.info = json.loads((m / "info.json").read_text())
|
| 153 |
+
self.modality = json.loads((m / "modality.json").read_text())
|
| 154 |
+
self.episodes = [json.loads(l) for l in (m / "episodes.jsonl").read_text().splitlines() if l.strip()]
|
| 155 |
+
self.stats = json.loads((m / "stats.json").read_text())
|
| 156 |
+
|
| 157 |
+
def action_slice(self, group: str) -> tuple[str, int, int]:
|
| 158 |
+
g = self.modality["action"][group]
|
| 159 |
+
return g.get("original_key") or "action", g["start"], g["end"]
|
| 160 |
+
|
| 161 |
+
def action_statistics(self, groups: list[str]) -> dict:
|
| 162 |
+
out = {}
|
| 163 |
+
for g in groups:
|
| 164 |
+
col, a, b = self.action_slice(g)
|
| 165 |
+
out[g] = {k: list(np.asarray(v)[a:b]) for k, v in self.stats[col].items()}
|
| 166 |
+
return out
|
| 167 |
+
|
| 168 |
+
def parquet(self, ep: int) -> Path:
|
| 169 |
+
return self.root / self.info["data_path"].format(
|
| 170 |
+
episode_chunk=ep // self.info["chunks_size"], episode_index=ep)
|
| 171 |
+
|
| 172 |
+
def video(self, ep: int, view: str) -> Path:
|
| 173 |
+
key = self.modality["video"][view].get("original_key") or view
|
| 174 |
+
return self.root / self.info["video_path"].format(
|
| 175 |
+
episode_chunk=ep // self.info["chunks_size"], episode_index=ep, video_key=key)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def native_dim(spec: dict) -> int:
|
| 179 |
+
return int(spec["action_dim"])
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def frames_at(path: Path, timestamps: np.ndarray) -> np.ndarray:
|
| 183 |
+
"""Frames nearest to the given timestamps (seconds), (N, H, W, 3) uint8 RGB."""
|
| 184 |
+
import decord
|
| 185 |
+
|
| 186 |
+
vr = decord.VideoReader(str(path))
|
| 187 |
+
ts = vr.get_frame_timestamp(range(len(vr)))[:, :1]
|
| 188 |
+
return vr.get_batch(np.abs(ts - timestamps).argmin(axis=0)).asnumpy()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class Mirror:
|
| 192 |
+
"""Left/right reflection: flip the frames, exchange the two eyes, swap left/right joint
|
| 193 |
+
groups (with a per-joint sign) and negate the joints listed in ``sign``. Declared per
|
| 194 |
+
embodiment because the sign map depends on the kinematics."""
|
| 195 |
+
|
| 196 |
+
def __init__(self, cfg: dict, groups: list[str], views: list[str]):
|
| 197 |
+
self.prob = float(cfg.get("prob", 0.0))
|
| 198 |
+
self.view_perm = list(range(len(views)))
|
| 199 |
+
for a, b in cfg.get("view_swap", []):
|
| 200 |
+
ia, ib = views.index(a), views.index(b)
|
| 201 |
+
self.view_perm[ia], self.view_perm[ib] = ib, ia
|
| 202 |
+
self.swaps = [(groups.index(a), groups.index(b), s) for a, b, s in cfg.get("swap", [])]
|
| 203 |
+
self.signs = {groups.index(g): np.asarray(s) for g, s in (cfg.get("sign") or {}).items()}
|
| 204 |
+
|
| 205 |
+
def __call__(self, frames: list[np.ndarray], parts: list[np.ndarray]):
|
| 206 |
+
if self.prob <= 0 or float(torch.rand(())) >= self.prob:
|
| 207 |
+
return frames, parts
|
| 208 |
+
frames = [np.ascontiguousarray(frames[i][..., ::-1, :]) for i in self.view_perm]
|
| 209 |
+
parts = list(parts)
|
| 210 |
+
for ia, ib, s in self.swaps:
|
| 211 |
+
s = 1.0 if s is None else np.asarray(s)
|
| 212 |
+
parts[ia], parts[ib] = parts[ib] * s, parts[ia] * s
|
| 213 |
+
for i, s in self.signs.items():
|
| 214 |
+
parts[i] = parts[i] * s
|
| 215 |
+
return frames, parts
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
class LeRobotIDMDataset(Dataset):
|
| 219 |
+
"""Every frame t of every episode is a sample: frames (t, t+K) of all views (clamped to the
|
| 220 |
+
episode), and actions a_t .. a_{t+H-1} (the last action repeats past the end)."""
|
| 221 |
+
|
| 222 |
+
def __init__(self, spec: dict, model_cfg: dict, data_cfg: dict, train: bool = True):
|
| 223 |
+
self.spec, self.train = spec, train
|
| 224 |
+
self.meta = LeRobotMeta(spec["root"])
|
| 225 |
+
self.groups = list(spec["action_groups"])
|
| 226 |
+
self.H, self.A = int(model_cfg["action_horizon"]), int(model_cfg["action_dim"])
|
| 227 |
+
self.K = int(data_cfg["frame_gap"])
|
| 228 |
+
eps = self.meta.episodes[: data_cfg.get("max_episodes") or None]
|
| 229 |
+
self.steps = [(e["episode_index"], t, e["length"]) for e in eps for t in range(e["length"])]
|
| 230 |
+
self.statistics = self.meta.action_statistics(self.groups)
|
| 231 |
+
self.q01, self.q99 = group_stats(self.statistics, self.groups)
|
| 232 |
+
widths = [self.meta.action_slice(g)[2] - self.meta.action_slice(g)[1] for g in self.groups]
|
| 233 |
+
assert sum(widths) == native_dim(spec) <= self.A, (spec["name"], widths, self.A)
|
| 234 |
+
self.mirror = Mirror(spec.get("mirror") or {}, self.groups, list(spec["views"]))
|
| 235 |
+
self.fov = spec.get("fov_range", data_cfg.get("fov_range"))
|
| 236 |
+
self.jitter = data_cfg.get("color_jitter")
|
| 237 |
+
self._cache = (None, None)
|
| 238 |
+
|
| 239 |
+
def __len__(self):
|
| 240 |
+
return len(self.steps)
|
| 241 |
+
|
| 242 |
+
def metadata(self) -> dict:
|
| 243 |
+
return {"statistics": {"action": self.statistics}, "spec": dict(self.spec)}
|
| 244 |
+
|
| 245 |
+
def _episode(self, ep: int):
|
| 246 |
+
if self._cache[0] != ep:
|
| 247 |
+
import pandas as pd
|
| 248 |
+
self._cache = (ep, pd.read_parquet(self.meta.parquet(ep)))
|
| 249 |
+
return self._cache[1]
|
| 250 |
+
|
| 251 |
+
def __getitem__(self, i: int) -> dict:
|
| 252 |
+
ep, t, L = self.steps[i]
|
| 253 |
+
df = self._episode(ep)
|
| 254 |
+
obs = np.clip(np.array([t, t + self.K]), 0, L - 1)
|
| 255 |
+
ts = df["timestamp"].to_numpy()[obs]
|
| 256 |
+
frames = [frames_at(self.meta.video(ep, v), ts) for v in self.spec["views"]]
|
| 257 |
+
rows = np.minimum(np.arange(t, t + self.H), L - 1)
|
| 258 |
+
parts = []
|
| 259 |
+
for g in self.groups:
|
| 260 |
+
col, a, b = self.meta.action_slice(g)
|
| 261 |
+
parts.append(np.stack(df[col].to_numpy()[rows])[:, a:b].astype(np.float64))
|
| 262 |
+
if self.train:
|
| 263 |
+
frames, parts = self.mirror(frames, parts)
|
| 264 |
+
# (v, t) -> images ordered [t0 v0, t0 v1, t1 v0, t1 v1]
|
| 265 |
+
stacked = np.stack(frames, 1).reshape(-1, *frames[0].shape[1:])
|
| 266 |
+
images = preprocess_frames(stacked, self.train, self.fov, self.jitter)
|
| 267 |
+
act = normalize(torch.from_numpy(np.concatenate(parts, 1)), self.q01, self.q99,
|
| 268 |
+
self.spec.get("norm", "q99")).numpy()
|
| 269 |
+
sample = model_inputs(images, self.spec, self.H, self.A)
|
| 270 |
+
sample["actions"] = np.pad(act, ((0, 0), (0, self.A - act.shape[1]))).astype(np.float32)
|
| 271 |
+
return sample
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
class MixtureDataset(Dataset):
|
| 275 |
+
"""Weighted mixture of embodiments. Each epoch, embodiment k fills round(w_k * N) of the
|
| 276 |
+
N = sum(len) slots with a fresh random permutation of its samples (repeated if up-weighted)."""
|
| 277 |
+
|
| 278 |
+
def __init__(self, datasets: list[LeRobotIDMDataset], weights: list[float], seed: int = 42):
|
| 279 |
+
self.datasets = datasets
|
| 280 |
+
w = np.asarray(weights, np.float64)
|
| 281 |
+
self.weights = w / w.sum()
|
| 282 |
+
self.seed, self.N = seed, sum(len(d) for d in datasets)
|
| 283 |
+
self.epoch, self.table = None, None
|
| 284 |
+
self.set_epoch(0)
|
| 285 |
+
|
| 286 |
+
def set_epoch(self, epoch: int) -> None:
|
| 287 |
+
if epoch == self.epoch:
|
| 288 |
+
return
|
| 289 |
+
self.epoch = epoch
|
| 290 |
+
rng = np.random.default_rng(self.seed + epoch)
|
| 291 |
+
raw = self.weights * self.N
|
| 292 |
+
slots = np.floor(raw).astype(int)
|
| 293 |
+
for i in np.argsort(-(raw - slots))[: self.N - slots.sum()]:
|
| 294 |
+
slots[i] += 1
|
| 295 |
+
rows = []
|
| 296 |
+
for k, (ds, n_slots) in enumerate(zip(self.datasets, slots)):
|
| 297 |
+
reps, rem = divmod(int(n_slots), len(ds))
|
| 298 |
+
local = np.concatenate([rng.permutation(len(ds)) for _ in range(reps)]
|
| 299 |
+
+ [rng.permutation(len(ds))[:rem]])
|
| 300 |
+
rows.append(np.stack([np.full(n_slots, k), local], 1))
|
| 301 |
+
self.table = np.concatenate(rows).astype(np.int64)
|
| 302 |
+
|
| 303 |
+
def __len__(self):
|
| 304 |
+
return self.N
|
| 305 |
+
|
| 306 |
+
def __getitem__(self, i):
|
| 307 |
+
k, j = self.table[i]
|
| 308 |
+
return self.datasets[k][j]
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def embodiment_specs(data_cfg: dict) -> dict[str, dict]:
|
| 312 |
+
"""Embodiments declared in the config, keyed by name, with ``data.roots`` overrides applied."""
|
| 313 |
+
roots = data_cfg.get("roots") or {}
|
| 314 |
+
specs = {}
|
| 315 |
+
for e in data_cfg["embodiments"]:
|
| 316 |
+
spec = dict(e)
|
| 317 |
+
spec["root"] = roots.get(spec["name"], spec.get("root"))
|
| 318 |
+
specs[spec["name"]] = spec
|
| 319 |
+
return specs
|
robotryon/idm/dit.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
#
|
| 4 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
# you may not use this file except in compliance with the License.
|
| 6 |
+
# You may obtain a copy of the License at
|
| 7 |
+
#
|
| 8 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
#
|
| 10 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
# See the License for the specific language governing permissions and
|
| 14 |
+
# limitations under the License.
|
| 15 |
+
#
|
| 16 |
+
# Adapted from GR00T-Dreams (Apache-2.0), gr00t/model/action_head/cross_attention_dit.py.
|
| 17 |
+
# Changes: plain nn.Module instead of diffusers ModelMixin/ConfigMixin, unused options removed.
|
| 18 |
+
# Parameter names are unchanged so GR00T-Dreams IDM checkpoints load as-is.
|
| 19 |
+
"""Diffusion transformer (DiT) of the flow-matching action head, and the self-attention
|
| 20 |
+
transformer that mixes the visual tokens before the DiT cross-attends to them."""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
from diffusers.models.attention import Attention, FeedForward
|
| 29 |
+
from diffusers.models.embeddings import TimestepEmbedding, Timesteps
|
| 30 |
+
from torch import nn
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TimestepEncoder(nn.Module):
|
| 34 |
+
def __init__(self, embedding_dim: int):
|
| 35 |
+
super().__init__()
|
| 36 |
+
self.time_proj = Timesteps(num_channels=256, flip_sin_to_cos=True, downscale_freq_shift=1)
|
| 37 |
+
self.timestep_embedder = TimestepEmbedding(in_channels=256, time_embed_dim=embedding_dim)
|
| 38 |
+
|
| 39 |
+
def forward(self, timesteps):
|
| 40 |
+
dtype = next(self.parameters()).dtype
|
| 41 |
+
return self.timestep_embedder(self.time_proj(timesteps).to(dtype))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class AdaLayerNorm(nn.Module):
|
| 45 |
+
def __init__(self, embedding_dim: int, norm_eps: float = 1e-5):
|
| 46 |
+
super().__init__()
|
| 47 |
+
self.silu = nn.SiLU()
|
| 48 |
+
self.linear = nn.Linear(embedding_dim, embedding_dim * 2)
|
| 49 |
+
self.norm = nn.LayerNorm(embedding_dim, norm_eps, False)
|
| 50 |
+
|
| 51 |
+
def forward(self, x, temb):
|
| 52 |
+
scale, shift = self.linear(self.silu(temb)).chunk(2, dim=1)
|
| 53 |
+
return self.norm(x) * (1 + scale[:, None]) + shift[:, None]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class BasicTransformerBlock(nn.Module):
|
| 57 |
+
"""Attention (self, or cross when encoder states are given) + feed-forward, pre-norm."""
|
| 58 |
+
|
| 59 |
+
def __init__(self, dim: int, num_attention_heads: int, attention_head_dim: int,
|
| 60 |
+
dropout: float = 0.0, activation_fn: str = "geglu", attention_bias: bool = False,
|
| 61 |
+
upcast_attention: bool = False, norm_elementwise_affine: bool = True,
|
| 62 |
+
norm_type: str = "layer_norm", norm_eps: float = 1e-5, final_dropout: bool = False):
|
| 63 |
+
super().__init__()
|
| 64 |
+
self.norm_type = norm_type
|
| 65 |
+
if norm_type == "ada_norm":
|
| 66 |
+
self.norm1 = AdaLayerNorm(dim)
|
| 67 |
+
else:
|
| 68 |
+
self.norm1 = nn.LayerNorm(dim, elementwise_affine=norm_elementwise_affine, eps=norm_eps)
|
| 69 |
+
self.attn1 = Attention(query_dim=dim, heads=num_attention_heads, dim_head=attention_head_dim,
|
| 70 |
+
dropout=dropout, bias=attention_bias, cross_attention_dim=None,
|
| 71 |
+
upcast_attention=upcast_attention, out_bias=True)
|
| 72 |
+
self.norm3 = nn.LayerNorm(dim, norm_eps, norm_elementwise_affine)
|
| 73 |
+
self.ff = FeedForward(dim, dropout=dropout, activation_fn=activation_fn,
|
| 74 |
+
final_dropout=final_dropout, inner_dim=None, bias=True)
|
| 75 |
+
self.final_dropout = nn.Dropout(dropout) if final_dropout else None
|
| 76 |
+
|
| 77 |
+
def forward(self, hidden_states, encoder_hidden_states=None, temb=None):
|
| 78 |
+
if self.norm_type == "ada_norm":
|
| 79 |
+
h = self.norm1(hidden_states, temb)
|
| 80 |
+
else:
|
| 81 |
+
h = self.norm1(hidden_states)
|
| 82 |
+
attn = self.attn1(h, encoder_hidden_states=encoder_hidden_states, attention_mask=None)
|
| 83 |
+
if self.final_dropout:
|
| 84 |
+
attn = self.final_dropout(attn)
|
| 85 |
+
hidden_states = attn + hidden_states
|
| 86 |
+
return self.ff(self.norm3(hidden_states)) + hidden_states
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class DiT(nn.Module):
|
| 90 |
+
"""Action-token transformer conditioned on the flow time (AdaLN) and on the visual tokens.
|
| 91 |
+
With ``interleave_self_attention`` the odd blocks are self-attention only."""
|
| 92 |
+
|
| 93 |
+
def __init__(self, num_attention_heads: int = 16, attention_head_dim: int = 64,
|
| 94 |
+
output_dim: int = 1024, num_layers: int = 8, dropout: float = 0.2,
|
| 95 |
+
attention_bias: bool = True, activation_fn: str = "gelu-approximate",
|
| 96 |
+
upcast_attention: bool = False, norm_type: str = "ada_norm",
|
| 97 |
+
norm_elementwise_affine: bool = False, norm_eps: float = 1e-5,
|
| 98 |
+
final_dropout: bool = True, interleave_self_attention: bool = True):
|
| 99 |
+
super().__init__()
|
| 100 |
+
self.inner_dim = num_attention_heads * attention_head_dim
|
| 101 |
+
self.interleave_self_attention = interleave_self_attention
|
| 102 |
+
self.timestep_encoder = TimestepEncoder(self.inner_dim)
|
| 103 |
+
self.transformer_blocks = nn.ModuleList([
|
| 104 |
+
BasicTransformerBlock(self.inner_dim, num_attention_heads, attention_head_dim,
|
| 105 |
+
dropout=dropout, activation_fn=activation_fn,
|
| 106 |
+
attention_bias=attention_bias, upcast_attention=upcast_attention,
|
| 107 |
+
norm_type=norm_type, norm_elementwise_affine=norm_elementwise_affine,
|
| 108 |
+
norm_eps=norm_eps, final_dropout=final_dropout)
|
| 109 |
+
for _ in range(num_layers)])
|
| 110 |
+
self.norm_out = nn.LayerNorm(self.inner_dim, elementwise_affine=False, eps=1e-6)
|
| 111 |
+
self.proj_out_1 = nn.Linear(self.inner_dim, 2 * self.inner_dim)
|
| 112 |
+
self.proj_out_2 = nn.Linear(self.inner_dim, output_dim)
|
| 113 |
+
|
| 114 |
+
def forward(self, hidden_states, encoder_hidden_states, timestep):
|
| 115 |
+
temb = self.timestep_encoder(timestep)
|
| 116 |
+
hidden_states = hidden_states.contiguous()
|
| 117 |
+
encoder_hidden_states = encoder_hidden_states.contiguous()
|
| 118 |
+
for idx, block in enumerate(self.transformer_blocks):
|
| 119 |
+
cross = not (idx % 2 == 1 and self.interleave_self_attention)
|
| 120 |
+
hidden_states = block(hidden_states, encoder_hidden_states if cross else None, temb)
|
| 121 |
+
shift, scale = self.proj_out_1(F.silu(temb)).chunk(2, dim=1)
|
| 122 |
+
hidden_states = self.norm_out(hidden_states) * (1 + scale[:, None]) + shift[:, None]
|
| 123 |
+
return self.proj_out_2(hidden_states)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class SelfAttentionTransformer(nn.Module):
|
| 127 |
+
def __init__(self, num_attention_heads: int = 16, attention_head_dim: int = 64,
|
| 128 |
+
num_layers: int = 4, dropout: float = 0.2, attention_bias: bool = True,
|
| 129 |
+
activation_fn: str = "gelu-approximate", upcast_attention: bool = False,
|
| 130 |
+
final_dropout: bool = True):
|
| 131 |
+
super().__init__()
|
| 132 |
+
inner = num_attention_heads * attention_head_dim
|
| 133 |
+
self.transformer_blocks = nn.ModuleList([
|
| 134 |
+
BasicTransformerBlock(inner, num_attention_heads, attention_head_dim, dropout=dropout,
|
| 135 |
+
activation_fn=activation_fn, attention_bias=attention_bias,
|
| 136 |
+
upcast_attention=upcast_attention, final_dropout=final_dropout)
|
| 137 |
+
for _ in range(num_layers)])
|
| 138 |
+
|
| 139 |
+
def forward(self, hidden_states: torch.Tensor, temb: Optional[torch.Tensor] = None):
|
| 140 |
+
hidden_states = hidden_states.contiguous()
|
| 141 |
+
for block in self.transformer_blocks:
|
| 142 |
+
hidden_states = block(hidden_states)
|
| 143 |
+
return hidden_states
|
robotryon/idm/labeling.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Action labels for a generated robot video (paper Sec. 3.4, App. B.2.6).
|
| 2 |
+
|
| 3 |
+
1. Sliding window, stride 1: at every frame t the IDM reads frames (t, t+K) (clamped to the
|
| 4 |
+
clip) and predicts the chunk a_t .. a_{t+H-1}.
|
| 5 |
+
2. Chunk averaging: frame u appears in the H chunks that start at u-H+1 .. u; its label is the
|
| 6 |
+
mean of those predictions (fewer at the clip ends).
|
| 7 |
+
3. Savitzky-Golay smoothing (polynomial order 2) per joint group: a long window on the arm-like
|
| 8 |
+
groups, a short one on the hand groups so that grasp onsets stay sharp.
|
| 9 |
+
4. Optional wrist correction (``wrist_correction.py``).
|
| 10 |
+
5. Proprioceptive state from the actions: q_t = a_{t-d} (the robot reaches a commanded pose
|
| 11 |
+
d frames later); the first d states repeat a_0.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
from scipy.signal import savgol_filter
|
| 19 |
+
|
| 20 |
+
from .data import collate, model_inputs, preprocess_frames, unnormalize
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ChunkAccumulator:
|
| 24 |
+
"""Average every chunk prediction that covers a frame."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, length: int, action_dim: int, horizon: int):
|
| 27 |
+
self.L, self.H = int(length), int(horizon)
|
| 28 |
+
self._sum = np.zeros((self.L, action_dim), dtype=np.float64)
|
| 29 |
+
self._cnt = np.zeros((self.L, 1), dtype=np.float64)
|
| 30 |
+
|
| 31 |
+
def add(self, start: int, chunk: np.ndarray) -> None:
|
| 32 |
+
n = min(self.H, len(chunk), self.L - start)
|
| 33 |
+
if n > 0:
|
| 34 |
+
self._sum[start:start + n] += chunk[:n]
|
| 35 |
+
self._cnt[start:start + n] += 1.0
|
| 36 |
+
|
| 37 |
+
def result(self) -> np.ndarray:
|
| 38 |
+
if (self._cnt == 0).any():
|
| 39 |
+
raise RuntimeError("some frames are covered by no chunk")
|
| 40 |
+
return (self._sum / self._cnt).astype(np.float32)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def smooth_actions(actions: np.ndarray, groups: dict[str, slice], hand_groups, window: int,
|
| 44 |
+
hand_window: int, order: int = 2) -> np.ndarray:
|
| 45 |
+
"""SG filter per group along time; hand groups use ``hand_window``. A window longer than the
|
| 46 |
+
clip (or not above the order) leaves the group unchanged."""
|
| 47 |
+
out = np.array(actions, dtype=np.float32, copy=True)
|
| 48 |
+
for name, sl in groups.items():
|
| 49 |
+
w = int(hand_window if name in hand_groups else window) | 1
|
| 50 |
+
if order < w <= len(out):
|
| 51 |
+
out[:, sl] = savgol_filter(out[:, sl], w, order, axis=0, mode="nearest")
|
| 52 |
+
return out
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def state_from_action(actions: np.ndarray, delay: int) -> np.ndarray:
|
| 56 |
+
"""q_t = a_{t-delay}; q_t = a_0 for t < delay."""
|
| 57 |
+
a = np.asarray(actions)
|
| 58 |
+
d = min(int(delay), len(a))
|
| 59 |
+
return np.concatenate([np.repeat(a[:1], d, axis=0), a[: len(a) - d]], axis=0)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def group_slices(spec: dict, widths: dict[str, int]) -> dict[str, slice]:
|
| 63 |
+
out, s = {}, 0
|
| 64 |
+
for g in spec["action_groups"]:
|
| 65 |
+
out[g] = slice(s, s + widths[g])
|
| 66 |
+
s += widths[g]
|
| 67 |
+
return out
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@torch.no_grad()
|
| 71 |
+
def predict_actions(model, views: list[np.ndarray], spec: dict, stats: dict, frame_gap: int,
|
| 72 |
+
batch_size: int, generator: torch.Generator | None = None) -> np.ndarray:
|
| 73 |
+
"""Chunk-averaged raw actions (T, native_dim) for one clip.
|
| 74 |
+
|
| 75 |
+
``views``: one (T, H, W, 3) uint8 RGB array per view in ``spec["views"]`` order, already at
|
| 76 |
+
the embodiment's ``video_size``. ``stats``: per-group action statistics of this embodiment.
|
| 77 |
+
"""
|
| 78 |
+
H, A = model.cfg["action_horizon"], model.cfg["action_dim"]
|
| 79 |
+
groups = list(spec["action_groups"])
|
| 80 |
+
q01 = torch.tensor(np.concatenate([stats[g]["q01"] for g in groups]).tolist())
|
| 81 |
+
q99 = torch.tensor(np.concatenate([stats[g]["q99"] for g in groups]).tolist())
|
| 82 |
+
n_native = len(q01)
|
| 83 |
+
T = min(len(v) for v in views)
|
| 84 |
+
device = next(model.parameters()).device
|
| 85 |
+
acc = ChunkAccumulator(T, n_native, H)
|
| 86 |
+
|
| 87 |
+
def sample(t):
|
| 88 |
+
obs = np.clip(np.array([t, t + frame_gap]), 0, T - 1)
|
| 89 |
+
frames = np.stack([v[obs] for v in views], 1).reshape(-1, *views[0].shape[1:])
|
| 90 |
+
return model_inputs(preprocess_frames(frames), spec, H, A)
|
| 91 |
+
|
| 92 |
+
for s in range(0, T, batch_size):
|
| 93 |
+
batch = collate([sample(t) for t in range(s, min(s + batch_size, T))])
|
| 94 |
+
batch = {k: v.to(device) for k, v in batch.items()}
|
| 95 |
+
pred = model.get_action(batch, generator=generator).cpu()[..., :n_native]
|
| 96 |
+
raw = unnormalize(pred, q01, q99, spec.get("norm", "q99")).numpy()
|
| 97 |
+
for b, chunk in enumerate(raw):
|
| 98 |
+
acc.add(s + b, chunk)
|
| 99 |
+
return acc.result()
|
robotryon/idm/model.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
#
|
| 4 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
# you may not use this file except in compliance with the License.
|
| 6 |
+
# You may obtain a copy of the License at
|
| 7 |
+
#
|
| 8 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
#
|
| 10 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
# See the License for the specific language governing permissions and
|
| 14 |
+
# limitations under the License.
|
| 15 |
+
#
|
| 16 |
+
# Adapted from GR00T-Dreams (Apache-2.0): gr00t/model/idm.py,
|
| 17 |
+
# gr00t/model/action_head/flow_matching_action_head_idm.py, multimodal_projector.py.
|
| 18 |
+
# Changes: Depth Anything V2 vision tower instead of SigLIP, no hydra, plain safetensors I/O,
|
| 19 |
+
# image tokens placed from a per-sample image count instead of token-id arrays.
|
| 20 |
+
"""Inverse dynamics model h_psi (paper Sec. 3.4, App. B.2.6).
|
| 21 |
+
|
| 22 |
+
Given two frames o_t and o_{t+K} (K = 20, 1 s at 20 fps; one or two camera views each), the IDM
|
| 23 |
+
predicts the action chunk a_{t:t+K} by flow matching:
|
| 24 |
+
|
| 25 |
+
images --DINOv2 trunk of Depth Anything V2-L--> 16x16 patch tokens (CLS dropped)
|
| 26 |
+
--linear + 2x(2x2 pool MLP)--> 16 tokens per image, + view embedding
|
| 27 |
+
tokens --self-attention transformer--> visual context (left zero-padded to 112 tokens)
|
| 28 |
+
noisy chunk a^tau --per-embodiment action encoder--> DiT (AdaLN on tau, cross-attention to
|
| 29 |
+
the context) --per-embodiment decoder--> velocity v; loss ||v - (a - eps)||^2
|
| 30 |
+
|
| 31 |
+
One model is shared by all embodiments. Actions are zero-padded to ``action_dim`` (the widest
|
| 32 |
+
robot) and the padded dimensions are masked out of the input and the loss; the action encoder
|
| 33 |
+
and decoder have separate weights per embodiment id. At inference the chunk is integrated
|
| 34 |
+
from Gaussian noise with ``num_inference_timesteps`` Euler steps.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import json
|
| 40 |
+
import re
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
|
| 43 |
+
import torch
|
| 44 |
+
import torch.nn.functional as F
|
| 45 |
+
from torch import nn
|
| 46 |
+
from torch.distributions import Beta
|
| 47 |
+
|
| 48 |
+
from .dit import DiT, SelfAttentionTransformer
|
| 49 |
+
|
| 50 |
+
DEFAULT_MODEL_CFG = dict(
|
| 51 |
+
vision_tower="depth-anything/Depth-Anything-V2-Large-hf",
|
| 52 |
+
vision_hidden_size=1024, hidden_size=1024,
|
| 53 |
+
action_dim=66, action_horizon=20,
|
| 54 |
+
max_num_embodiments=32, max_num_views=6, max_seq_len=1024,
|
| 55 |
+
max_vl_tokens=112, tokens_per_image=16,
|
| 56 |
+
frozen_vision_layers=[11],
|
| 57 |
+
num_inference_timesteps=16, num_timestep_buckets=1000,
|
| 58 |
+
noise_beta_alpha=1.5, noise_beta_beta=1.0, noise_s=0.999,
|
| 59 |
+
dit=dict(num_layers=8, num_attention_heads=16, attention_head_dim=64, dropout=0.2,
|
| 60 |
+
output_dim=1024, interleave_self_attention=True),
|
| 61 |
+
vl_self_attention=dict(num_layers=4, num_attention_heads=16, attention_head_dim=64,
|
| 62 |
+
dropout=0.2),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ----------------------------------------------------------------------------- vision tower
|
| 67 |
+
class DepthAnythingTower(nn.Module):
|
| 68 |
+
"""The DINOv2 trunk of Depth Anything V2 (the DPT neck and depth head are discarded).
|
| 69 |
+
Input: ImageNet-normalised 224x224 images. Output: (N, 256, D) post-layernorm patch tokens
|
| 70 |
+
of the last layer, CLS token removed."""
|
| 71 |
+
|
| 72 |
+
def __init__(self, name: str, pretrained: bool = True):
|
| 73 |
+
super().__init__()
|
| 74 |
+
from transformers import DepthAnythingConfig, DepthAnythingForDepthEstimation
|
| 75 |
+
|
| 76 |
+
if pretrained:
|
| 77 |
+
model = DepthAnythingForDepthEstimation.from_pretrained(name)
|
| 78 |
+
else:
|
| 79 |
+
model = DepthAnythingForDepthEstimation._from_config(DepthAnythingConfig.from_pretrained(name))
|
| 80 |
+
self.backbone = model.backbone
|
| 81 |
+
# Only read with bool_masked_pos, which the backbone never passes: no gradient (DDP).
|
| 82 |
+
self.backbone.embeddings.mask_token.requires_grad_(False)
|
| 83 |
+
|
| 84 |
+
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
| 85 |
+
return self.backbone(pixel_values).feature_maps[-1][:, 1:]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class _DownSample(nn.Module):
|
| 89 |
+
"""Concatenate 2x2 neighbouring tokens of a square grid: (N, h*w, C) -> (N, h*w/4, 4C)."""
|
| 90 |
+
|
| 91 |
+
def forward(self, x):
|
| 92 |
+
n, L, c = x.shape
|
| 93 |
+
h = w = int(L ** 0.5)
|
| 94 |
+
x = x.reshape(n, h, w, c)
|
| 95 |
+
if w % 2 or h % 2:
|
| 96 |
+
x = F.pad(x, (0, 0, 0, h % 2, 0, w % 2))
|
| 97 |
+
n, w, h, c = x.shape
|
| 98 |
+
x = x.view(n, w, h // 2, c * 2).permute(0, 2, 1, 3).contiguous()
|
| 99 |
+
return x.view(n, h // 2, w // 2, c * 4).reshape(n, -1, c * 4)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class MultimodalProjector(nn.Module):
|
| 103 |
+
"""``mlp_doubledownsample``: 256 patch tokens -> 64 -> 16 tokens."""
|
| 104 |
+
|
| 105 |
+
def __init__(self, mm_hidden: int, hidden: int):
|
| 106 |
+
super().__init__()
|
| 107 |
+
self.layers = nn.Sequential(
|
| 108 |
+
_DownSample(), nn.LayerNorm(mm_hidden * 4), nn.Linear(mm_hidden * 4, mm_hidden),
|
| 109 |
+
nn.GELU(), nn.Linear(mm_hidden, mm_hidden),
|
| 110 |
+
_DownSample(), nn.LayerNorm(mm_hidden * 4), nn.Linear(mm_hidden * 4, hidden),
|
| 111 |
+
nn.GELU(), nn.Linear(hidden, hidden))
|
| 112 |
+
|
| 113 |
+
def forward(self, x):
|
| 114 |
+
return self.layers(x)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ----------------------------------------------------------------------------- action head
|
| 118 |
+
class CategorySpecificLinear(nn.Module):
|
| 119 |
+
def __init__(self, num_categories, input_dim, hidden_dim):
|
| 120 |
+
super().__init__()
|
| 121 |
+
self.W = nn.Parameter(0.02 * torch.randn(num_categories, input_dim, hidden_dim))
|
| 122 |
+
self.b = nn.Parameter(torch.zeros(num_categories, hidden_dim))
|
| 123 |
+
|
| 124 |
+
def forward(self, x, cat_ids):
|
| 125 |
+
return torch.bmm(x, self.W[cat_ids]) + self.b[cat_ids].unsqueeze(1)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class CategorySpecificMLP(nn.Module):
|
| 129 |
+
def __init__(self, num_categories, input_dim, hidden_dim, output_dim):
|
| 130 |
+
super().__init__()
|
| 131 |
+
self.layer1 = CategorySpecificLinear(num_categories, input_dim, hidden_dim)
|
| 132 |
+
self.layer2 = CategorySpecificLinear(num_categories, hidden_dim, output_dim)
|
| 133 |
+
|
| 134 |
+
def forward(self, x, cat_ids):
|
| 135 |
+
return self.layer2(F.relu(self.layer1(x, cat_ids)), cat_ids)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def sinusoidal(timesteps: torch.Tensor, dim: int) -> torch.Tensor:
|
| 139 |
+
"""(B, T) -> (B, T, dim) sin/cos encoding."""
|
| 140 |
+
timesteps = timesteps.float()
|
| 141 |
+
half = dim // 2
|
| 142 |
+
exponent = -torch.arange(half, dtype=torch.float, device=timesteps.device) * (
|
| 143 |
+
torch.log(torch.tensor(10000.0)) / half)
|
| 144 |
+
freqs = timesteps.unsqueeze(-1) * exponent.exp()
|
| 145 |
+
return torch.cat([torch.sin(freqs), torch.cos(freqs)], dim=-1)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class MultiEmbodimentActionEncoder(nn.Module):
|
| 149 |
+
def __init__(self, action_dim, hidden_size, num_embodiments):
|
| 150 |
+
super().__init__()
|
| 151 |
+
self.hidden_size = hidden_size
|
| 152 |
+
self.W1 = CategorySpecificLinear(num_embodiments, action_dim, hidden_size)
|
| 153 |
+
self.W2 = CategorySpecificLinear(num_embodiments, 2 * hidden_size, hidden_size)
|
| 154 |
+
self.W3 = CategorySpecificLinear(num_embodiments, hidden_size, hidden_size)
|
| 155 |
+
|
| 156 |
+
def forward(self, actions, timesteps, cat_ids):
|
| 157 |
+
B, T, _ = actions.shape
|
| 158 |
+
a_emb = self.W1(actions, cat_ids)
|
| 159 |
+
tau = sinusoidal(timesteps.unsqueeze(1).expand(-1, T), self.hidden_size).to(a_emb.dtype)
|
| 160 |
+
x = self.W2(torch.cat([a_emb, tau], dim=-1), cat_ids)
|
| 161 |
+
return self.W3(x * torch.sigmoid(x), cat_ids)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class IDM(nn.Module):
|
| 165 |
+
"""Flow-matching inverse dynamics model. See the module docstring.
|
| 166 |
+
|
| 167 |
+
Batch (see ``data.collate``): ``images`` (N, 3, 224, 224) for all samples concatenated in
|
| 168 |
+
order [t0 view0, t0 view1, t1 view0, ...]; ``view_ids`` (N,); ``n_images`` (B,);
|
| 169 |
+
``embodiment_id`` (B,); ``actions_mask`` (B, H, A) bool, True on the robot's native
|
| 170 |
+
dimensions; ``actions`` (B, H, A) normalised targets (training only).
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
def __init__(self, cfg: dict | None = None, pretrained_vision: bool = True):
|
| 174 |
+
super().__init__()
|
| 175 |
+
c = {**DEFAULT_MODEL_CFG, **(cfg or {})}
|
| 176 |
+
self.cfg = c
|
| 177 |
+
D = c["hidden_size"]
|
| 178 |
+
self.vision = DepthAnythingTower(c["vision_tower"], pretrained=pretrained_vision)
|
| 179 |
+
self.vision_projector = nn.Linear(c["vision_hidden_size"], D)
|
| 180 |
+
self.mm_projector = MultimodalProjector(c["vision_hidden_size"], D)
|
| 181 |
+
self.view_embedding = nn.Embedding(c["max_num_views"], D)
|
| 182 |
+
self.position_embedding = nn.Embedding(c["max_seq_len"], D)
|
| 183 |
+
nn.init.normal_(self.view_embedding.weight, std=0.02)
|
| 184 |
+
nn.init.normal_(self.position_embedding.weight, std=0.02)
|
| 185 |
+
self.vl_self_attention = SelfAttentionTransformer(**c["vl_self_attention"])
|
| 186 |
+
self.dit = DiT(**c["dit"])
|
| 187 |
+
self.action_encoder = MultiEmbodimentActionEncoder(c["action_dim"], D, c["max_num_embodiments"])
|
| 188 |
+
self.action_decoder = CategorySpecificMLP(c["max_num_embodiments"], D, D, c["action_dim"])
|
| 189 |
+
self.beta_dist = Beta(c["noise_beta_alpha"], c["noise_beta_beta"])
|
| 190 |
+
# One ViT layer is kept frozen, as in the trained checkpoint.
|
| 191 |
+
for i in c["frozen_vision_layers"]:
|
| 192 |
+
self.vision.backbone.encoder.layer[i].requires_grad_(False)
|
| 193 |
+
|
| 194 |
+
# -- encoding -------------------------------------------------------------------------
|
| 195 |
+
def encode(self, batch) -> torch.Tensor:
|
| 196 |
+
"""Visual context (B, max_vl_tokens, D): each sample's image tokens right-aligned,
|
| 197 |
+
zeros in front (the zeros are attended to, exactly as in training)."""
|
| 198 |
+
x = self.mm_projector(self.vision_projector(self.vision(batch["images"])))
|
| 199 |
+
x = x + self.view_embedding(batch["view_ids"]).unsqueeze(1)
|
| 200 |
+
n_tok = batch["n_images"] * self.cfg["tokens_per_image"]
|
| 201 |
+
L = self.cfg["max_vl_tokens"]
|
| 202 |
+
assert int(n_tok.max()) <= L, f"{int(n_tok.max())} image tokens > max_vl_tokens={L}"
|
| 203 |
+
pos = torch.arange(L, device=x.device)
|
| 204 |
+
mask = (pos[None] >= (L - n_tok)[:, None]).unsqueeze(-1).expand(-1, -1, x.shape[-1])
|
| 205 |
+
vl = torch.zeros(len(n_tok), L, x.shape[-1], dtype=x.dtype, device=x.device)
|
| 206 |
+
return self.vl_self_attention(vl.masked_scatter(mask, x))
|
| 207 |
+
|
| 208 |
+
def _velocity(self, context, actions, t_disc, t_dit, emb_id):
|
| 209 |
+
sa = self.action_encoder(actions, t_disc, emb_id)
|
| 210 |
+
T = sa.shape[1]
|
| 211 |
+
sa = sa + self.position_embedding(torch.arange(T, device=sa.device)).unsqueeze(0)
|
| 212 |
+
out = self.dit(sa, context, t_dit)
|
| 213 |
+
return self.action_decoder(out, emb_id)[:, -T:]
|
| 214 |
+
|
| 215 |
+
# -- training -------------------------------------------------------------------------
|
| 216 |
+
def forward(self, batch) -> torch.Tensor:
|
| 217 |
+
"""Flow-matching loss over the native (unpadded) action dimensions."""
|
| 218 |
+
context = self.encode(batch)
|
| 219 |
+
actions, mask = batch["actions"], batch["actions_mask"].to(batch["actions"].dtype)
|
| 220 |
+
noise = torch.randn_like(actions)
|
| 221 |
+
s = self.cfg["noise_s"]
|
| 222 |
+
t = (s - self.beta_dist.sample([actions.shape[0]]).to(actions.device, actions.dtype)) / s
|
| 223 |
+
t = t[:, None, None]
|
| 224 |
+
noisy = ((1 - t) * noise + t * actions) * mask
|
| 225 |
+
t_disc = (t[:, 0, 0] * self.cfg["num_timestep_buckets"]).long()
|
| 226 |
+
pred = self._velocity(context, noisy, t_disc, t_disc, batch["embodiment_id"])
|
| 227 |
+
loss = F.mse_loss(pred, actions - noise, reduction="none") * mask
|
| 228 |
+
return loss.sum() / mask.sum()
|
| 229 |
+
|
| 230 |
+
# -- inference ------------------------------------------------------------------------
|
| 231 |
+
@torch.inference_mode()
|
| 232 |
+
def get_action(self, batch, generator: torch.Generator | None = None) -> torch.Tensor:
|
| 233 |
+
"""Integrate the flow from noise; returns normalised, padded actions (B, H, A)."""
|
| 234 |
+
emb_id = batch["embodiment_id"]
|
| 235 |
+
images = batch["images"]
|
| 236 |
+
B, H, A = len(emb_id), self.cfg["action_horizon"], self.cfg["action_dim"]
|
| 237 |
+
x = torch.randn((B, H, A), dtype=images.dtype, device=images.device, generator=generator)
|
| 238 |
+
mask = batch["actions_mask"].to(x.dtype)
|
| 239 |
+
x = x * mask
|
| 240 |
+
context = self.encode(batch)
|
| 241 |
+
n = self.cfg["num_inference_timesteps"]
|
| 242 |
+
for i in range(n):
|
| 243 |
+
t = int(i / float(n) * self.cfg["num_timestep_buckets"])
|
| 244 |
+
v = self._velocity(context, x, (torch.ones(B) * t).to(x.device),
|
| 245 |
+
torch.tensor([t], device=x.device, dtype=torch.long), emb_id)
|
| 246 |
+
x = (x + (1.0 / n) * v) * mask
|
| 247 |
+
return x
|
| 248 |
+
|
| 249 |
+
# -- I/O ------------------------------------------------------------------------------
|
| 250 |
+
def save_pretrained(self, out_dir: str | Path, metadata: dict | None = None) -> None:
|
| 251 |
+
from safetensors.torch import save_file
|
| 252 |
+
|
| 253 |
+
out = Path(out_dir)
|
| 254 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 255 |
+
sd = {k: v.detach().contiguous().cpu() for k, v in self.state_dict().items()}
|
| 256 |
+
save_file(sd, str(out / "model.safetensors"), metadata={"format": "pt"})
|
| 257 |
+
(out / "idm_config.json").write_text(json.dumps(self.cfg, indent=1))
|
| 258 |
+
if metadata is not None:
|
| 259 |
+
(out / "metadata.json").write_text(json.dumps(metadata, indent=1))
|
| 260 |
+
|
| 261 |
+
@classmethod
|
| 262 |
+
def from_pretrained(cls, ckpt_dir: str | Path, device="cpu") -> "IDM":
|
| 263 |
+
"""Load a checkpoint written by ``save_pretrained`` or by the GR00T-Dreams IDM trainer."""
|
| 264 |
+
from safetensors.torch import load_file
|
| 265 |
+
|
| 266 |
+
d = Path(ckpt_dir)
|
| 267 |
+
if (d / "idm_config.json").exists():
|
| 268 |
+
cfg = json.loads((d / "idm_config.json").read_text())
|
| 269 |
+
else:
|
| 270 |
+
cfg = _legacy_config(json.loads((d / "config.json").read_text()))
|
| 271 |
+
model = cls(cfg, pretrained_vision=False)
|
| 272 |
+
sd = load_file(str(d / "model.safetensors"))
|
| 273 |
+
if any(k.startswith("action_head.") for k in sd):
|
| 274 |
+
sd = _legacy_state_dict(sd)
|
| 275 |
+
model.load_state_dict(sd, strict=True)
|
| 276 |
+
return model.to(device).eval()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def load_metadata(ckpt_dir: str | Path) -> dict:
|
| 280 |
+
"""Per-embodiment normalisation statistics saved with the checkpoint, keyed by name."""
|
| 281 |
+
d = Path(ckpt_dir)
|
| 282 |
+
for p in (d / "metadata.json", d / "experiment_cfg" / "metadata.json"):
|
| 283 |
+
if p.exists():
|
| 284 |
+
return json.loads(p.read_text())
|
| 285 |
+
raise FileNotFoundError(f"no metadata.json in {d}")
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _legacy_config(c: dict) -> dict:
|
| 289 |
+
h = c["action_head_cfg"]["config"]
|
| 290 |
+
keep = ("num_layers", "num_attention_heads", "attention_head_dim", "dropout")
|
| 291 |
+
return dict(
|
| 292 |
+
vision_tower=h["siglip_model_cfg"]["pretrained_model_name_or_path"],
|
| 293 |
+
vision_hidden_size=h["siglip_hidden_size"], hidden_size=h["hidden_size"],
|
| 294 |
+
action_dim=h["action_dim"], action_horizon=h["action_horizon"],
|
| 295 |
+
max_num_embodiments=h.get("max_num_embodiments", 32), max_num_views=h["max_num_views"],
|
| 296 |
+
max_seq_len=h.get("max_seq_len", 1024),
|
| 297 |
+
num_inference_timesteps=h["num_inference_timesteps"],
|
| 298 |
+
num_timestep_buckets=h["num_timestep_buckets"],
|
| 299 |
+
noise_beta_alpha=h["noise_beta_alpha"], noise_beta_beta=h["noise_beta_beta"],
|
| 300 |
+
noise_s=h["noise_s"],
|
| 301 |
+
dit={**{k: h["diffusion_model_cfg"][k] for k in keep},
|
| 302 |
+
"output_dim": h["diffusion_model_cfg"]["output_dim"],
|
| 303 |
+
"interleave_self_attention": h["diffusion_model_cfg"]["interleave_self_attention"]},
|
| 304 |
+
vl_self_attention={k: h["vl_self_attention_cfg"][k] for k in keep},
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
_LEGACY_RENAMES = (
|
| 309 |
+
(r"^action_head\.siglip_model\.vision_model\.backbone\.", "vision.backbone."),
|
| 310 |
+
(r"^action_head\.model\.", "dit."),
|
| 311 |
+
(r"^action_head\.vl_self_attention_model\.", "vl_self_attention."),
|
| 312 |
+
(r"^action_head\.", ""),
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _legacy_state_dict(sd: dict) -> dict:
|
| 317 |
+
out = {}
|
| 318 |
+
for k, v in sd.items():
|
| 319 |
+
if k.endswith(("siglip_model.logit_scale", "siglip_model.logit_bias")):
|
| 320 |
+
continue
|
| 321 |
+
for pat, rep in _LEGACY_RENAMES:
|
| 322 |
+
k2 = re.sub(pat, rep, k)
|
| 323 |
+
if k2 != k:
|
| 324 |
+
k = k2
|
| 325 |
+
break
|
| 326 |
+
out[k] = v
|
| 327 |
+
return out
|
robotryon/idm/wrist_correction.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wrist correction of IDM action labels (paper Sec. 3.4, App. B.2.6).
|
| 2 |
+
|
| 3 |
+
The IDM reads joint angles from pixels and places the wrist less accurately than the 3D hand
|
| 4 |
+
reconstruction of the human source video. For each hand we therefore move the wrist, and only
|
| 5 |
+
the wrist, toward the reconstruction:
|
| 6 |
+
|
| 7 |
+
1. The reconstructed human wrist (camera frame) is mapped to the robot base frame with the
|
| 8 |
+
head-camera extrinsics T_base_cam.
|
| 9 |
+
2. The robot hand implied by the IDM joints (FK) is scaled by ``hand_scale``
|
| 10 |
+
about its wrist; the target wrist is the one at which the scaled hand's end-effector keypoint
|
| 11 |
+
coincides with the reconstructed wrist.
|
| 12 |
+
3. Targets are kept only on high-confidence frames. The offset d = target - IDM wrist is
|
| 13 |
+
cleaned: excursion rejection (hysteresis around a running median of accepted offsets),
|
| 14 |
+
linear fill of short gaps, zero-phase 2nd-order Butterworth low-pass per usable segment.
|
| 15 |
+
4. Damped least squares IK on the arm joints of that side: wrist position to the IDM wrist + d,
|
| 16 |
+
wrist orientation held at the FK orientation of the IDM arm joints (weighted rotation
|
| 17 |
+
residual), damping toward the IDM arm joints, numerical Jacobian of FK, warm-started from
|
| 18 |
+
the previous frame of the segment.
|
| 19 |
+
5. The solution is blended into the IDM joints with weights ramping 0 -> 1 over ``ramp`` frames
|
| 20 |
+
at segment edges (not at the clip ends), then the arm tracks get SG smoothing. Hand joints
|
| 21 |
+
and all other joints are left untouched.
|
| 22 |
+
|
| 23 |
+
Kinematics interface required (minimal protocol; ``robotryon.embodiment.Embodiment`` is
|
| 24 |
+
expected to satisfy it)::
|
| 25 |
+
|
| 26 |
+
class Kinematics(Protocol):
|
| 27 |
+
arm_joint_slices: dict[str, slice] # {"left": slice, "right": slice} into the action
|
| 28 |
+
def fk(self, q: np.ndarray) -> dict[str, dict[str, np.ndarray]]:
|
| 29 |
+
# {"left": {"wrist_pos": (3,), "wrist_rot": (3, 3), "keypoints": (17, 3)}, "right": ...}
|
| 30 |
+
# robot base frame, metres; keypoints use robotryon.skeleton order (0 = wrist)
|
| 31 |
+
|
| 32 |
+
Hands are indexed 0 = left, 1 = right, as in ``robotryon.skeleton``.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
from typing import Callable
|
| 38 |
+
|
| 39 |
+
import numpy as np
|
| 40 |
+
from scipy.signal import butter, filtfilt, savgol_filter
|
| 41 |
+
|
| 42 |
+
SIDES = ("left", "right")
|
| 43 |
+
|
| 44 |
+
DEFAULTS = dict(
|
| 45 |
+
hand_scale=1.0, ee_keypoint=0, min_confidence=0.5,
|
| 46 |
+
reject_enter_mm=150.0, reject_exit_mm=80.0, reject_hard_mm=500.0, reject_ref_n=15,
|
| 47 |
+
gap_fill=6, cutoff_hz=2.0, fps=20.0, ramp=8, smooth_window=9, smooth_order=2,
|
| 48 |
+
ik_iters=30, w_rot=2000.0, damping=0.5, step_cap=0.08, fd_eps=1e-3,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ----------------------------------------------------------------------------- track cleaning
|
| 53 |
+
def segments(mask: np.ndarray) -> list[tuple[int, int]]:
|
| 54 |
+
"""Half-open [a, b) runs of True."""
|
| 55 |
+
out, a = [], None
|
| 56 |
+
for i, m in enumerate(mask):
|
| 57 |
+
if m and a is None:
|
| 58 |
+
a = i
|
| 59 |
+
if not m and a is not None:
|
| 60 |
+
out.append((a, i))
|
| 61 |
+
a = None
|
| 62 |
+
if a is not None:
|
| 63 |
+
out.append((a, len(mask)))
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def reject_excursions(d, ok, enter, exit_, hard, ref_n=15):
|
| 68 |
+
"""Refuse frames whose offset leaves the level it has been holding (running median of the
|
| 69 |
+
last ``ref_n`` accepted offsets); a refusal lasts until the offset is back within ``exit_``.
|
| 70 |
+
``hard`` caps |d| before a level is established."""
|
| 71 |
+
good, buf, ref = ok.copy(), [], None
|
| 72 |
+
for t in range(len(d)):
|
| 73 |
+
if not ok[t]:
|
| 74 |
+
continue
|
| 75 |
+
if np.linalg.norm(d[t]) > hard:
|
| 76 |
+
good[t] = False
|
| 77 |
+
continue
|
| 78 |
+
if ref is None:
|
| 79 |
+
buf.append(d[t])
|
| 80 |
+
if len(buf) >= 5:
|
| 81 |
+
ref = np.median(np.array(buf[-ref_n:]), axis=0)
|
| 82 |
+
continue
|
| 83 |
+
dev = float(np.linalg.norm(d[t] - ref))
|
| 84 |
+
if dev > enter or (dev > exit_ and not good[max(t - 1, 0)]):
|
| 85 |
+
good[t] = False
|
| 86 |
+
else:
|
| 87 |
+
buf.append(d[t])
|
| 88 |
+
ref = np.median(np.array(buf[-ref_n:]), axis=0)
|
| 89 |
+
return good
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def fill_short_gaps(track, ok, max_gap):
|
| 93 |
+
"""Linearly interpolate refused runs of <= max_gap frames between usable frames (in place);
|
| 94 |
+
returns the new usable mask."""
|
| 95 |
+
ok = ok.copy()
|
| 96 |
+
for a, b in segments(~ok):
|
| 97 |
+
if a == 0 or b == len(ok) or b - a > max_gap:
|
| 98 |
+
continue
|
| 99 |
+
for k in range(track.shape[1]):
|
| 100 |
+
track[a:b, k] = np.interp(np.arange(a, b), [a - 1, b], [track[a - 1, k], track[b, k]])
|
| 101 |
+
ok[a:b] = True
|
| 102 |
+
return ok
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def lowpass(track, ok, cutoff_hz, fps):
|
| 106 |
+
"""Zero-phase Butterworth per usable segment (segments too short for filtfilt are kept)."""
|
| 107 |
+
b, a = butter(2, cutoff_hz / (fps / 2.0), "low")
|
| 108 |
+
out = track.copy()
|
| 109 |
+
for lo, hi in segments(ok):
|
| 110 |
+
if hi - lo > 3 * max(len(a), len(b)) + 2:
|
| 111 |
+
out[lo:hi] = np.stack([filtfilt(b, a, track[lo:hi, c]) for c in range(track.shape[1])], 1)
|
| 112 |
+
return out
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def ramp_weights(ok, ramp):
|
| 116 |
+
"""1 inside usable segments, ramping linearly at segment edges that are not clip ends."""
|
| 117 |
+
T, w = len(ok), np.zeros(len(ok))
|
| 118 |
+
for a, b in segments(ok):
|
| 119 |
+
for t in range(a, b):
|
| 120 |
+
up = 1.0 if a == 0 else (t - a + 1) / ramp
|
| 121 |
+
dn = 1.0 if b == T else (b - t) / ramp
|
| 122 |
+
w[t] = min(1.0, up, dn)
|
| 123 |
+
return w
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ----------------------------------------------------------------------------- IK
|
| 127 |
+
def rotvec(R_cur, R_ref):
|
| 128 |
+
"""Axis-angle of R_cur R_ref^T."""
|
| 129 |
+
from scipy.spatial.transform import Rotation
|
| 130 |
+
|
| 131 |
+
return Rotation.from_matrix(R_cur @ R_ref.T).as_rotvec()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def ik_wrist(fk: Callable, q, side, sl, target_mm, R_ref, q_ref, iters=30, w_rot=2000.0,
|
| 135 |
+
damping=0.5, step_cap=0.08, fd_eps=1e-3):
|
| 136 |
+
"""Damped least squares on the joints ``sl``: wrist position (mm) to ``target_mm``,
|
| 137 |
+
orientation pinned to ``R_ref`` (``w_rot`` mm per rad), damping toward ``q_ref``.
|
| 138 |
+
Returns (q, position residual in mm before the last step)."""
|
| 139 |
+
s = np.array(q, dtype=np.float64)
|
| 140 |
+
n = sl.stop - sl.start
|
| 141 |
+
|
| 142 |
+
def res(x):
|
| 143 |
+
h = fk(x)[side]
|
| 144 |
+
return np.concatenate([np.asarray(h["wrist_pos"]) * 1000.0 - target_mm,
|
| 145 |
+
w_rot * rotvec(np.asarray(h["wrist_rot"]), R_ref)])
|
| 146 |
+
|
| 147 |
+
lam, last = damping * 1e4, np.inf
|
| 148 |
+
for _ in range(iters):
|
| 149 |
+
r = res(s)
|
| 150 |
+
if not np.all(np.isfinite(r)):
|
| 151 |
+
return np.array(q, dtype=np.float64), np.inf
|
| 152 |
+
last = float(np.linalg.norm(r[:3]))
|
| 153 |
+
J = np.zeros((6, n))
|
| 154 |
+
for j in range(n):
|
| 155 |
+
s2 = s.copy()
|
| 156 |
+
s2[sl.start + j] += fd_eps
|
| 157 |
+
J[:, j] = (res(s2) - r) / fd_eps
|
| 158 |
+
if not np.all(np.isfinite(J)):
|
| 159 |
+
return np.array(q, dtype=np.float64), np.inf
|
| 160 |
+
dq = np.linalg.solve(J.T @ J + (1.0 + lam) * np.eye(n), -J.T @ r - lam * (s[sl] - q_ref[sl]))
|
| 161 |
+
norm = np.linalg.norm(dq)
|
| 162 |
+
if norm > step_cap:
|
| 163 |
+
dq *= step_cap / norm
|
| 164 |
+
s[sl] += dq
|
| 165 |
+
if norm < 1e-4:
|
| 166 |
+
break
|
| 167 |
+
return s, last
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ----------------------------------------------------------------------------- main entry
|
| 171 |
+
def camera_to_base(points_cam: np.ndarray, T_base_cam: np.ndarray) -> np.ndarray:
|
| 172 |
+
"""(T, 2, 3) camera-frame points -> base frame; T_base_cam is (4, 4) or (T, 4, 4)."""
|
| 173 |
+
Tm = np.broadcast_to(np.asarray(T_base_cam, np.float64), (len(points_cam), 4, 4))
|
| 174 |
+
return np.einsum("tij,thj->thi", Tm[:, :3, :3], points_cam) + Tm[:, None, :3, 3]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def correct_wrists(actions: np.ndarray, kin, wrist_base: np.ndarray, confidence: np.ndarray,
|
| 178 |
+
cfg: dict | None = None) -> tuple[np.ndarray, dict]:
|
| 179 |
+
"""Correct the arm joints of ``actions`` (T, D) so the wrists follow ``wrist_base``.
|
| 180 |
+
|
| 181 |
+
wrist_base: (T, 2, 3) reconstructed human wrists in the robot base frame (metres, NaN when
|
| 182 |
+
missing); confidence: (T, 2). Returns the corrected actions and per-side statistics.
|
| 183 |
+
"""
|
| 184 |
+
c = {**DEFAULTS, **(cfg or {})}
|
| 185 |
+
act = np.asarray(actions, np.float64)
|
| 186 |
+
T = len(act)
|
| 187 |
+
out = act.copy()
|
| 188 |
+
fk_idm = [kin.fk(act[t]) for t in range(T)]
|
| 189 |
+
info = {}
|
| 190 |
+
for hi, side in enumerate(SIDES):
|
| 191 |
+
sl = kin.arm_joint_slices[side]
|
| 192 |
+
sl = sl if isinstance(sl, slice) else slice(*sl)
|
| 193 |
+
p_idm = np.stack([np.asarray(f[side]["wrist_pos"]) for f in fk_idm]) * 1000.0
|
| 194 |
+
R_idm = [np.asarray(f[side]["wrist_rot"]) for f in fk_idm]
|
| 195 |
+
# end effector of the scaled IDM hand, relative to its wrist
|
| 196 |
+
ee = np.stack([np.asarray(f[side]["keypoints"])[c["ee_keypoint"]] for f in fk_idm]) * 1000.0
|
| 197 |
+
target = wrist_base[:, hi] * 1000.0 - c["hand_scale"] * (ee - p_idm)
|
| 198 |
+
d = target - p_idm
|
| 199 |
+
ok = (confidence[:, hi] >= c["min_confidence"]) & np.isfinite(d).all(1)
|
| 200 |
+
n_conf = int(ok.sum())
|
| 201 |
+
d = np.where(ok[:, None], d, 0.0)
|
| 202 |
+
ok = reject_excursions(d, ok, c["reject_enter_mm"], c["reject_exit_mm"],
|
| 203 |
+
c["reject_hard_mm"], c["reject_ref_n"])
|
| 204 |
+
n_rej = n_conf - int(ok.sum())
|
| 205 |
+
ok = fill_short_gaps(d, ok, c["gap_fill"])
|
| 206 |
+
d = lowpass(d, ok, c["cutoff_hz"], c["fps"])
|
| 207 |
+
q_ik, prev, resid = act[:, sl].copy(), None, []
|
| 208 |
+
for t in range(T):
|
| 209 |
+
if not ok[t]:
|
| 210 |
+
prev = None
|
| 211 |
+
continue
|
| 212 |
+
cur = act[t].copy()
|
| 213 |
+
if prev is not None:
|
| 214 |
+
cur[sl] = prev
|
| 215 |
+
new, r = ik_wrist(kin.fk, cur, side, sl, p_idm[t] + d[t], R_idm[t], act[t],
|
| 216 |
+
c["ik_iters"], c["w_rot"], c["damping"], c["step_cap"], c["fd_eps"])
|
| 217 |
+
if not np.isfinite(r):
|
| 218 |
+
ok[t], prev = False, None
|
| 219 |
+
continue
|
| 220 |
+
q_ik[t] = prev = new[sl]
|
| 221 |
+
resid.append(r)
|
| 222 |
+
w = ramp_weights(ok, c["ramp"])
|
| 223 |
+
blended = act[:, sl] + w[:, None] * (q_ik - act[:, sl])
|
| 224 |
+
if T > c["smooth_window"]:
|
| 225 |
+
blended = savgol_filter(blended, c["smooth_window"], c["smooth_order"], axis=0, mode="nearest")
|
| 226 |
+
out[:, sl] = blended
|
| 227 |
+
info[side] = dict(confident_frames=n_conf, rejected=n_rej, corrected_frames=int((w > 0).sum()),
|
| 228 |
+
ik_residual_mm_median=float(np.median(resid)) if resid else None)
|
| 229 |
+
return out.astype(np.asarray(actions).dtype), info
|
robotryon/image/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Keyframe translation f_theta (paper Sec. 3.2): FLUX.2 [klein] 9B Base as a reference-guided
|
| 2 |
+
inpainting model.
|
| 3 |
+
|
| 4 |
+
Modules:
|
| 5 |
+
fill_channels widened patch embedding, channel-concatenated [noisy | conditioned frame | mask]
|
| 6 |
+
stereo_ids RoPE ids with the camera view on the 4th axis
|
| 7 |
+
episodes data layout access and model-resolution geometry
|
| 8 |
+
dataset training samples (reference window, robot-only exemplar, masks, skeleton)
|
| 9 |
+
text prompts, caption dropout and the text-embedding cache
|
| 10 |
+
training flow-matching loss
|
| 11 |
+
model FFT / LoRA build, save and load
|
| 12 |
+
sampler true-CFG Euler sampler returning latent tokens
|
| 13 |
+
ar_keyframes autoregressive translation with latent reuse
|
| 14 |
+
"""
|
robotryon/image/ar_keyframes.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Autoregressive keyframe translation (paper Eq. 3.2, App. B.2.3).
|
| 2 |
+
|
| 3 |
+
Human keyframes are translated in temporal order. Keyframe t_j is conditioned on
|
| 4 |
+
|
| 5 |
+
* the conditioned human frame Phi(o^h; M^h, S^h): the human arm mask (convex hull per arm,
|
| 6 |
+
fixed dilation, eroded object removed) blacked out and the retargeted robot-hand skeleton drawn
|
| 7 |
+
on top, plus the mask itself as an input channel;
|
| 8 |
+
* the reference: for the first keyframe, the encoded robot-only reference image supplied by the
|
| 9 |
+
user (it selects the embodiment); for every later keyframe, the DENOISED LATENT TOKENS of the
|
| 10 |
+
previous keyframe, reused directly as reference tokens (no decode / re-encode).
|
| 11 |
+
|
| 12 |
+
``ref_skeleton: true`` replaces latent reuse by a pixel-space reference: the previous keyframe is
|
| 13 |
+
decoded, the current keyframe's skeleton is painted on it, and the result is re-encoded (the first
|
| 14 |
+
keyframe's reference image is painted the same way).
|
| 15 |
+
|
| 16 |
+
Stereo episodes are translated jointly (both views in one token sequence) by default; ``views:
|
| 17 |
+
mono`` translates each view as its own chain.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
from PIL import Image
|
| 28 |
+
|
| 29 |
+
from ..masks import MaskAug, inference_mask
|
| 30 |
+
from ..skeleton import condition_frame, draw_skeleton, paint
|
| 31 |
+
from .episodes import Episode, FrameReader, load_view_frame
|
| 32 |
+
from .fill_channels import pack
|
| 33 |
+
from .sampler import LatentCodec, ViewCondition, denoise
|
| 34 |
+
from ..captions import HandStateCuts, hand_state_arrays, hand_state_prompt, load_hand_state, words_at
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class TranslatedKeyframe:
|
| 39 |
+
image: np.ndarray # uint8 (H, W, 3) robot keyframe
|
| 40 |
+
cond: np.ndarray # uint8 (H, W, 3) conditioned human frame
|
| 41 |
+
mask: np.ndarray # uint8 (H, W) {0, 1} inference mask
|
| 42 |
+
tokens: torch.Tensor # (1, h*w, 128) denoised latent tokens (next keyframe's reference)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class KeyframeTranslator:
|
| 46 |
+
def __init__(self, pipe, cfg, device="cuda"):
|
| 47 |
+
self.pipe, self.cfg, self.device = pipe, cfg, device
|
| 48 |
+
self.codec = LatentCodec(pipe.vae, device)
|
| 49 |
+
self.reader = FrameReader()
|
| 50 |
+
self.dilate = MaskAug.from_config(cfg.masks).infer_dilate()
|
| 51 |
+
self.ref_skeleton = bool(cfg.get("ref_skeleton", False))
|
| 52 |
+
self._prompts: dict[str, tuple] = {}
|
| 53 |
+
|
| 54 |
+
def prompt_fn(self, ep: Episode, episode_dir: Path):
|
| 55 |
+
"""``prompt(t)``: the instruction and each hand's closure / facing words at keyframe t
|
| 56 |
+
(``robotryon.captions``), read from the episode's ``hand_state_<view>.npz`` (or computed
|
| 57 |
+
from ``hands.npz`` when absent)."""
|
| 58 |
+
cuts = HandStateCuts.from_config(self.cfg.caption.get("cuts"))
|
| 59 |
+
states = []
|
| 60 |
+
for i, v in enumerate(ep.views):
|
| 61 |
+
f = episode_dir / f"hand_state_{v}.npz"
|
| 62 |
+
if f.exists():
|
| 63 |
+
states.append(load_hand_state(f))
|
| 64 |
+
if not states:
|
| 65 |
+
z = np.load(episode_dir / "hands.npz")
|
| 66 |
+
states = [hand_state_arrays(z["joints3d"][i], z["valid"][i].astype(bool))
|
| 67 |
+
for i in range(z["joints3d"].shape[0])]
|
| 68 |
+
return lambda t: hand_state_prompt(*words_at(states, int(t), cuts))
|
| 69 |
+
|
| 70 |
+
def encode_prompt(self, text: str):
|
| 71 |
+
if text not in self._prompts:
|
| 72 |
+
self._prompts[text] = self.pipe.encode_prompt(prompt=text, device=self.device)
|
| 73 |
+
return self._prompts[text]
|
| 74 |
+
|
| 75 |
+
def condition(self, ep: Episode, view: str, t: int):
|
| 76 |
+
"""(frame, conditioned frame, mask, skeleton image) of one human keyframe at model resolution."""
|
| 77 |
+
c = self.cfg
|
| 78 |
+
f = load_view_frame(self.reader, ep, view, t, int(c.image.long_edge),
|
| 79 |
+
tuple(c.image.aspect) if c.image.get("aspect") else None,
|
| 80 |
+
c.image.get("crop_anchor", "bottom"))
|
| 81 |
+
H, W = f.image.shape[:2]
|
| 82 |
+
mask = inference_mask(f.hulls, W, H, self.dilate, f.object_mask, int(c.masks.obj_erode))
|
| 83 |
+
skel = draw_skeleton(f.kp, f.vis, W, H)
|
| 84 |
+
cond = condition_frame(f.image, mask, skel)
|
| 85 |
+
return f.image, cond, mask, skel
|
| 86 |
+
|
| 87 |
+
def reference(self, view: str, prev: dict, references: dict, skel: np.ndarray) -> torch.Tensor:
|
| 88 |
+
"""Reference tokens of one view for the current keyframe."""
|
| 89 |
+
if view in prev and not self.ref_skeleton:
|
| 90 |
+
return prev[view].tokens # latent reuse
|
| 91 |
+
if view in prev:
|
| 92 |
+
img = prev[view].image
|
| 93 |
+
else:
|
| 94 |
+
H, W = skel.shape[:2]
|
| 95 |
+
img = np.asarray(Image.fromarray(references[view]).resize((W, H), Image.LANCZOS))
|
| 96 |
+
return pack(self.codec.encode(paint(img, skel) if self.ref_skeleton else img))
|
| 97 |
+
|
| 98 |
+
@torch.no_grad()
|
| 99 |
+
def translate_chain(self, views: list[str], keyframes: list[int], references: dict[str, np.ndarray],
|
| 100 |
+
prompt, conditions) -> dict:
|
| 101 |
+
"""One chain over ``keyframes``; ``views`` are denoised jointly.
|
| 102 |
+
|
| 103 |
+
``prompt`` is a string or ``prompt(t)`` giving each keyframe its own prompt.
|
| 104 |
+
``conditions(view, t)`` returns ``(frame, conditioned frame, mask, skeleton image)``.
|
| 105 |
+
Returns ``{view: {t: TranslatedKeyframe}}``.
|
| 106 |
+
"""
|
| 107 |
+
c = self.cfg
|
| 108 |
+
prompt_at = prompt if callable(prompt) else (lambda t: prompt)
|
| 109 |
+
neg, neg_ids = self.encode_prompt("") if float(c.guidance_scale) > 1.0 else (None, None)
|
| 110 |
+
prev: dict[str, TranslatedKeyframe] = {}
|
| 111 |
+
out: dict[str, dict] = {v: {} for v in views}
|
| 112 |
+
for t in keyframes:
|
| 113 |
+
conds, frames = [], []
|
| 114 |
+
for v in views:
|
| 115 |
+
_, cond, mask, skel = conditions(v, t)
|
| 116 |
+
mask_t = torch.from_numpy(mask.astype(np.float32))[None, None].to(self.device)
|
| 117 |
+
conds.append(ViewCondition(self.codec.encode(cond), mask_t,
|
| 118 |
+
self.reference(v, prev, references, skel)))
|
| 119 |
+
frames.append((cond, mask))
|
| 120 |
+
pos, pos_ids = self.encode_prompt(prompt_at(t))
|
| 121 |
+
gen = torch.Generator(device=self.device).manual_seed(int(c.seed) + int(t))
|
| 122 |
+
tokens = denoise(self.pipe.transformer, self.pipe.scheduler, conds, pos, pos_ids, neg, neg_ids,
|
| 123 |
+
steps=int(c.steps), guidance_scale=float(c.guidance_scale), generator=gen)
|
| 124 |
+
for v, tok, (cond, mask) in zip(views, tokens, frames):
|
| 125 |
+
H, W = mask.shape
|
| 126 |
+
img = self.codec.decode_tokens(tok, H // 16, W // 16)
|
| 127 |
+
out[v][t] = prev[v] = TranslatedKeyframe(img, cond, mask, tok)
|
| 128 |
+
return out
|
| 129 |
+
|
| 130 |
+
def translate_episode(self, episode_dir: str | Path, references: dict[str, np.ndarray],
|
| 131 |
+
out_dir: str | Path) -> Path:
|
| 132 |
+
"""Translate every keyframe of a preprocessed human episode and write the results."""
|
| 133 |
+
c = self.cfg
|
| 134 |
+
ep = Episode.load(episode_dir)
|
| 135 |
+
kf = json.loads((Path(episode_dir) / "keyframes.json").read_text())["indices"]
|
| 136 |
+
keyframes = sorted({int(i) for i in kf} | {0})
|
| 137 |
+
stereo = len(ep.views) >= 2 and c.views in ("auto", "stereo")
|
| 138 |
+
if c.views == "stereo" and not stereo:
|
| 139 |
+
raise ValueError(f"{episode_dir}: views=stereo needs two camera views, found {ep.views}")
|
| 140 |
+
chains = [ep.views[:2]] if stereo else [[v] for v in ep.views]
|
| 141 |
+
missing = [v for v in ep.views if v not in references]
|
| 142 |
+
if missing:
|
| 143 |
+
raise ValueError(f"no reference image for view(s) {missing}")
|
| 144 |
+
prompt_at = self.prompt_fn(ep, Path(episode_dir))
|
| 145 |
+
prompts = {int(t): prompt_at(t) for t in keyframes}
|
| 146 |
+
out = Path(out_dir) / ep.path.name
|
| 147 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 148 |
+
for views in chains:
|
| 149 |
+
res = self.translate_chain(views, keyframes, references, prompt_at,
|
| 150 |
+
lambda v, t: self.condition(ep, v, t))
|
| 151 |
+
for v, per_t in res.items():
|
| 152 |
+
for t, k in per_t.items():
|
| 153 |
+
Image.fromarray(k.image).save(out / f"keyframe_{v}_{t:06d}.png")
|
| 154 |
+
Image.fromarray(k.cond).save(out / f"cond_{v}_{t:06d}.png")
|
| 155 |
+
Image.fromarray(k.mask * 255).save(out / f"mask_{v}_{t:06d}.png")
|
| 156 |
+
(out / "keyframes.json").write_text(json.dumps({
|
| 157 |
+
"indices": keyframes, "views": ep.views, "joint_stereo": stereo, "prompts": prompts,
|
| 158 |
+
"ref_skeleton": self.ref_skeleton, "steps": int(c.steps),
|
| 159 |
+
"guidance_scale": float(c.guidance_scale),
|
| 160 |
+
"seed": int(c.seed)}, indent=1))
|
| 161 |
+
return out
|
robotryon/image/dataset.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training samples for the keyframe-translation model (paper Sec. 3.2, App. B.2.3).
|
| 2 |
+
|
| 3 |
+
A sample is a robot frame o_t of one episode, seen by one camera (mono) or both (stereo):
|
| 4 |
+
|
| 5 |
+
* target o_t
|
| 6 |
+
* cond Phi(o_t; M_t, S_t): the randomised shape-agnostic mask blacked out, the augmented
|
| 7 |
+
hand skeleton (rendered on the fly from keypoints) drawn on top
|
| 8 |
+
* mask M_t
|
| 9 |
+
* reference o_t' with t' drawn uniformly from [t - 5 s, t - 2 s] of the same episode (frame 0 if
|
| 10 |
+
the target is too early). With probability ``p_robot_only`` the reference is instead a
|
| 11 |
+
"robot-only" exemplar: a frame of ANOTHER episode of the same embodiment with
|
| 12 |
+
everything but the robot (its tight mask, eroded) filled with white. This is
|
| 13 |
+
the reference the first keyframe gets at inference.
|
| 14 |
+
* prompt the hand-state prompt of the target frame (each hand's closure / facing words from the
|
| 15 |
+
episode's ``hand_state_<view>.npz``; see ``robotryon.captions``), as a cached
|
| 16 |
+
embedding.
|
| 17 |
+
|
| 18 |
+
With probability ``ref_skeleton_p`` the target's (augmented) skeleton is also painted on the
|
| 19 |
+
reference, sequential or robot-only alike; otherwise the reference is left clean.
|
| 20 |
+
|
| 21 |
+
The two views of a stereo sample share every random draw (mask dilations, skeleton corruption,
|
| 22 |
+
reference frame, field-of-view crop): they are two views of one instant.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import torch
|
| 28 |
+
from PIL import Image
|
| 29 |
+
|
| 30 |
+
from ..masks import MaskAug, fov_augment, robot_only
|
| 31 |
+
from ..skeleton import SkeletonAugment, condition_frame, draw_skeleton, paint
|
| 32 |
+
from .episodes import Episode, FrameReader, crop_resize, load_view_frame, output_geometry, scan_root
|
| 33 |
+
from ..captions import HandStateCuts, all_prompts, load_hand_state, sample_prompt, words_at
|
| 34 |
+
from .text import TextEmbedCache
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _to_tensor(img: Image.Image) -> torch.Tensor:
|
| 38 |
+
a = np.asarray(img, dtype=np.float32)
|
| 39 |
+
if a.ndim == 2:
|
| 40 |
+
return torch.from_numpy(a / 255.0)[None]
|
| 41 |
+
return torch.from_numpy(a / 127.5 - 1.0).permute(2, 0, 1).contiguous()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class KeyframeDataset(torch.utils.data.Dataset):
|
| 45 |
+
"""Indexed by ``(episode_index, frame, view_indices, seed)`` tuples from :class:`FillBatchSampler`."""
|
| 46 |
+
|
| 47 |
+
def __init__(self, cfg, text_cache: TextEmbedCache | None):
|
| 48 |
+
d = cfg.data
|
| 49 |
+
roots = d.roots if isinstance(d.roots, (list, tuple)) else [d.roots]
|
| 50 |
+
self.root_weights = []
|
| 51 |
+
self.episodes: list[Episode] = []
|
| 52 |
+
for i, r in enumerate(roots):
|
| 53 |
+
path, w = (r.path, float(r.get("weight", 1.0))) if isinstance(r, dict) else (r, 1.0)
|
| 54 |
+
eps = scan_root(path, i)
|
| 55 |
+
if not eps:
|
| 56 |
+
raise FileNotFoundError(f"no episodes (*/meta.json) under {path}")
|
| 57 |
+
self.episodes += eps
|
| 58 |
+
self.root_weights.append(w)
|
| 59 |
+
self.long_edge = int(cfg.image.long_edge)
|
| 60 |
+
self.aspect = tuple(cfg.image.aspect) if cfg.image.get("aspect") else None
|
| 61 |
+
self.anchor = cfg.image.get("crop_anchor", "bottom")
|
| 62 |
+
self.ref_window = tuple(d.ref_window_sec)
|
| 63 |
+
self.p_robot_only = float(d.p_robot_only)
|
| 64 |
+
self.ref_skeleton_p = float(d.ref_skeleton_p)
|
| 65 |
+
self.robot_only_erode = int(d.robot_only.erode)
|
| 66 |
+
self.robot_only_bg = int(d.robot_only.background)
|
| 67 |
+
self.robot_only_min_area = float(d.robot_only.min_area)
|
| 68 |
+
self.bonus_tags = set(cfg.masks.get("bonus_embodiments", []) or [])
|
| 69 |
+
self.mask_aug = MaskAug.from_config(cfg.masks)
|
| 70 |
+
self.fov = cfg.masks.fov
|
| 71 |
+
self.skel_aug = SkeletonAugment.from_config(cfg.skeleton.augment)
|
| 72 |
+
self.thickness = tuple(cfg.skeleton.thickness)
|
| 73 |
+
self.cap = cfg.caption
|
| 74 |
+
self.text_cache = text_cache
|
| 75 |
+
self.reader: FrameReader | None = None
|
| 76 |
+
self.by_embodiment: dict[str, list[int]] = {}
|
| 77 |
+
for i, ep in enumerate(self.episodes):
|
| 78 |
+
self.by_embodiment.setdefault(ep.embodiment, []).append(i)
|
| 79 |
+
|
| 80 |
+
def __len__(self) -> int:
|
| 81 |
+
return len(self.episodes)
|
| 82 |
+
|
| 83 |
+
def hand_states(self, ep: Episode, views: list[str]) -> list:
|
| 84 |
+
return [load_hand_state(ep.path / f"hand_state_{v}.npz") for v in views
|
| 85 |
+
if (ep.path / f"hand_state_{v}.npz").exists()]
|
| 86 |
+
|
| 87 |
+
def all_prompts(self) -> set[str]:
|
| 88 |
+
return set(all_prompts())
|
| 89 |
+
|
| 90 |
+
# -- reference ------------------------------------------------------------------------------
|
| 91 |
+
def reference_index(self, ep: Episode, t: int, rng: np.random.Generator) -> int:
|
| 92 |
+
lo, hi = self.ref_window
|
| 93 |
+
newest = t - int(round(lo * ep.fps))
|
| 94 |
+
oldest = max(0, t - int(round(hi * ep.fps)))
|
| 95 |
+
if newest < oldest:
|
| 96 |
+
return 0
|
| 97 |
+
return int(rng.integers(oldest, newest + 1))
|
| 98 |
+
|
| 99 |
+
def draw_exemplar(self, ep_idx: int, views: list[str], rng) -> tuple[int, int] | None:
|
| 100 |
+
"""(episode, frame) of another episode of the same embodiment with a visible robot."""
|
| 101 |
+
ep = self.episodes[ep_idx]
|
| 102 |
+
pool = [i for i in self.by_embodiment.get(ep.embodiment, [])
|
| 103 |
+
if i != ep_idx and all(v in self.episodes[i].views for v in views)]
|
| 104 |
+
if not pool:
|
| 105 |
+
return None
|
| 106 |
+
ex = int(pool[int(rng.integers(0, len(pool)))])
|
| 107 |
+
ex_ep = self.episodes[ex]
|
| 108 |
+
for _ in range(8):
|
| 109 |
+
t = int(rng.integers(0, ex_ep.num_frames))
|
| 110 |
+
m = self.reader.binary(ex_ep.mask(views[0]), t)
|
| 111 |
+
if m.mean() >= self.robot_only_min_area:
|
| 112 |
+
return ex, t
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
def robot_only(self, ex: int, t: int, view: str, size) -> np.ndarray:
|
| 116 |
+
ep = self.episodes[ex]
|
| 117 |
+
rgb = self.reader.rgb(ep.video(view), t)
|
| 118 |
+
src = (rgb.shape[1], rgb.shape[0])
|
| 119 |
+
box, _ = output_geometry(src[0], src[1], self.long_edge, self.aspect, self.anchor)
|
| 120 |
+
img = crop_resize(rgb, box, size, src)
|
| 121 |
+
m = crop_resize(self.reader.binary(ep.mask(view), t).astype(np.uint8) * 255, box, size, src)
|
| 122 |
+
return robot_only(img, m > 127, self.robot_only_erode, self.robot_only_bg)
|
| 123 |
+
|
| 124 |
+
# -- one view -------------------------------------------------------------------------------
|
| 125 |
+
def view_streams(self, ep: Episode, view: str, t: int, ref_t: int, exemplar, ref_skeleton: bool,
|
| 126 |
+
rng) -> dict:
|
| 127 |
+
f = load_view_frame(self.reader, ep, view, t, self.long_edge, self.aspect, self.anchor)
|
| 128 |
+
H, W = f.image.shape[:2]
|
| 129 |
+
mask, _ = self.mask_aug.train_mask(f.hulls, W, H, rng, bonus=ep.embodiment in self.bonus_tags,
|
| 130 |
+
object_mask=f.object_mask)
|
| 131 |
+
kp, vis = self.skel_aug(f.kp, f.vis, rng)
|
| 132 |
+
th = int(rng.integers(self.thickness[0], self.thickness[1] + 1))
|
| 133 |
+
skel = draw_skeleton(kp, vis, W, H, thickness=th, radius=max(1, int(round(th * 1.5))))
|
| 134 |
+
cond = condition_frame(f.image, mask, skel)
|
| 135 |
+
if exemplar is not None:
|
| 136 |
+
ref = self.robot_only(exemplar[0], exemplar[1], view, (W, H))
|
| 137 |
+
else:
|
| 138 |
+
rgb = self.reader.rgb(ep.video(view), ref_t)
|
| 139 |
+
src = (rgb.shape[1], rgb.shape[0])
|
| 140 |
+
box, _ = output_geometry(src[0], src[1], self.long_edge, self.aspect, self.anchor)
|
| 141 |
+
ref = crop_resize(rgb, box, (W, H), src)
|
| 142 |
+
if ref_skeleton:
|
| 143 |
+
ref = paint(ref, skel)
|
| 144 |
+
return {"target": f.image, "cond": cond, "mask": mask * 255, "reference": ref,
|
| 145 |
+
"has_hull": bool(f.hulls)}
|
| 146 |
+
|
| 147 |
+
def __getitem__(self, item) -> dict:
|
| 148 |
+
ep_idx, t, view_idx, seed = item
|
| 149 |
+
if self.reader is None:
|
| 150 |
+
self.reader = FrameReader()
|
| 151 |
+
ep = self.episodes[ep_idx]
|
| 152 |
+
views = [ep.views[i] for i in view_idx]
|
| 153 |
+
rng = np.random.default_rng([int(seed), 0])
|
| 154 |
+
exemplar = None
|
| 155 |
+
if float(rng.random()) < self.p_robot_only:
|
| 156 |
+
exemplar = self.draw_exemplar(ep_idx, views, rng)
|
| 157 |
+
ref_skeleton = float(rng.random()) < self.ref_skeleton_p
|
| 158 |
+
for attempt in range(8): # prefer frames where the arm is visible
|
| 159 |
+
ref_t = self.reference_index(ep, t, rng)
|
| 160 |
+
out = [self.view_streams(ep, v, t, ref_t, exemplar, ref_skeleton,
|
| 161 |
+
np.random.default_rng([int(seed), 1, attempt])) for v in views]
|
| 162 |
+
if out[0]["has_hull"]:
|
| 163 |
+
break
|
| 164 |
+
t = int(rng.integers(0, ep.num_frames))
|
| 165 |
+
keys = ("target", "cond", "mask", "reference")
|
| 166 |
+
imgs = [Image.fromarray(np.asarray(o[k], np.uint8)) for o in out for k in keys]
|
| 167 |
+
imgs, _ = fov_augment(imgs, rng, float(self.fov.crop_min), float(self.fov.scale_max),
|
| 168 |
+
float(self.fov.get("flip_prob", 0.0)))
|
| 169 |
+
n = len(keys)
|
| 170 |
+
sample = {k: torch.stack([_to_tensor(imgs[v * n + j]) for v in range(len(views))])
|
| 171 |
+
for j, k in enumerate(keys)}
|
| 172 |
+
words = words_at(self.hand_states(ep, views), t, HandStateCuts.from_config(self.cap.get("cuts")))
|
| 173 |
+
prompt = sample_prompt(words, rng, float(self.cap.p_uncond), float(self.cap.p_drop_hands))
|
| 174 |
+
sample["prompt"] = prompt
|
| 175 |
+
if self.text_cache is not None:
|
| 176 |
+
sample["prompt_embeds"] = self.text_cache.load(prompt)
|
| 177 |
+
sample["robot_only"] = exemplar is not None
|
| 178 |
+
sample["ref_skeleton"] = ref_skeleton
|
| 179 |
+
return sample
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def collate(batch: list[dict]) -> dict:
|
| 183 |
+
out = {k: torch.stack([b[k] for b in batch]) for k in ("target", "cond", "mask", "reference")}
|
| 184 |
+
if "prompt_embeds" in batch[0]:
|
| 185 |
+
out["prompt_embeds"] = torch.stack([b["prompt_embeds"] for b in batch])
|
| 186 |
+
out["prompt"] = [b["prompt"] for b in batch]
|
| 187 |
+
out["robot_only"] = [b["robot_only"] for b in batch]
|
| 188 |
+
return out
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class FillBatchSampler(torch.utils.data.Sampler):
|
| 192 |
+
"""Endless stream of homogeneous batches for one rank.
|
| 193 |
+
|
| 194 |
+
A batch is all-stereo or all-mono (the token sequence length depends on it): stereo with
|
| 195 |
+
probability ``stereo_p`` (paper: stereo : mono = 3 : 1) when stereo episodes exist. Items pick
|
| 196 |
+
a data root by weight, an episode uniformly within it, a frame uniformly, and for mono a view
|
| 197 |
+
uniformly. Each rank draws its own stream from ``(seed, rank)``.
|
| 198 |
+
"""
|
| 199 |
+
|
| 200 |
+
def __init__(self, dataset: KeyframeDataset, batch_size: int, stereo_p: float, seed: int,
|
| 201 |
+
rank: int = 0):
|
| 202 |
+
self.ds, self.bs, self.stereo_p = dataset, int(batch_size), float(stereo_p)
|
| 203 |
+
self.rng = np.random.default_rng([int(seed), int(rank)])
|
| 204 |
+
eps = dataset.episodes
|
| 205 |
+
n_roots = len(dataset.root_weights)
|
| 206 |
+
self.pools = {s: [[i for i, e in enumerate(eps) if e.root == r and (len(e.views) >= 2 or not s)]
|
| 207 |
+
for r in range(n_roots)] for s in (True, False)}
|
| 208 |
+
self.has_stereo = any(self.pools[True])
|
| 209 |
+
|
| 210 |
+
def _root_probs(self, stereo: bool) -> np.ndarray:
|
| 211 |
+
w = np.array([wt if self.pools[stereo][r] else 0.0
|
| 212 |
+
for r, wt in enumerate(self.ds.root_weights)], np.float64)
|
| 213 |
+
return w / w.sum()
|
| 214 |
+
|
| 215 |
+
def __iter__(self):
|
| 216 |
+
while True:
|
| 217 |
+
stereo = self.has_stereo and float(self.rng.random()) < self.stereo_p
|
| 218 |
+
p = self._root_probs(stereo)
|
| 219 |
+
batch = []
|
| 220 |
+
for r in self.rng.choice(len(p), size=self.bs, p=p):
|
| 221 |
+
pool = self.pools[stereo][int(r)]
|
| 222 |
+
e = pool[int(self.rng.integers(0, len(pool)))]
|
| 223 |
+
ep = self.ds.episodes[e]
|
| 224 |
+
t = int(self.rng.integers(0, ep.num_frames))
|
| 225 |
+
views = (0, 1) if stereo else (int(self.rng.integers(0, len(ep.views))),)
|
| 226 |
+
batch.append((e, t, views, int(self.rng.integers(0, 2 ** 31))))
|
| 227 |
+
yield batch
|
robotryon/image/episodes.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Episode directories in the shared RoboTryOn data layout, and random frame access.
|
| 2 |
+
|
| 3 |
+
::
|
| 4 |
+
|
| 5 |
+
<root>/<episode_id>/
|
| 6 |
+
meta.json {"fps", "views", "num_frames", "caption", "embodiment"}
|
| 7 |
+
video_<view>.mp4 RGB
|
| 8 |
+
keypoints_<view>.npz kp (T, 2, 17, 2) px at video resolution, vis (T, 2, 17)
|
| 9 |
+
mask_<view>.mp4 tight arm + hand mask (gray, > 127 = masked)
|
| 10 |
+
object_mask_<view>.mp4 optional manipulated-object mask
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
from collections import OrderedDict
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import cv2
|
| 20 |
+
import numpy as np
|
| 21 |
+
from PIL import Image
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class Episode:
|
| 26 |
+
path: Path
|
| 27 |
+
views: list[str]
|
| 28 |
+
num_frames: int
|
| 29 |
+
fps: float
|
| 30 |
+
caption: str = ""
|
| 31 |
+
embodiment: str = ""
|
| 32 |
+
root: int = 0
|
| 33 |
+
extra: dict = field(default_factory=dict)
|
| 34 |
+
|
| 35 |
+
@classmethod
|
| 36 |
+
def load(cls, path: str | Path, root: int = 0) -> "Episode":
|
| 37 |
+
path = Path(path)
|
| 38 |
+
m = json.loads((path / "meta.json").read_text())
|
| 39 |
+
return cls(path=path, views=list(m["views"]), num_frames=int(m["num_frames"]),
|
| 40 |
+
fps=float(m["fps"]), caption=str(m.get("caption", "")),
|
| 41 |
+
embodiment=str(m.get("embodiment", "")), root=root, extra=m)
|
| 42 |
+
|
| 43 |
+
def video(self, view: str) -> Path:
|
| 44 |
+
return self.path / f"video_{view}.mp4"
|
| 45 |
+
|
| 46 |
+
def mask(self, view: str) -> Path:
|
| 47 |
+
return self.path / f"mask_{view}.mp4"
|
| 48 |
+
|
| 49 |
+
def object_mask(self, view: str) -> Path | None:
|
| 50 |
+
p = self.path / f"object_mask_{view}.mp4"
|
| 51 |
+
return p if p.exists() else None
|
| 52 |
+
|
| 53 |
+
def keypoints(self, view: str) -> Path:
|
| 54 |
+
return self.path / f"keypoints_{view}.npz"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def scan_root(root: str | Path, root_index: int = 0) -> list[Episode]:
|
| 58 |
+
return [Episode.load(p.parent, root_index) for p in sorted(Path(root).glob("*/meta.json"))]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class FrameReader:
|
| 62 |
+
"""Small LRU of open ``cv2.VideoCapture`` objects and keypoint files (one per worker)."""
|
| 63 |
+
|
| 64 |
+
def __init__(self, max_open: int = 16):
|
| 65 |
+
self._caps: OrderedDict[str, cv2.VideoCapture] = OrderedDict()
|
| 66 |
+
self._kps: OrderedDict[str, tuple[np.ndarray, np.ndarray]] = OrderedDict()
|
| 67 |
+
self.max_open = max_open
|
| 68 |
+
|
| 69 |
+
def _cap(self, path: Path) -> cv2.VideoCapture:
|
| 70 |
+
key = str(path)
|
| 71 |
+
if key in self._caps:
|
| 72 |
+
self._caps.move_to_end(key)
|
| 73 |
+
return self._caps[key]
|
| 74 |
+
cap = cv2.VideoCapture(key)
|
| 75 |
+
if not cap.isOpened():
|
| 76 |
+
raise FileNotFoundError(f"cannot open video {key}")
|
| 77 |
+
self._caps[key] = cap
|
| 78 |
+
while len(self._caps) > self.max_open:
|
| 79 |
+
self._caps.popitem(last=False)[1].release()
|
| 80 |
+
return cap
|
| 81 |
+
|
| 82 |
+
def rgb(self, path: Path, t: int) -> np.ndarray:
|
| 83 |
+
cap = self._cap(path)
|
| 84 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, int(t))
|
| 85 |
+
ok, bgr = cap.read()
|
| 86 |
+
if not ok:
|
| 87 |
+
raise RuntimeError(f"cannot decode frame {t} of {path}")
|
| 88 |
+
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 89 |
+
|
| 90 |
+
def binary(self, path: Path, t: int) -> np.ndarray:
|
| 91 |
+
"""Gray mask frame -> bool ``(H, W)`` (> 127)."""
|
| 92 |
+
return self.rgb(path, t).max(axis=-1) > 127
|
| 93 |
+
|
| 94 |
+
def keypoints(self, path: Path) -> tuple[np.ndarray, np.ndarray]:
|
| 95 |
+
key = str(path)
|
| 96 |
+
if key not in self._kps:
|
| 97 |
+
z = np.load(key)
|
| 98 |
+
self._kps[key] = (z["kp"].astype(np.float32), z["vis"].astype(bool))
|
| 99 |
+
while len(self._kps) > 4 * self.max_open:
|
| 100 |
+
self._kps.popitem(last=False)
|
| 101 |
+
return self._kps[key]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def output_geometry(src_w: int, src_h: int, long_edge: int, aspect: tuple[int, int] | None,
|
| 105 |
+
anchor: str = "bottom") -> tuple[tuple[int, int, int, int], tuple[int, int]]:
|
| 106 |
+
"""Crop box ``(x0, y0, w, h)`` in source pixels and output size ``(W, H)``.
|
| 107 |
+
|
| 108 |
+
A source with a different aspect ratio than ``aspect`` is cropped to it first ("bottom" keeps
|
| 109 |
+
the bottom edge, where the arms enter; "center" crops symmetrically). The long edge is then
|
| 110 |
+
resized to ``long_edge`` and both sides snapped to multiples of 16 (VAE /8 and 2x2 packing).
|
| 111 |
+
"""
|
| 112 |
+
x0, y0, w, h = 0, 0, src_w, src_h
|
| 113 |
+
if aspect is not None and abs(src_w / src_h - aspect[0] / aspect[1]) > 1e-3:
|
| 114 |
+
if src_w / src_h < aspect[0] / aspect[1]: # too tall: crop height
|
| 115 |
+
h = int(round(src_w * aspect[1] / aspect[0]))
|
| 116 |
+
y0 = src_h - h if anchor == "bottom" else (src_h - h) // 2
|
| 117 |
+
else: # too wide: crop width, centred
|
| 118 |
+
w = int(round(src_h * aspect[0] / aspect[1]))
|
| 119 |
+
x0 = (src_w - w) // 2
|
| 120 |
+
s = long_edge / max(w, h)
|
| 121 |
+
snap = lambda v: max(16, int(round(v / 16) * 16)) # noqa: E731
|
| 122 |
+
return (x0, y0, w, h), (snap(w * s), snap(h * s))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def crop_resize(img: np.ndarray, box, size, src_size, nearest: bool = False) -> np.ndarray:
|
| 126 |
+
"""Crop ``box`` (source-frame pixels) out of ``img`` and resize to ``size``.
|
| 127 |
+
|
| 128 |
+
``img`` may have a different resolution than the source frame ``src_size`` (e.g. a mask video
|
| 129 |
+
stored smaller); the box is rescaled accordingly. RGB uses PIL Lanczos (as the original
|
| 130 |
+
pipeline did), masks use nearest neighbour.
|
| 131 |
+
"""
|
| 132 |
+
fx, fy = img.shape[1] / src_size[0], img.shape[0] / src_size[1]
|
| 133 |
+
x0, y0 = int(round(box[0] * fx)), int(round(box[1] * fy))
|
| 134 |
+
x1, y1 = int(round((box[0] + box[2]) * fx)), int(round((box[1] + box[3]) * fy))
|
| 135 |
+
sub = img[y0:y1, x0:x1]
|
| 136 |
+
if nearest:
|
| 137 |
+
return cv2.resize(sub.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST)
|
| 138 |
+
pil = Image.fromarray(sub)
|
| 139 |
+
return np.asarray(pil if pil.size == tuple(size) else pil.resize(size, Image.LANCZOS))
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@dataclass
|
| 143 |
+
class ViewFrame:
|
| 144 |
+
"""One camera frame mapped to the model resolution.
|
| 145 |
+
|
| 146 |
+
image: uint8 ``(H, W, 3)``; hulls: per-arm convex hulls in output pixels; kp/vis: hand
|
| 147 |
+
keypoints in output pixels; object_mask: uint8 ``(H, W)`` or None.
|
| 148 |
+
"""
|
| 149 |
+
image: np.ndarray
|
| 150 |
+
hulls: list
|
| 151 |
+
kp: np.ndarray
|
| 152 |
+
vis: np.ndarray
|
| 153 |
+
object_mask: np.ndarray | None
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def load_view_frame(reader: FrameReader, ep: Episode, view: str, t: int, long_edge: int,
|
| 157 |
+
aspect=None, anchor: str = "bottom", with_object: bool = True) -> ViewFrame:
|
| 158 |
+
from ..masks import arm_hulls, scale_hulls
|
| 159 |
+
rgb = reader.rgb(ep.video(view), t)
|
| 160 |
+
src = (rgb.shape[1], rgb.shape[0])
|
| 161 |
+
box, size = output_geometry(src[0], src[1], long_edge, aspect, anchor)
|
| 162 |
+
sx, sy = size[0] / box[2], size[1] / box[3]
|
| 163 |
+
image = crop_resize(rgb, box, size, src)
|
| 164 |
+
m = reader.binary(ep.mask(view), t)
|
| 165 |
+
mx, my = src[0] / m.shape[1], src[1] / m.shape[0] # mask px -> source px
|
| 166 |
+
hulls = scale_hulls(arm_hulls(m), mx * sx, my * sy, (box[0] / mx, box[1] / my))
|
| 167 |
+
kp, vis = reader.keypoints(ep.keypoints(view))
|
| 168 |
+
kp_t = (kp[t] - np.array(box[:2], np.float32)) * np.array([sx, sy], np.float32)
|
| 169 |
+
om = None
|
| 170 |
+
if with_object and ep.object_mask(view) is not None:
|
| 171 |
+
om = crop_resize(reader.binary(ep.object_mask(view), t).astype(np.uint8), box, size, src,
|
| 172 |
+
nearest=True)
|
| 173 |
+
return ViewFrame(image, hulls, kp_t, vis[t].copy(), om)
|
robotryon/image/fill_channels.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Channel-concatenated inpainting conditioning for the FLUX.2 [klein] transformer.
|
| 2 |
+
|
| 3 |
+
The conditioned frame and the mask are pixel-aligned with the target, so they ride the CHANNEL
|
| 4 |
+
axis of the target tokens (FLUX-Fill style)::
|
| 5 |
+
|
| 6 |
+
target token = [ noisy latent (128) | conditioned-frame latent (128) | folded mask (256) ]
|
| 7 |
+
|
| 8 |
+
The patch embedding ``x_embedder`` is ``Linear(128, inner_dim)`` in the base model and is applied
|
| 9 |
+
once to the whole token sequence, so it is widened to 512 input features. The new columns are
|
| 10 |
+
zero-initialised: before training the widened model reproduces the base model exactly, and a
|
| 11 |
+
conditioning stream it cannot use degrades to "ignored" rather than to noise. The widened
|
| 12 |
+
embedding is always trained in full (FFT and LoRA).
|
| 13 |
+
|
| 14 |
+
Reference tokens are spatially unaligned with the target and ride the SEQUENCE axis instead; they
|
| 15 |
+
carry only the 128 real latent channels and are zero-padded to 512, which contributes exactly
|
| 16 |
+
what the base model would because the new columns start at zero.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn as nn
|
| 22 |
+
import torch.nn.functional as F
|
| 23 |
+
|
| 24 |
+
BASE_IN = 128 # 32 VAE latent channels x 2x2 patchify
|
| 25 |
+
MASK_IN = 256 # 8x8 pixel fold -> 64 channels at H/8, then 2x2 patchify
|
| 26 |
+
FILL_IN = BASE_IN + BASE_IN + MASK_IN # 512
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def widen_x_embedder(transformer, new_in: int = FILL_IN) -> nn.Linear:
|
| 30 |
+
"""Grow ``transformer.x_embedder`` in place (old columns kept, new ones zero). Idempotent.
|
| 31 |
+
|
| 32 |
+
Also records the new width in the model config (``in_channels=512, out_channels=128``) so a
|
| 33 |
+
full checkpoint written with ``save_pretrained`` reloads with ``from_pretrained`` directly.
|
| 34 |
+
"""
|
| 35 |
+
old: nn.Linear = transformer.x_embedder
|
| 36 |
+
if old.in_features == new_in:
|
| 37 |
+
return old
|
| 38 |
+
if old.in_features != BASE_IN:
|
| 39 |
+
raise ValueError(f"x_embedder has {old.in_features} inputs, expected {BASE_IN} or {new_in}")
|
| 40 |
+
new = nn.Linear(new_in, old.out_features, bias=old.bias is not None,
|
| 41 |
+
device=old.weight.device, dtype=old.weight.dtype)
|
| 42 |
+
with torch.no_grad():
|
| 43 |
+
new.weight.zero_()
|
| 44 |
+
new.weight[:, :BASE_IN].copy_(old.weight)
|
| 45 |
+
if old.bias is not None:
|
| 46 |
+
new.bias.copy_(old.bias)
|
| 47 |
+
transformer.x_embedder = new
|
| 48 |
+
out_ch = transformer.config.out_channels or transformer.config.in_channels
|
| 49 |
+
transformer.register_to_config(in_channels=new_in, out_channels=out_ch)
|
| 50 |
+
return new
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def fold_mask_to_latent(mask: torch.Tensor, vae_scale: int = 8) -> torch.Tensor:
|
| 54 |
+
"""``(B, 1, H, W)`` mask in [0, 1] -> ``(B, 256, H/16, W/16)``.
|
| 55 |
+
|
| 56 |
+
The mask is not interpolated: each 8x8 pixel block is folded into 64 channels (as in FLUX
|
| 57 |
+
Fill) so sub-latent edge precision survives, then the same 2x2 patchify as the latents.
|
| 58 |
+
"""
|
| 59 |
+
b, c, h, w = mask.shape
|
| 60 |
+
if c != 1:
|
| 61 |
+
raise ValueError(f"expected a single-channel mask, got {c}")
|
| 62 |
+
if h % (vae_scale * 2) or w % (vae_scale * 2):
|
| 63 |
+
raise ValueError(f"mask {h}x{w} must be a multiple of {vae_scale * 2}")
|
| 64 |
+
lh, lw = h // vae_scale, w // vae_scale
|
| 65 |
+
m = mask.view(b, lh, vae_scale, lw, vae_scale)
|
| 66 |
+
m = m.permute(0, 2, 4, 1, 3).reshape(b, vae_scale * vae_scale, lh, lw)
|
| 67 |
+
m = m.view(b, vae_scale * vae_scale, lh // 2, 2, lw // 2, 2)
|
| 68 |
+
return m.permute(0, 1, 3, 5, 2, 4).reshape(b, vae_scale * vae_scale * 4, lh // 2, lw // 2)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def pad_condition_tokens(packed: torch.Tensor, new_in: int = FILL_IN) -> torch.Tensor:
|
| 72 |
+
"""Zero-pad ``(B, N, 128)`` reference tokens to the widened embedding width."""
|
| 73 |
+
if packed.shape[-1] == new_in:
|
| 74 |
+
return packed
|
| 75 |
+
return F.pad(packed, (0, new_in - packed.shape[-1]))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def pack(latents: torch.Tensor) -> torch.Tensor:
|
| 79 |
+
"""``(B, C, h, w)`` -> ``(B, h*w, C)`` tokens (same as ``Flux2KleinPipeline._pack_latents``)."""
|
| 80 |
+
b, c, h, w = latents.shape
|
| 81 |
+
return latents.reshape(b, c, h * w).permute(0, 2, 1)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def unpack(tokens: torch.Tensor, h: int, w: int) -> torch.Tensor:
|
| 85 |
+
"""Inverse of :func:`pack`."""
|
| 86 |
+
b, n, c = tokens.shape
|
| 87 |
+
return tokens.permute(0, 2, 1).reshape(b, c, h, w)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def fill_tokens(noisy: torch.Tensor, cond: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
|
| 91 |
+
"""Target tokens ``[noisy | cond | mask]`` from latents ``(B,128,h,w)`` and pixel mask ``(B,1,H,W)``."""
|
| 92 |
+
return torch.cat([pack(noisy), pack(cond), pack(fold_mask_to_latent(mask.to(cond.dtype)))], dim=-1)
|
robotryon/image/model.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build, save and load the keyframe-translation model f_theta (FLUX.2 [klein] 9B Base).
|
| 2 |
+
|
| 3 |
+
Two training modes share everything but the trainable set:
|
| 4 |
+
|
| 5 |
+
* ``fft``: the whole transformer is trained (FSDP, paper Table 4). Checkpoint:
|
| 6 |
+
``<ckpt>/transformer/`` written by ``save_pretrained`` (sharded safetensors, config records the
|
| 7 |
+
widened ``in_channels=512``).
|
| 8 |
+
* ``lora``: LoRA adapters on the attention / fused QKV-MLP projections of every block plus the
|
| 9 |
+
widened ``x_embedder`` trained in full. Checkpoint: ``<ckpt>/pytorch_lora_weights.safetensors``
|
| 10 |
+
(diffusers LoRA format) and ``<ckpt>/x_embedder.safetensors``.
|
| 11 |
+
|
| 12 |
+
``load_pipeline`` recognises either checkpoint type.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
from safetensors.torch import load_file, save_file
|
| 20 |
+
|
| 21 |
+
from .fill_channels import FILL_IN, widen_x_embedder
|
| 22 |
+
|
| 23 |
+
LORA_FILE = "pytorch_lora_weights.safetensors"
|
| 24 |
+
X_EMBEDDER_FILE = "x_embedder.safetensors"
|
| 25 |
+
NUM_SINGLE_BLOCKS = 24
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def lora_targets() -> list[str]:
|
| 29 |
+
"""Attention projections of the double-stream blocks, the fused QKV+MLP input projection of
|
| 30 |
+
the single-stream blocks, and the single-stream attention output projections."""
|
| 31 |
+
return (["to_k", "to_q", "to_v", "to_out.0", "to_qkv_mlp_proj"]
|
| 32 |
+
+ [f"single_transformer_blocks.{i}.attn.to_out" for i in range(NUM_SINGLE_BLOCKS)])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_transformer(base: str, dtype: torch.dtype):
|
| 36 |
+
from diffusers import Flux2Transformer2DModel
|
| 37 |
+
return Flux2Transformer2DModel.from_pretrained(base, subfolder="transformer", torch_dtype=dtype)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def build_trainable(base: str, mode: str, lora_cfg=None):
|
| 41 |
+
"""Transformer ready for training (x_embedder widened, trainable set marked).
|
| 42 |
+
|
| 43 |
+
fft: loaded in fp32 (FSDP keeps fp32 master shards and computes in bf16), all parameters
|
| 44 |
+
trainable. lora: base weights frozen in bf16, adapters and x_embedder trainable in fp32.
|
| 45 |
+
"""
|
| 46 |
+
if mode == "fft":
|
| 47 |
+
tr = load_transformer(base, torch.float32)
|
| 48 |
+
widen_x_embedder(tr, FILL_IN)
|
| 49 |
+
tr.requires_grad_(True)
|
| 50 |
+
elif mode == "lora":
|
| 51 |
+
from peft import LoraConfig
|
| 52 |
+
tr = load_transformer(base, torch.bfloat16)
|
| 53 |
+
tr.requires_grad_(False)
|
| 54 |
+
tr.add_adapter(LoraConfig(r=int(lora_cfg.rank), lora_alpha=int(lora_cfg.alpha),
|
| 55 |
+
lora_dropout=float(lora_cfg.get("dropout", 0.0)),
|
| 56 |
+
init_lora_weights="gaussian", target_modules=lora_targets()))
|
| 57 |
+
widen_x_embedder(tr, FILL_IN).requires_grad_(True)
|
| 58 |
+
for p in tr.parameters():
|
| 59 |
+
if p.requires_grad:
|
| 60 |
+
p.data = p.data.float()
|
| 61 |
+
else:
|
| 62 |
+
raise ValueError(f"train.mode must be 'fft' or 'lora', got {mode!r}")
|
| 63 |
+
return tr
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def save_lora(transformer, out_dir: str | Path) -> None:
|
| 67 |
+
"""LoRA tensors in diffusers format plus the full widened x_embedder."""
|
| 68 |
+
from diffusers import Flux2KleinPipeline
|
| 69 |
+
from peft.utils import get_peft_model_state_dict
|
| 70 |
+
out_dir = Path(out_dir)
|
| 71 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
lora = get_peft_model_state_dict(transformer)
|
| 73 |
+
lora = {k: v.detach().to(torch.bfloat16).cpu().contiguous() for k, v in lora.items()}
|
| 74 |
+
Flux2KleinPipeline.save_lora_weights(out_dir, transformer_lora_layers=lora)
|
| 75 |
+
save_file({"x_embedder.weight": transformer.x_embedder.weight.detach().float().cpu().contiguous()},
|
| 76 |
+
str(out_dir / X_EMBEDDER_FILE))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def save_full(transformer, out_dir: str | Path, state_dict: dict | None = None) -> None:
|
| 80 |
+
"""Full transformer as a diffusers model directory ``<out_dir>/transformer`` (bf16 shards).
|
| 81 |
+
|
| 82 |
+
``state_dict`` is the gathered full state dict when training under FSDP (only the main
|
| 83 |
+
process calls this); the unwrapped module supplies the config.
|
| 84 |
+
"""
|
| 85 |
+
import json
|
| 86 |
+
from huggingface_hub import split_torch_state_dict_into_shards
|
| 87 |
+
d = Path(out_dir) / "transformer"
|
| 88 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
sd = state_dict if state_dict is not None else transformer.state_dict()
|
| 90 |
+
sd = {k: v.detach().to(torch.bfloat16).cpu().contiguous() for k, v in sd.items()}
|
| 91 |
+
split = split_torch_state_dict_into_shards(
|
| 92 |
+
sd, filename_pattern="diffusion_pytorch_model{suffix}.safetensors", max_shard_size="5GB")
|
| 93 |
+
for fname, keys in split.filename_to_tensors.items():
|
| 94 |
+
save_file({k: sd[k] for k in keys}, str(d / fname), metadata={"format": "pt"})
|
| 95 |
+
if split.is_sharded:
|
| 96 |
+
index = {"metadata": split.metadata, "weight_map": split.tensor_to_filename}
|
| 97 |
+
(d / "diffusion_pytorch_model.safetensors.index.json").write_text(json.dumps(index, indent=2))
|
| 98 |
+
transformer.save_config(d)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def checkpoint_kind(ckpt: str | Path) -> str:
|
| 102 |
+
ckpt = Path(ckpt)
|
| 103 |
+
if (ckpt / LORA_FILE).exists():
|
| 104 |
+
return "lora"
|
| 105 |
+
if (ckpt / "transformer" / "config.json").exists():
|
| 106 |
+
return "fft"
|
| 107 |
+
raise FileNotFoundError(f"{ckpt} holds neither {LORA_FILE} nor transformer/config.json")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def load_pipeline(base: str, ckpt: str | Path, device: str = "cuda", text_encoder: bool = True):
|
| 111 |
+
"""``Flux2KleinPipeline`` in bf16 with the trained keyframe-translation weights.
|
| 112 |
+
|
| 113 |
+
``text_encoder=False`` skips the text encoder (prompts encoded beforehand).
|
| 114 |
+
|
| 115 |
+
LoRA: widen ``x_embedder`` BEFORE loading the adapter (it was fitted to the 512-wide
|
| 116 |
+
embedding), copy the trained embedding, then load the adapter unfused.
|
| 117 |
+
"""
|
| 118 |
+
from diffusers import Flux2KleinPipeline
|
| 119 |
+
ckpt = Path(ckpt)
|
| 120 |
+
kind = checkpoint_kind(ckpt)
|
| 121 |
+
kw = {} if text_encoder else {"text_encoder": None, "tokenizer": None}
|
| 122 |
+
if kind == "fft":
|
| 123 |
+
from diffusers import Flux2Transformer2DModel
|
| 124 |
+
tr = Flux2Transformer2DModel.from_pretrained(ckpt / "transformer", torch_dtype=torch.bfloat16)
|
| 125 |
+
pipe = Flux2KleinPipeline.from_pretrained(base, transformer=tr, torch_dtype=torch.bfloat16, **kw)
|
| 126 |
+
pipe.to(device)
|
| 127 |
+
else:
|
| 128 |
+
pipe = Flux2KleinPipeline.from_pretrained(base, torch_dtype=torch.bfloat16, **kw).to(device)
|
| 129 |
+
widen_x_embedder(pipe.transformer, FILL_IN)
|
| 130 |
+
w = load_file(str(ckpt / X_EMBEDDER_FILE))["x_embedder.weight"]
|
| 131 |
+
with torch.no_grad():
|
| 132 |
+
pipe.transformer.x_embedder.weight.copy_(w.to(pipe.transformer.x_embedder.weight.dtype))
|
| 133 |
+
pipe.load_lora_weights(str(ckpt), weight_name=LORA_FILE)
|
| 134 |
+
if pipe.transformer.x_embedder.in_features != FILL_IN:
|
| 135 |
+
raise ValueError(f"{ckpt}: x_embedder has {pipe.transformer.x_embedder.in_features} inputs")
|
| 136 |
+
pipe.transformer.requires_grad_(False)
|
| 137 |
+
return pipe
|
| 138 |
+
|
robotryon/image/sampler.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inpainting sampler for keyframe translation (true classifier-free guidance, Euler flow matching).
|
| 2 |
+
|
| 3 |
+
``Flux2KleinPipeline.__call__`` cannot express channel-concatenated conditioning, so the denoising
|
| 4 |
+
loop is written out here. The token layout must match training exactly (see ``fill_channels`` and
|
| 5 |
+
``stereo_ids``)::
|
| 6 |
+
|
| 7 |
+
[ target_v (T=0, L=v): noisy | conditioned frame | mask ] for every view v
|
| 8 |
+
[ reference_v (T=10, L=v): 128 latent channels, zero-padded ] for every view v
|
| 9 |
+
|
| 10 |
+
The schedule is the pipeline's: ``sigmas = linspace(1, 1/steps, steps)`` shifted by the empirical
|
| 11 |
+
``mu`` of the total target token count (both views for stereo). FLUX.2 [klein] Base has no guidance
|
| 12 |
+
embedding, so guidance is true CFG against the empty prompt:
|
| 13 |
+
``v = v_uncond + s * (v_cond - v_uncond)``.
|
| 14 |
+
|
| 15 |
+
The sampler returns the final latent TOKENS. In autoregressive inference these tokens are fed
|
| 16 |
+
directly as the next keyframe's reference tokens, without decoding and re-encoding (App. B.2.3).
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
|
| 25 |
+
from .fill_channels import BASE_IN, FILL_IN, fold_mask_to_latent, pack, pad_condition_tokens, unpack
|
| 26 |
+
from .stereo_ids import fill_ids
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class LatentCodec:
|
| 30 |
+
"""Pixels <-> normalised, 2x2-patchified FLUX.2 VAE latents ``(1, 128, H/16, W/16)``."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, vae, device):
|
| 33 |
+
self.vae, self.device = vae, device
|
| 34 |
+
self.mean = vae.bn.running_mean.view(1, -1, 1, 1).to(device)
|
| 35 |
+
self.std = torch.sqrt(vae.bn.running_var.view(1, -1, 1, 1) + vae.config.batch_norm_eps).to(device)
|
| 36 |
+
|
| 37 |
+
@staticmethod
|
| 38 |
+
def to_tensor(img: np.ndarray) -> torch.Tensor:
|
| 39 |
+
"""uint8 ``(H, W, 3)`` -> float ``(1, 3, H, W)`` in [-1, 1]."""
|
| 40 |
+
return torch.from_numpy(np.asarray(img).astype(np.float32) / 127.5 - 1).permute(2, 0, 1)[None]
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
def patchify(lat: torch.Tensor) -> torch.Tensor:
|
| 44 |
+
b, c, h, w = lat.shape
|
| 45 |
+
lat = lat.view(b, c, h // 2, 2, w // 2, 2).permute(0, 1, 3, 5, 2, 4)
|
| 46 |
+
return lat.reshape(b, c * 4, h // 2, w // 2)
|
| 47 |
+
|
| 48 |
+
@staticmethod
|
| 49 |
+
def unpatchify(lat: torch.Tensor) -> torch.Tensor:
|
| 50 |
+
b, c, h, w = lat.shape
|
| 51 |
+
lat = lat.reshape(b, c // 4, 2, 2, h, w).permute(0, 1, 4, 2, 5, 3)
|
| 52 |
+
return lat.reshape(b, c // 4, h * 2, w * 2)
|
| 53 |
+
|
| 54 |
+
def encode_tensor(self, px: torch.Tensor) -> torch.Tensor:
|
| 55 |
+
lat = self.vae.encode(px.to(self.device, self.vae.dtype)).latent_dist.mode()
|
| 56 |
+
return (self.patchify(lat) - self.mean) / self.std
|
| 57 |
+
|
| 58 |
+
def encode(self, img: np.ndarray) -> torch.Tensor:
|
| 59 |
+
return self.encode_tensor(self.to_tensor(img))
|
| 60 |
+
|
| 61 |
+
def decode_tokens(self, tokens: torch.Tensor, h: int, w: int) -> np.ndarray:
|
| 62 |
+
"""``(1, h*w, 128)`` tokens -> uint8 ``(16h, 16w, 3)`` image."""
|
| 63 |
+
lat = unpack(tokens, h, w) * self.std + self.mean
|
| 64 |
+
img = self.vae.decode(self.unpatchify(lat).to(self.vae.dtype), return_dict=False)[0]
|
| 65 |
+
return ((img[0].float().permute(1, 2, 0).clamp(-1, 1).cpu().numpy() + 1) * 127.5).astype(np.uint8)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class ViewCondition:
|
| 70 |
+
"""Conditioning of one camera view.
|
| 71 |
+
|
| 72 |
+
cond_latent: ``(1, 128, h, w)`` latent of the conditioned frame Phi(o; M, S).
|
| 73 |
+
mask: ``(1, 1, 16h, 16w)`` pixel mask in [0, 1].
|
| 74 |
+
reference_tokens: ``(1, h*w, 128)`` reference tokens (an encoded image, or the previous
|
| 75 |
+
keyframe's denoised tokens).
|
| 76 |
+
"""
|
| 77 |
+
cond_latent: torch.Tensor
|
| 78 |
+
mask: torch.Tensor
|
| 79 |
+
reference_tokens: torch.Tensor
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@torch.no_grad()
|
| 83 |
+
def denoise(transformer, scheduler, views: list[ViewCondition], prompt_embeds, text_ids,
|
| 84 |
+
neg_embeds=None, neg_text_ids=None, steps: int = 30, guidance_scale: float = 3.5,
|
| 85 |
+
generator: torch.Generator | None = None) -> list[torch.Tensor]:
|
| 86 |
+
"""Jointly denoise one or more views. Returns one ``(1, h*w, 128)`` token tensor per view."""
|
| 87 |
+
from diffusers.pipelines.flux2.pipeline_flux2_klein import compute_empirical_mu
|
| 88 |
+
device, dtype = prompt_embeds.device, transformer.dtype
|
| 89 |
+
n = len(views)
|
| 90 |
+
_, _, h, w = views[0].cond_latent.shape
|
| 91 |
+
S = h * w
|
| 92 |
+
cond = [torch.cat([pack(v.cond_latent.float()),
|
| 93 |
+
pack(fold_mask_to_latent(v.mask.to(device, torch.float32)))], dim=-1).to(dtype)
|
| 94 |
+
for v in views]
|
| 95 |
+
refs = [pad_condition_tokens(v.reference_tokens.to(dtype), FILL_IN) for v in views]
|
| 96 |
+
ids = fill_ids(h, w, n).to(device)
|
| 97 |
+
lats = [torch.randn((1, S, BASE_IN), generator=generator, device=device, dtype=dtype)
|
| 98 |
+
for _ in range(n)]
|
| 99 |
+
|
| 100 |
+
use_cfg = guidance_scale is not None and guidance_scale > 1.0 and neg_embeds is not None
|
| 101 |
+
sigmas = np.linspace(1.0, 1 / steps, steps)
|
| 102 |
+
scheduler.set_timesteps(sigmas=sigmas, device=device, mu=compute_empirical_mu(S * n, steps))
|
| 103 |
+
scheduler.set_begin_index(0)
|
| 104 |
+
for t in scheduler.timesteps:
|
| 105 |
+
hidden = torch.cat([torch.cat([lats[i], cond[i]], dim=-1) for i in range(n)] + refs, dim=1)
|
| 106 |
+
tt = t.expand(1).to(dtype) / 1000
|
| 107 |
+
pred = transformer(hidden_states=hidden, timestep=tt, guidance=None,
|
| 108 |
+
encoder_hidden_states=prompt_embeds, txt_ids=text_ids, img_ids=ids,
|
| 109 |
+
return_dict=False)[0]
|
| 110 |
+
if use_cfg:
|
| 111 |
+
neg = transformer(hidden_states=hidden, timestep=tt, guidance=None,
|
| 112 |
+
encoder_hidden_states=neg_embeds, txt_ids=neg_text_ids, img_ids=ids,
|
| 113 |
+
return_dict=False)[0]
|
| 114 |
+
pred = neg + guidance_scale * (pred - neg)
|
| 115 |
+
# One Euler step per view against the same t: rewind the scheduler's step counter.
|
| 116 |
+
idx = scheduler.step_index
|
| 117 |
+
for i in range(n):
|
| 118 |
+
scheduler._step_index = idx
|
| 119 |
+
lats[i] = scheduler.step(pred[:, i * S:(i + 1) * S], t, lats[i],
|
| 120 |
+
return_dict=False)[0].to(dtype)
|
| 121 |
+
return lats
|
robotryon/image/stereo_ids.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RoPE position ids for one or two camera views in one token sequence.
|
| 2 |
+
|
| 3 |
+
FLUX.2 gives every image token a 4D position ``(T, H, W, L)``. The pipeline sets ``L = 0`` for all
|
| 4 |
+
image tokens (only text tokens use it), so for images ``L`` is a free axis; stereo generation
|
| 5 |
+
uses it as the camera-view index. ``T`` keeps its learned meaning: targets at ``T = 0`` and the
|
| 6 |
+
reference at ``T = 10``. The token order is::
|
| 7 |
+
|
| 8 |
+
[target_v0, target_v1, ..., reference_v0, reference_v1, ...]
|
| 9 |
+
|
| 10 |
+
With one view this is byte-identical to the stock ``_prepare_latent_ids`` + ``_prepare_image_ids``.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
TARGET_T = 0
|
| 17 |
+
REFERENCE_T = 10
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def view_image_ids(sizes: list[tuple[int, int]], view_ids: list[int],
|
| 21 |
+
t_coords: list[int]) -> torch.Tensor:
|
| 22 |
+
"""``sizes[i] = (h, w)`` token grid of image i -> ``(1, sum h*w, 4)`` int64 position ids."""
|
| 23 |
+
if not (len(sizes) == len(view_ids) == len(t_coords)):
|
| 24 |
+
raise ValueError("one size, view id and t coordinate per image")
|
| 25 |
+
out = [torch.cartesian_prod(torch.tensor([int(t)]), torch.arange(h), torch.arange(w),
|
| 26 |
+
torch.tensor([int(v)]))
|
| 27 |
+
for (h, w), v, t in zip(sizes, view_ids, t_coords)]
|
| 28 |
+
return torch.cat(out, dim=0).unsqueeze(0)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def fill_ids(h: int, w: int, n_views: int) -> torch.Tensor:
|
| 32 |
+
"""Ids for ``n_views`` targets followed by ``n_views`` references, all on an ``h x w`` grid."""
|
| 33 |
+
return view_image_ids([(h, w)] * (2 * n_views), list(range(n_views)) * 2,
|
| 34 |
+
[TARGET_T] * n_views + [REFERENCE_T] * n_views)
|
robotryon/image/text.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The on-disk text-embedding cache of the image model's prompts (``robotryon.captions``).
|
| 2 |
+
|
| 3 |
+
Qwen3 prompt embeddings are ``(512, 12288)`` per prompt, too large to keep for every prompt in
|
| 4 |
+
memory, so they are computed once at trainer start-up and stored as one safetensors file per
|
| 5 |
+
prompt, named by a hash of the text.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from safetensors.torch import load_file, save_file
|
| 14 |
+
|
| 15 |
+
MAX_SEQUENCE_LENGTH = 512
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TextEmbedCache:
|
| 19 |
+
"""One ``<sha1>.safetensors`` file per prompt holding ``prompt_embeds`` ``(L, D)``."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, root: str | Path):
|
| 22 |
+
self.root = Path(root)
|
| 23 |
+
|
| 24 |
+
def path(self, prompt: str) -> Path:
|
| 25 |
+
return self.root / f"{hashlib.sha1(prompt.encode('utf-8')).hexdigest()}.safetensors"
|
| 26 |
+
|
| 27 |
+
def missing(self, prompts) -> list[str]:
|
| 28 |
+
return sorted({p for p in prompts if not self.path(p).exists()})
|
| 29 |
+
|
| 30 |
+
def load(self, prompt: str) -> torch.Tensor:
|
| 31 |
+
p = self.path(prompt)
|
| 32 |
+
if not p.exists():
|
| 33 |
+
raise FileNotFoundError(f"no cached embedding for prompt {prompt!r}")
|
| 34 |
+
return load_file(str(p))["prompt_embeds"]
|
| 35 |
+
|
| 36 |
+
@torch.no_grad()
|
| 37 |
+
def fill(self, text_pipe, prompts, device, batch_size: int = 16) -> int:
|
| 38 |
+
"""Encode and store the given prompts with a ``Flux2KleinPipeline`` holding the text encoder."""
|
| 39 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
prompts = list(prompts)
|
| 41 |
+
for i in range(0, len(prompts), batch_size):
|
| 42 |
+
chunk = prompts[i:i + batch_size]
|
| 43 |
+
emb, _ = text_pipe.encode_prompt(prompt=chunk, device=device,
|
| 44 |
+
max_sequence_length=MAX_SEQUENCE_LENGTH)
|
| 45 |
+
for p, e in zip(chunk, emb):
|
| 46 |
+
tmp = self.path(p).with_suffix(".tmp")
|
| 47 |
+
save_file({"prompt_embeds": e.to(torch.bfloat16).contiguous().cpu()}, str(tmp))
|
| 48 |
+
tmp.replace(self.path(p))
|
| 49 |
+
return len(prompts)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def text_ids_for(prompt_embeds: torch.Tensor) -> torch.Tensor:
|
| 53 |
+
"""``(B, L, D)`` -> ``(B, L, 4)`` text position ids (same as ``Flux2KleinPipeline._prepare_text_ids``)."""
|
| 54 |
+
b, n, _ = prompt_embeds.shape
|
| 55 |
+
ids = torch.cartesian_prod(torch.arange(1), torch.arange(1), torch.arange(1), torch.arange(n))
|
| 56 |
+
return ids.unsqueeze(0).expand(b, -1, -1).to(prompt_embeds.device)
|
robotryon/image/training.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flow-matching objective of the keyframe-translation model (paper Eq. 3.1).
|
| 2 |
+
|
| 3 |
+
With ``z = E_img(o_t)`` and ``z_tau = (1 - tau) z + tau eps``, the transformer predicts
|
| 4 |
+
``eps - z`` from the target tokens ``[z_tau | E_img(Phi(o_t; M_t, S_t)) | fold(M_t)]`` and the
|
| 5 |
+
reference tokens ``E_img(o_t')``. ``tau`` is drawn uniformly from the base model's training
|
| 6 |
+
sigma grid, one value per sample shared by its views. For stereo samples the loss is the mean
|
| 7 |
+
over the two views.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
from .fill_channels import FILL_IN, fill_tokens, pack, pad_condition_tokens, unpack
|
| 14 |
+
from .sampler import LatentCodec
|
| 15 |
+
from .stereo_ids import fill_ids
|
| 16 |
+
from .text import text_ids_for
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def flow_matching_loss(transformer, codec: LatentCodec, sched_sigmas: torch.Tensor,
|
| 20 |
+
sched_timesteps: torch.Tensor, batch: dict, device) -> torch.Tensor:
|
| 21 |
+
B, V = batch["target"].shape[:2]
|
| 22 |
+
with torch.no_grad():
|
| 23 |
+
enc = [{k: codec.encode_tensor(batch[k][:, v].to(device))
|
| 24 |
+
for k in ("target", "cond", "reference")} for v in range(V)]
|
| 25 |
+
z0 = enc[0]["target"]
|
| 26 |
+
_, _, h, w = z0.shape
|
| 27 |
+
n = sched_timesteps.numel()
|
| 28 |
+
idx = (torch.rand(B) * n).long().clamp(max=n - 1)
|
| 29 |
+
sigma = sched_sigmas[idx].to(device=device, dtype=z0.dtype).view(B, 1, 1, 1)
|
| 30 |
+
timestep = sched_timesteps[idx].to(device)
|
| 31 |
+
|
| 32 |
+
targets, tokens = [], []
|
| 33 |
+
for v, e in enumerate(enc):
|
| 34 |
+
noise = torch.randn_like(e["target"])
|
| 35 |
+
noisy = (1.0 - sigma) * e["target"] + sigma * noise
|
| 36 |
+
tokens.append(fill_tokens(noisy, e["cond"], batch["mask"][:, v].to(device)))
|
| 37 |
+
targets.append(noise - e["target"])
|
| 38 |
+
tokens += [pad_condition_tokens(pack(e["reference"]), FILL_IN) for e in enc]
|
| 39 |
+
hidden = torch.cat(tokens, dim=1).to(torch.bfloat16)
|
| 40 |
+
ids = fill_ids(h, w, V).to(device).expand(B, -1, -1)
|
| 41 |
+
prompt_embeds = batch["prompt_embeds"].to(device)
|
| 42 |
+
pred = transformer(hidden_states=hidden, timestep=timestep / 1000, guidance=None,
|
| 43 |
+
encoder_hidden_states=prompt_embeds, txt_ids=text_ids_for(prompt_embeds),
|
| 44 |
+
img_ids=ids, return_dict=False)[0]
|
| 45 |
+
S = h * w
|
| 46 |
+
losses = [((unpack(pred[:, v * S:(v + 1) * S], h, w).float() - targets[v].float()) ** 2)
|
| 47 |
+
.reshape(B, -1).mean(1).mean() for v in range(V)]
|
| 48 |
+
return sum(losses) / V
|
robotryon/masks/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shape-agnostic arm/hand masks for keyframe translation (paper Sec. 3.2, App. B.2.3).
|
| 2 |
+
|
| 3 |
+
The mask must hide the silhouette of whatever embodiment it covers, so it is never the raw
|
| 4 |
+
segmentation: every arm component becomes its convex hull, which is then dilated. During training
|
| 5 |
+
the dilation is random per arm, the manipulated object is cut out of the hole with some
|
| 6 |
+
probability, and the whole sample is jointly rescaled (field-of-view crop / pad). At inference the
|
| 7 |
+
dilation is fixed and the eroded object mask is always subtracted.
|
| 8 |
+
"""
|
| 9 |
+
from .hull import arm_hulls, fill_hulls, scale_hulls
|
| 10 |
+
from .augment import MaskAug, fov_augment, inference_mask
|
| 11 |
+
from .reference import robot_only
|
| 12 |
+
|
| 13 |
+
__all__ = ["arm_hulls", "fill_hulls", "scale_hulls", "MaskAug", "fov_augment", "inference_mask",
|
| 14 |
+
"robot_only"]
|
robotryon/masks/augment.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train-time mask randomisation, the fixed inference mask, and the joint field-of-view augmentation.
|
| 2 |
+
|
| 3 |
+
Training (``MaskAug.train_mask``):
|
| 4 |
+
* one random dilation per arm hull, so two arms in one frame already carry different
|
| 5 |
+
hole-to-arm ratios and the model cannot read the hand size off the hole;
|
| 6 |
+
* episodes flagged as "bonus" (thin human arms) get a raised dilation floor, because a hull
|
| 7 |
+
hugging a thin arm leaves no room for a thicker robot arm;
|
| 8 |
+
* with probability ``p_object_cut``, where an object mask exists, the eroded object is cut out
|
| 9 |
+
of the hole (a concave hole), which is what inference always does.
|
| 10 |
+
|
| 11 |
+
Inference (``inference_mask``): every hull dilated by a fixed kernel (the middle of the training
|
| 12 |
+
range plus the bonus), then the eroded object mask is subtracted so the object is preserved.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
import cv2
|
| 19 |
+
import numpy as np
|
| 20 |
+
from PIL import Image
|
| 21 |
+
|
| 22 |
+
from .hull import fill_hulls
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _ellipse(k: int) -> np.ndarray:
|
| 26 |
+
k = int(k) | 1
|
| 27 |
+
return cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class MaskAug:
|
| 32 |
+
dilate_min: int = 3
|
| 33 |
+
dilate_max: int = 71
|
| 34 |
+
bonus: int = 21 # added to the dilation floor for "bonus" (human) episodes
|
| 35 |
+
p_object_cut: float = 0.5
|
| 36 |
+
obj_erode_min: int = 3
|
| 37 |
+
obj_erode_max: int = 21
|
| 38 |
+
min_keep_frac: float = 0.4 # reject a cut that leaves less than this fraction of the hole
|
| 39 |
+
|
| 40 |
+
@classmethod
|
| 41 |
+
def from_config(cls, cfg) -> "MaskAug":
|
| 42 |
+
cfg = cfg or {}
|
| 43 |
+
return cls(**{k: cfg[k] for k in cls.__dataclass_fields__ if k in cfg})
|
| 44 |
+
|
| 45 |
+
def sample_dilate(self, rng: np.random.Generator, bonus: bool = False) -> int:
|
| 46 |
+
lo = self.dilate_min + (self.bonus if bonus else 0)
|
| 47 |
+
return int(rng.integers(lo, max(lo + 1, self.dilate_max) + 1)) | 1
|
| 48 |
+
|
| 49 |
+
def sample_obj_erode(self, rng: np.random.Generator) -> int:
|
| 50 |
+
return int(rng.integers(self.obj_erode_min, self.obj_erode_max + 1)) | 1
|
| 51 |
+
|
| 52 |
+
def infer_dilate(self) -> int:
|
| 53 |
+
"""Fixed inference dilation: middle of the training range plus the bonus."""
|
| 54 |
+
return (self.dilate_min + self.dilate_max) // 2 + self.bonus
|
| 55 |
+
|
| 56 |
+
def train_mask(self, polys: list[np.ndarray], width: int, height: int,
|
| 57 |
+
rng: np.random.Generator, bonus: bool = False,
|
| 58 |
+
object_mask: np.ndarray | None = None) -> tuple[np.ndarray, int]:
|
| 59 |
+
"""Randomised training hole. Returns ``(mask uint8 {0,1}, object erode kernel or 0)``."""
|
| 60 |
+
mask = np.zeros((height, width), np.uint8)
|
| 61 |
+
for one in fill_hulls(polys, width, height):
|
| 62 |
+
mask |= cv2.dilate(one, _ellipse(self.sample_dilate(rng, bonus)))
|
| 63 |
+
if object_mask is None:
|
| 64 |
+
return mask, 0
|
| 65 |
+
if float(rng.random()) >= self.p_object_cut:
|
| 66 |
+
return mask, 0
|
| 67 |
+
om = (np.asarray(object_mask) > 0).astype(np.uint8)
|
| 68 |
+
if om.sum() == 0:
|
| 69 |
+
return mask, 0
|
| 70 |
+
e = self.sample_obj_erode(rng)
|
| 71 |
+
om = cv2.erode(om, _ellipse(e))
|
| 72 |
+
cut = (mask.astype(bool) & ~om.astype(bool)).astype(np.uint8)
|
| 73 |
+
hull_px = int(mask.sum())
|
| 74 |
+
if cut.sum() > 0 and (hull_px == 0 or cut.sum() / hull_px > self.min_keep_frac):
|
| 75 |
+
return cut, e
|
| 76 |
+
return mask, 0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def inference_mask(polys: list[np.ndarray], width: int, height: int, dilate: int,
|
| 80 |
+
object_mask: np.ndarray | None = None, obj_erode: int = 9) -> np.ndarray:
|
| 81 |
+
"""Fixed-dilation hull mask minus the eroded object -> ``(H, W)`` uint8 {0, 1}."""
|
| 82 |
+
mask = np.zeros((height, width), np.uint8)
|
| 83 |
+
for one in fill_hulls(polys, width, height):
|
| 84 |
+
mask |= one
|
| 85 |
+
if mask.any():
|
| 86 |
+
mask = cv2.dilate(mask, _ellipse(dilate))
|
| 87 |
+
if object_mask is not None:
|
| 88 |
+
om = (np.asarray(object_mask) > 0).astype(np.uint8)
|
| 89 |
+
if obj_erode > 0:
|
| 90 |
+
om = cv2.erode(om, _ellipse(obj_erode))
|
| 91 |
+
mask = (mask.astype(bool) & ~om.astype(bool)).astype(np.uint8)
|
| 92 |
+
return mask
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def fov_augment(imgs: list[Image.Image], rng: np.random.Generator, crop_min: float,
|
| 96 |
+
scale_max: float = 1.0, flip_prob: float = 0.0) -> tuple[list[Image.Image], float]:
|
| 97 |
+
"""One shared field-of-view draw for every stream of a sample (all views included).
|
| 98 |
+
|
| 99 |
+
``scale ~ U[crop_min, max(1, scale_max)]``. Below 1 a ``scale``-sized window is cropped and
|
| 100 |
+
resized back; above 1 the image is pasted at a random offset onto a black canvas ``scale``
|
| 101 |
+
times larger and resized back, so the scene shrinks and black borders appear (the mask is
|
| 102 |
+
black there too: "not a hole"). The model thus learns that the camera's field of view is not
|
| 103 |
+
fixed.
|
| 104 |
+
"""
|
| 105 |
+
if not imgs:
|
| 106 |
+
return imgs, 1.0
|
| 107 |
+
w, h = imgs[0].size
|
| 108 |
+
if any(im.size != (w, h) for im in imgs):
|
| 109 |
+
raise ValueError("fov_augment needs equally sized images")
|
| 110 |
+
scale = float(rng.uniform(crop_min, max(1.0, float(scale_max))))
|
| 111 |
+
if scale > 1.0 + 1e-6:
|
| 112 |
+
cw, ch = int(round(w * scale)), int(round(h * scale))
|
| 113 |
+
left = int(rng.integers(0, cw - w + 1))
|
| 114 |
+
top = int(rng.integers(0, ch - h + 1))
|
| 115 |
+
flip = float(rng.random()) < flip_prob
|
| 116 |
+
out = []
|
| 117 |
+
for im in imgs:
|
| 118 |
+
canvas = Image.new(im.mode, (cw, ch), 0)
|
| 119 |
+
canvas.paste(im, (left, top))
|
| 120 |
+
im2 = canvas.resize((w, h), Image.LANCZOS)
|
| 121 |
+
out.append(im2.transpose(Image.FLIP_LEFT_RIGHT) if flip else im2)
|
| 122 |
+
return out, scale
|
| 123 |
+
cw, ch = max(8, int(round(w * scale))), max(8, int(round(h * scale)))
|
| 124 |
+
left = int(rng.integers(0, w - cw + 1)) if w > cw else 0
|
| 125 |
+
top = int(rng.integers(0, h - ch + 1)) if h > ch else 0
|
| 126 |
+
flip = float(rng.random()) < flip_prob
|
| 127 |
+
out = []
|
| 128 |
+
for im in imgs:
|
| 129 |
+
im2 = im.crop((left, top, left + cw, top + ch)).resize((w, h), Image.LANCZOS)
|
| 130 |
+
out.append(im2.transpose(Image.FLIP_LEFT_RIGHT) if flip else im2)
|
| 131 |
+
return out, scale
|
robotryon/masks/hull.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convex hull per arm.
|
| 2 |
+
|
| 3 |
+
A segmentation silhouette gives the embodiment away (a robot arm has cables and a different
|
| 4 |
+
outline than a human arm). Each connected component of the mask is therefore replaced by its
|
| 5 |
+
convex hull. Small gaps are closed before the component split, so a limb and a thin cable that
|
| 6 |
+
pinches to a one-pixel thread stay one component instead of producing a separate cable-shaped
|
| 7 |
+
polygon.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import cv2
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
PRE_DILATE = 9 # closing kernel before the component split (px at mask resolution)
|
| 15 |
+
MIN_COMPONENT_PX = 256 # smaller components are segmentation noise, not an arm
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def arm_hulls(mask: np.ndarray, pre_dilate: int = PRE_DILATE,
|
| 19 |
+
min_component_px: int = MIN_COMPONENT_PX) -> list[np.ndarray]:
|
| 20 |
+
"""Binary mask ``(H, W)`` -> list of ``(K, 2)`` float32 hull polygons in mask pixels."""
|
| 21 |
+
m = (np.asarray(mask) > 0).astype(np.uint8)
|
| 22 |
+
if m.sum() == 0:
|
| 23 |
+
return []
|
| 24 |
+
if pre_dilate > 0:
|
| 25 |
+
k = pre_dilate | 1
|
| 26 |
+
m = cv2.dilate(m, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)))
|
| 27 |
+
n, lab = cv2.connectedComponents(m)
|
| 28 |
+
polys = []
|
| 29 |
+
for i in range(1, n):
|
| 30 |
+
ys, xs = np.where(lab == i)
|
| 31 |
+
if ys.size < min_component_px:
|
| 32 |
+
continue
|
| 33 |
+
pts = np.stack([xs, ys], axis=1).astype(np.int32)
|
| 34 |
+
polys.append(cv2.convexHull(pts).reshape(-1, 2).astype(np.float32))
|
| 35 |
+
return polys
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def scale_hulls(polys: list[np.ndarray], sx: float, sy: float,
|
| 39 |
+
offset: tuple[float, float] = (0.0, 0.0)) -> list[np.ndarray]:
|
| 40 |
+
"""Map polygons from mask pixels to output pixels: ``(p - offset) * (sx, sy)``."""
|
| 41 |
+
off = np.asarray(offset, np.float32)
|
| 42 |
+
s = np.array([sx, sy], np.float32)
|
| 43 |
+
return [(p - off) * s for p in polys]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def fill_hulls(polys: list[np.ndarray], width: int, height: int) -> list[np.ndarray]:
|
| 47 |
+
"""Rasterise each polygon separately -> list of ``(H, W)`` uint8 {0, 1} masks."""
|
| 48 |
+
out = []
|
| 49 |
+
for p in polys:
|
| 50 |
+
one = np.zeros((height, width), np.uint8)
|
| 51 |
+
cv2.fillConvexPoly(one, p.astype(np.int32), 1)
|
| 52 |
+
out.append(one)
|
| 53 |
+
return out
|
robotryon/masks/reference.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Robot-only reference image: the robot's pixels on a plain white background.
|
| 2 |
+
|
| 3 |
+
The first keyframe of a demonstration has no translated predecessor, so its reference is a real
|
| 4 |
+
frame of the target robot with everything but the robot removed; this selects the embodiment.
|
| 5 |
+
Training uses the same construction for its robot-only exemplars.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import cv2
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
WHITE = 255
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def robot_only(image: np.ndarray, mask: np.ndarray, erode: int = 7,
|
| 17 |
+
background: int = WHITE) -> np.ndarray:
|
| 18 |
+
"""Keep the robot pixels of ``image`` (H, W, 3) where ``mask`` (H, W) is set.
|
| 19 |
+
|
| 20 |
+
The mask is eroded by ``erode`` px so no background leaks in at the silhouette; everything
|
| 21 |
+
else is filled with ``background``.
|
| 22 |
+
"""
|
| 23 |
+
m = np.asarray(mask).astype(bool)
|
| 24 |
+
if erode > 0:
|
| 25 |
+
k = int(erode) | 1
|
| 26 |
+
m = cv2.erode(m.astype(np.uint8), cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))) > 0
|
| 27 |
+
out = np.full_like(image, background)
|
| 28 |
+
out[m] = image[m]
|
| 29 |
+
return out
|
robotryon/skeleton/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .augment import SkeletonAugment
|
| 2 |
+
from .render import condition_frame, draw_skeleton, draw_skeleton_video, paint
|
| 3 |
+
from .topology import EDGES, KEYPOINT_NAMES, MEDIAPIPE21_TO_17, NUM_HANDS, NUM_KEYPOINTS
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"SkeletonAugment", "condition_frame", "draw_skeleton", "draw_skeleton_video", "paint",
|
| 7 |
+
"EDGES", "KEYPOINT_NAMES", "MEDIAPIPE21_TO_17", "NUM_HANDS", "NUM_KEYPOINTS",
|
| 8 |
+
]
|
robotryon/skeleton/augment.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training-time skeleton augmentation.
|
| 2 |
+
|
| 3 |
+
Human keypoints at inference come from a reconstructed and retargeted hand, which is imprecise.
|
| 4 |
+
To make the generative models treat the skeleton as a coarse guide rather than an exact pose,
|
| 5 |
+
robot keypoints are corrupted during training:
|
| 6 |
+
|
| 7 |
+
* each keypoint is displaced by up to half the length of the finger segment it belongs to,
|
| 8 |
+
* the whole skeleton is dropped with probability ``p_drop_all``,
|
| 9 |
+
* each keypoint is dropped independently with probability ``p_drop_keypoint``.
|
| 10 |
+
|
| 11 |
+
For video, the displacement is sampled once per clip and per keypoint and then held fixed in
|
| 12 |
+
time, so the skeleton flow stays temporally coherent; keypoint dropout is also per clip.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
from .topology import segment_length_per_keypoint
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class SkeletonAugment:
|
| 26 |
+
max_shift_frac: float = 0.5
|
| 27 |
+
p_drop_all: float = 0.2
|
| 28 |
+
p_drop_keypoint: float = 0.2
|
| 29 |
+
enabled: bool = True
|
| 30 |
+
|
| 31 |
+
@classmethod
|
| 32 |
+
def from_config(cls, cfg) -> "SkeletonAugment":
|
| 33 |
+
if cfg is None:
|
| 34 |
+
return cls(enabled=False)
|
| 35 |
+
return cls(**{k: cfg[k] for k in ("max_shift_frac", "p_drop_all", "p_drop_keypoint",
|
| 36 |
+
"enabled") if k in cfg})
|
| 37 |
+
|
| 38 |
+
def __call__(self, kp: np.ndarray, vis: np.ndarray, rng: np.random.Generator,
|
| 39 |
+
temporal: bool | None = None) -> tuple[np.ndarray, np.ndarray]:
|
| 40 |
+
"""Corrupt keypoints.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
kp: ``(H, 17, 2)`` for one frame or ``(T, H, 17, 2)`` for a clip, in pixels.
|
| 44 |
+
vis: matching visibility, ``(H, 17)`` or ``(T, H, 17)``.
|
| 45 |
+
rng: random generator.
|
| 46 |
+
temporal: treat the first axis as time. Inferred from ``kp.ndim == 4`` if omitted.
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
``(kp, vis)``: new arrays, the inputs are not modified.
|
| 50 |
+
"""
|
| 51 |
+
kp = np.array(kp, dtype=np.float32, copy=True)
|
| 52 |
+
vis = np.array(vis, dtype=bool, copy=True)
|
| 53 |
+
if not self.enabled:
|
| 54 |
+
return kp, vis
|
| 55 |
+
if temporal is None:
|
| 56 |
+
temporal = kp.ndim == 4
|
| 57 |
+
shape = kp.shape[1:-1] if temporal else kp.shape[:-1] # (H, 17)
|
| 58 |
+
|
| 59 |
+
if rng.random() < self.p_drop_all:
|
| 60 |
+
vis[...] = False
|
| 61 |
+
return kp, vis
|
| 62 |
+
|
| 63 |
+
# Segment length from the clip median, so a single bad frame cannot blow up the shift.
|
| 64 |
+
seg = segment_length_per_keypoint(kp, vis)
|
| 65 |
+
if temporal:
|
| 66 |
+
seg = np.median(np.where(vis, seg, np.nan), axis=0)
|
| 67 |
+
seg = np.nan_to_num(seg, nan=0.0)
|
| 68 |
+
radius = self.max_shift_frac * seg * np.sqrt(rng.random(shape)) # uniform in a disc
|
| 69 |
+
angle = rng.uniform(0.0, 2 * np.pi, shape)
|
| 70 |
+
shift = np.stack([radius * np.cos(angle), radius * np.sin(angle)], -1).astype(np.float32)
|
| 71 |
+
kp = kp + (shift[None] if temporal else shift)
|
| 72 |
+
|
| 73 |
+
keep = rng.random(shape) >= self.p_drop_keypoint
|
| 74 |
+
vis &= keep[None] if temporal else keep
|
| 75 |
+
return kp, vis
|
robotryon/skeleton/render.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Draw hand skeletons as 2D images.
|
| 2 |
+
|
| 3 |
+
The same renderer is used for every embodiment and for humans, so the skeleton carries pose
|
| 4 |
+
only and never reveals what kind of hand produced it.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import cv2
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from .topology import COLORS, EDGES, GROUP_OF
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def draw_skeleton(kp: np.ndarray, vis: np.ndarray, width: int, height: int,
|
| 16 |
+
thickness: int = 2, radius: int = 3, supersample: int = 2) -> np.ndarray:
|
| 17 |
+
"""Render one frame of hand skeletons on a black canvas.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
kp: ``(H, 17, 2)`` pixel coordinates.
|
| 21 |
+
vis: ``(H, 17)`` visibility. Invisible or non-finite keypoints are skipped together with
|
| 22 |
+
their edges.
|
| 23 |
+
width, height: canvas size in pixels.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
``(height, width, 3)`` uint8 RGB image.
|
| 27 |
+
"""
|
| 28 |
+
s = max(1, int(supersample))
|
| 29 |
+
canvas = np.zeros((height * s, width * s, 3), np.uint8)
|
| 30 |
+
# Same stroke convention as the original renderer: sizes are given on the supersampled
|
| 31 |
+
# canvas at half scale, so thickness=2, radius=3 is the production look.
|
| 32 |
+
lw, dr = max(1, thickness * s // 2), max(1, radius * s // 2)
|
| 33 |
+
kp = np.asarray(kp, np.float64)
|
| 34 |
+
vis = np.asarray(vis, bool) & np.isfinite(kp).all(-1)
|
| 35 |
+
for h in range(kp.shape[0]):
|
| 36 |
+
pts = np.round(kp[h] * s).astype(np.int64) if vis[h].any() else None
|
| 37 |
+
if pts is None:
|
| 38 |
+
continue
|
| 39 |
+
for a, b, g in EDGES:
|
| 40 |
+
if vis[h, a] and vis[h, b]:
|
| 41 |
+
cv2.line(canvas, tuple(pts[a]), tuple(pts[b]), COLORS[g], lw, cv2.LINE_AA)
|
| 42 |
+
for j in range(kp.shape[1]):
|
| 43 |
+
if vis[h, j]:
|
| 44 |
+
cv2.circle(canvas, tuple(pts[j]), dr, COLORS[GROUP_OF[j]], -1, cv2.LINE_AA)
|
| 45 |
+
if s > 1:
|
| 46 |
+
canvas = cv2.resize(canvas, (width, height), interpolation=cv2.INTER_AREA)
|
| 47 |
+
return canvas
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def draw_skeleton_video(kp: np.ndarray, vis: np.ndarray, width: int, height: int,
|
| 51 |
+
**kw) -> np.ndarray:
|
| 52 |
+
"""``(T, H, 17, 2)`` keypoints -> ``(T, height, width, 3)`` uint8 skeleton video."""
|
| 53 |
+
return np.stack([draw_skeleton(kp[t], vis[t], width, height, **kw)
|
| 54 |
+
for t in range(kp.shape[0])])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def paint(frame: np.ndarray, skeleton: np.ndarray) -> np.ndarray:
|
| 58 |
+
"""Overlay a rendered skeleton on a frame; skeleton pixels win wherever drawn."""
|
| 59 |
+
out = frame.copy()
|
| 60 |
+
lit = skeleton.max(axis=-1) > 0
|
| 61 |
+
out[lit] = skeleton[lit]
|
| 62 |
+
return out
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def condition_frame(frame: np.ndarray, mask: np.ndarray | None,
|
| 66 |
+
skeleton: np.ndarray) -> np.ndarray:
|
| 67 |
+
"""Phi(o; M, S) = ((1 - M) * o) (+) S: black out the mask region, then draw the skeleton.
|
| 68 |
+
|
| 69 |
+
The skeleton is drawn even slightly outside the mask so fingertips are never clipped.
|
| 70 |
+
``mask=None`` leaves the frame unmasked (mask-free inference).
|
| 71 |
+
"""
|
| 72 |
+
out = frame.copy()
|
| 73 |
+
if mask is not None:
|
| 74 |
+
out[np.asarray(mask).astype(bool)] = 0
|
| 75 |
+
return paint(out, skeleton)
|
robotryon/skeleton/topology.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hand keypoint topology shared by every embodiment.
|
| 2 |
+
|
| 3 |
+
Each hand has 17 keypoints in MediaPipe order with the four distal (DIP) joints removed:
|
| 4 |
+
|
| 5 |
+
0 wrist
|
| 6 |
+
1, 2, 3, 4 thumb: base (CMC), MCP, IP, tip
|
| 7 |
+
5, 6, 7 index: knuckle (MCP), middle (PIP), tip
|
| 8 |
+
8, 9, 10 middle
|
| 9 |
+
11, 12, 13 ring
|
| 10 |
+
14, 15, 16 little
|
| 11 |
+
|
| 12 |
+
Robot hands map their links onto the same 17 points, so hands with different degrees of
|
| 13 |
+
freedom share one skeleton. Arrays of keypoints use shape ``(..., 2, 17, D)``: hand 0 is the
|
| 14 |
+
left hand, hand 1 is the right hand.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
NUM_KEYPOINTS = 17
|
| 22 |
+
NUM_HANDS = 2
|
| 23 |
+
|
| 24 |
+
KEYPOINT_NAMES = (
|
| 25 |
+
"wrist",
|
| 26 |
+
"thumb_cmc", "thumb_mcp", "thumb_ip", "thumb_tip",
|
| 27 |
+
"index_mcp", "index_pip", "index_tip",
|
| 28 |
+
"middle_mcp", "middle_pip", "middle_tip",
|
| 29 |
+
"ring_mcp", "ring_pip", "ring_tip",
|
| 30 |
+
"little_mcp", "little_pip", "little_tip",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# MediaPipe-21 index of each of the 17 keypoints (the DIP joints 7/11/15/19 are dropped).
|
| 34 |
+
MEDIAPIPE21_TO_17 = (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 13, 14, 16, 17, 18, 20)
|
| 35 |
+
|
| 36 |
+
GROUP_OF = (
|
| 37 |
+
"palm",
|
| 38 |
+
"thumb", "thumb", "thumb", "thumb",
|
| 39 |
+
"index", "index", "index",
|
| 40 |
+
"middle", "middle", "middle",
|
| 41 |
+
"ring", "ring", "ring",
|
| 42 |
+
"little", "little", "little",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# (parent, child, group). The palm connects the wrist to the thumb base and the little knuckle
|
| 46 |
+
# and links the knuckles to one another; each finger is a chain from its knuckle to its tip.
|
| 47 |
+
EDGES: tuple[tuple[int, int, str], ...] = (
|
| 48 |
+
(0, 1, "palm"), (0, 5, "palm"), (5, 8, "palm"), (8, 11, "palm"), (11, 14, "palm"),
|
| 49 |
+
(0, 14, "palm"),
|
| 50 |
+
(1, 2, "thumb"), (2, 3, "thumb"), (3, 4, "thumb"),
|
| 51 |
+
(5, 6, "index"), (6, 7, "index"),
|
| 52 |
+
(8, 9, "middle"), (9, 10, "middle"),
|
| 53 |
+
(11, 12, "ring"), (12, 13, "ring"),
|
| 54 |
+
(14, 15, "little"), (15, 16, "little"),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
COLORS = {
|
| 58 |
+
"palm": (255, 170, 60), "thumb": (255, 232, 70), "index": (110, 235, 120),
|
| 59 |
+
"middle": (95, 205, 255), "ring": (185, 150, 255), "little": (255, 125, 205),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# Finger segments a keypoint belongs to, used to scale the training-time perturbation.
|
| 63 |
+
# A keypoint is displaced by at most half the length of the shortest finger segment it touches.
|
| 64 |
+
_FINGER_EDGES = tuple((a, b) for a, b, g in EDGES if g != "palm")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def segment_length_per_keypoint(kp: np.ndarray, vis: np.ndarray | None = None) -> np.ndarray:
|
| 68 |
+
"""Length of the finger segment each keypoint belongs to.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
kp: ``(..., 17, D)`` keypoints of one or more hands.
|
| 72 |
+
vis: optional ``(..., 17)`` visibility; invisible endpoints do not contribute.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
``(..., 17)`` lengths. The wrist uses its distance to the nearest knuckle. A keypoint
|
| 76 |
+
without any valid segment gets 0, so it is never displaced.
|
| 77 |
+
"""
|
| 78 |
+
kp = np.asarray(kp, dtype=np.float64)
|
| 79 |
+
if vis is None:
|
| 80 |
+
vis = np.isfinite(kp).all(-1)
|
| 81 |
+
out = np.full(kp.shape[:-1], np.inf)
|
| 82 |
+
edges = _FINGER_EDGES + ((0, 5), (0, 1))
|
| 83 |
+
for a, b in edges:
|
| 84 |
+
ok = vis[..., a] & vis[..., b]
|
| 85 |
+
d = np.linalg.norm(np.nan_to_num(kp[..., a, :] - kp[..., b, :]), axis=-1)
|
| 86 |
+
d = np.where(ok, d, np.inf)
|
| 87 |
+
out[..., a] = np.minimum(out[..., a], d)
|
| 88 |
+
out[..., b] = np.minimum(out[..., b], d)
|
| 89 |
+
out[~np.isfinite(out)] = 0.0
|
| 90 |
+
return out
|
robotryon/video/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Video model g_phi: an in-context LoRA on LTX-2.5 that synthesizes the robot video between
|
| 2 |
+
translated keyframes under the guidance of the hand-skeleton flow (paper Sec. 3.3).
|
| 3 |
+
|
| 4 |
+
Modules:
|
| 5 |
+
conditioning training sequence layout, keyframe sampling, flow-matching loss
|
| 6 |
+
dataset random training clips with on-the-fly skeleton rendering and augmentation
|
| 7 |
+
rope4d fourth RoPE axis distinguishing the two stereo views
|
| 8 |
+
pipeline diffusers pipeline subclass and the two-stage generation of one clip
|
| 9 |
+
chunks splitting long videos at keyframes and concatenating the clips
|
| 10 |
+
text caption embeddings (cached once; the text encoder is never co-resident)
|
| 11 |
+
training gradient checkpointing and LoRA weight I/O
|
| 12 |
+
io mp4 / png helpers
|
| 13 |
+
|
| 14 |
+
Heavy dependencies (torch, diffusers) are imported by the submodules that need them.
|
| 15 |
+
"""
|
robotryon/video/chunks.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Splitting a long demonstration into clips the video model can generate at once.
|
| 2 |
+
|
| 3 |
+
The video VAE takes clips of ``8k + 1`` frames, and the model is trained on clips of at most
|
| 4 |
+
145 frames (about 7 s at 20 fps). A longer demonstration is therefore cut at its keyframes:
|
| 5 |
+
each clip runs from one keyframe to a later one, holds every keyframe in between, and the next
|
| 6 |
+
clip starts at the keyframe the previous one ended on. Adjacent clips thus **share their
|
| 7 |
+
boundary keyframe**, so the arm pose cannot jump at the seam, and the concatenation drops the
|
| 8 |
+
duplicated boundary frame of every clip after the first.
|
| 9 |
+
|
| 10 |
+
A clip is ``8k + 1`` frames long, which usually reaches past its last keyframe; those trailing
|
| 11 |
+
frames are anchored to nothing and are discarded at concatenation. Two corner cases follow the
|
| 12 |
+
original generation code:
|
| 13 |
+
|
| 14 |
+
* two consecutive keyframes further apart than the cap: the clip is stretched past the cap
|
| 15 |
+
rather than leaving a hole in the video (logged as off-distribution);
|
| 16 |
+
* the end of the video leaves less than the minimum clip length: a short tail clip of at least
|
| 17 |
+
9 frames is generated instead of dropping the end of the demonstration, pulling the last
|
| 18 |
+
keyframe onto the clip's final frame if the ``8k + 1`` constraint requires it.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from dataclasses import asdict, dataclass
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
VAE_TEMPORAL = 8
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def allowed_frame_counts(lo: int, hi: int) -> list[int]:
|
| 31 |
+
"""Clip lengths in ``[lo, hi]`` the VAE accepts (``F = 8k + 1``)."""
|
| 32 |
+
return [f for f in range(lo, hi + 1) if (f - 1) % VAE_TEMPORAL == 0]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def next_8k1(n: int) -> int:
|
| 36 |
+
"""Smallest ``8k + 1`` that is >= n."""
|
| 37 |
+
return n if (n - 1) % VAE_TEMPORAL == 0 else n + (VAE_TEMPORAL - (n - 1) % VAE_TEMPORAL)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def prev_8k1(n: int) -> int:
|
| 41 |
+
"""Largest ``8k + 1`` that is <= n (0 if none)."""
|
| 42 |
+
return n - (n - 1) % VAE_TEMPORAL if n >= 1 else 0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def latent_index(pixel: int, num_frames: int) -> int:
|
| 46 |
+
"""Latent frame holding a pixel frame: latent 0 is pixel 0, latent j>0 is [8j-7, 8j]."""
|
| 47 |
+
lat_n = (num_frames - 1) // VAE_TEMPORAL + 1
|
| 48 |
+
return 0 if pixel == 0 else min((pixel + 7) // VAE_TEMPORAL, lat_n - 1)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class Chunk:
|
| 53 |
+
"""One generated clip: ``frames`` frames starting at ``start`` of the episode.
|
| 54 |
+
|
| 55 |
+
``kf_slots`` index into the episode's keyframe list, ``kf_local`` are the same keyframes
|
| 56 |
+
as frame indices inside the clip (first is always 0).
|
| 57 |
+
"""
|
| 58 |
+
start: int
|
| 59 |
+
frames: int
|
| 60 |
+
kf_slots: list[int]
|
| 61 |
+
kf_local: list[int]
|
| 62 |
+
|
| 63 |
+
def to_dict(self) -> dict:
|
| 64 |
+
return asdict(self)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _tail_chunk(kf: list[int], i: int, start: int, avail: int, log) -> Chunk | None:
|
| 68 |
+
"""A short last clip covering what is left after keyframe ``i``."""
|
| 69 |
+
F = prev_8k1(avail)
|
| 70 |
+
if F < 9:
|
| 71 |
+
return None
|
| 72 |
+
ids = [t for t in range(i, len(kf)) if kf[t] <= start + F - 1] or [i]
|
| 73 |
+
if ids[-1] + 1 < len(kf):
|
| 74 |
+
ids.append(ids[-1] + 1) # include the last keyframe, pulled to F-1
|
| 75 |
+
local = [min(kf[t] - start, F - 1) for t in ids]
|
| 76 |
+
if len(set(local)) < 2:
|
| 77 |
+
return None
|
| 78 |
+
log(f" tail clip: frames {start}..{start + F - 1} ({F}f, below the training minimum) "
|
| 79 |
+
f"keyframes {ids} @ {local}")
|
| 80 |
+
return Chunk(start, F, ids, local)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def plan_chunks(kf: list[int], total: int, frames_range=(49, 145), log=print) -> list[Chunk]:
|
| 84 |
+
"""Cut a ``total``-frame video with keyframes ``kf`` (sorted frame indices) into clips.
|
| 85 |
+
|
| 86 |
+
Each clip starts at a keyframe and ends at a later one, holding as many keyframes as fit
|
| 87 |
+
in ``frames_range[1]`` frames; its length is the smallest allowed ``8k + 1`` that covers
|
| 88 |
+
them (at least ``frames_range[0]``).
|
| 89 |
+
"""
|
| 90 |
+
choices = allowed_frame_counts(*frames_range)
|
| 91 |
+
lo, hi = choices[0], choices[-1]
|
| 92 |
+
out: list[Chunk] = []
|
| 93 |
+
i = 0
|
| 94 |
+
while i < len(kf) - 1:
|
| 95 |
+
j = i + 1
|
| 96 |
+
while j + 1 < len(kf) and (kf[j + 1] - kf[i] + 1) <= hi:
|
| 97 |
+
j += 1
|
| 98 |
+
span = kf[j] - kf[i] + 1
|
| 99 |
+
start = kf[i]
|
| 100 |
+
if span > hi:
|
| 101 |
+
# Two consecutive keyframes are further apart than the cap: stretch the clip.
|
| 102 |
+
F = next_8k1(span)
|
| 103 |
+
if start + F > total:
|
| 104 |
+
F = prev_8k1(total - start)
|
| 105 |
+
if F < lo:
|
| 106 |
+
tc = _tail_chunk(kf, i, start, total - start, log)
|
| 107 |
+
if tc is not None:
|
| 108 |
+
out.append(tc)
|
| 109 |
+
break
|
| 110 |
+
log(f" keyframes {i}->{j} are {span} frames apart, above the {hi}-frame cap: "
|
| 111 |
+
f"generating {F} frames")
|
| 112 |
+
out.append(Chunk(start, F, [i, j], [0, min(kf[j] - start, F - 1)]))
|
| 113 |
+
i = j
|
| 114 |
+
continue
|
| 115 |
+
avail = total - start
|
| 116 |
+
F = next((f for f in choices if f >= max(span, lo) and f <= avail), None)
|
| 117 |
+
if F is None:
|
| 118 |
+
# The video ends before a long-enough clip: keep the start (frame 0 of a clip is
|
| 119 |
+
# the substituted keyframe) and pull the last keyframe onto the clip's last frame.
|
| 120 |
+
F = max((f for f in choices if f <= avail), default=None)
|
| 121 |
+
if F is None or F < lo:
|
| 122 |
+
tc = _tail_chunk(kf, i, start, avail, log)
|
| 123 |
+
if tc is not None:
|
| 124 |
+
out.append(tc)
|
| 125 |
+
break
|
| 126 |
+
log(f" last clip: {span} -> {F} frames, last keyframe pulled to {F - 1}")
|
| 127 |
+
local = [min(k - start, F - 1) for k in kf[i:j + 1]]
|
| 128 |
+
out.append(Chunk(start, F, list(range(i, j + 1)), local))
|
| 129 |
+
i = j
|
| 130 |
+
return out
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def drop_colliding_keyframes(kf_local: list[int], num_frames: int) -> list[int]:
|
| 134 |
+
"""Positions (into ``kf_local``) to keep so no two keyframes share a latent frame.
|
| 135 |
+
|
| 136 |
+
Short clips can put two keyframes into the same latent slab (17 frames, keyframes at
|
| 137 |
+
[0, 11, 16] -> latents [0, 2, 2]), which the pipeline cannot represent. The first and last
|
| 138 |
+
keyframes are kept; colliding interior ones are dropped.
|
| 139 |
+
"""
|
| 140 |
+
if len(kf_local) <= 2:
|
| 141 |
+
return list(range(len(kf_local)))
|
| 142 |
+
last = latent_index(kf_local[-1], num_frames)
|
| 143 |
+
keep, seen = [], set()
|
| 144 |
+
for n, loc in enumerate(kf_local):
|
| 145 |
+
li = latent_index(loc, num_frames)
|
| 146 |
+
if n < len(kf_local) - 1 and (li in seen or li == last):
|
| 147 |
+
continue
|
| 148 |
+
seen.add(li)
|
| 149 |
+
keep.append(n)
|
| 150 |
+
return keep
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def concat_chunks(chunks: list[Chunk], clips: list[np.ndarray]) -> np.ndarray:
|
| 154 |
+
"""Join generated clips ``(F, H, W, 3)`` into one video.
|
| 155 |
+
|
| 156 |
+
Clip 0 contributes frames ``0..kf_local[-1]``; every later clip starts one frame in, since
|
| 157 |
+
its frame 0 is the boundary keyframe the previous clip already ended on. Frames after a
|
| 158 |
+
clip's last keyframe are dropped.
|
| 159 |
+
"""
|
| 160 |
+
parts = []
|
| 161 |
+
for n, (ch, clip) in enumerate(zip(chunks, clips)):
|
| 162 |
+
lo = ch.kf_local[0] + (1 if n else 0)
|
| 163 |
+
parts.append(clip[lo:ch.kf_local[-1] + 1])
|
| 164 |
+
return np.concatenate(parts, axis=0)
|
robotryon/video/conditioning.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Assembling one training step of the in-context video model g_phi (paper Eq. 3.3).
|
| 2 |
+
|
| 3 |
+
A training sequence holds three groups of tokens, in the order of diffusers'
|
| 4 |
+
``LTX2InContextPipeline``::
|
| 5 |
+
|
| 6 |
+
[ target tokens | keyframe tokens | skeleton-flow reference tokens ]
|
| 7 |
+
|
| 8 |
+
* **target**: the clip ``w = E_vid(o_{1:T})``, noised by flow matching to ``w_tau``. The model
|
| 9 |
+
predicts the velocity ``eps - w`` on these tokens.
|
| 10 |
+
* **keyframes** ``o_T``: each keyframe is one pixel frame, VAE-encoded on its own (exactly what
|
| 11 |
+
inference can build from a PNG), clean (timestep 0), and placed at its true time by RoPE
|
| 12 |
+
coordinates ``[f, f+1) / fps``. The keyframe at frame 0 instead *replaces* latent frame 0 of
|
| 13 |
+
the target, which encodes exactly that one frame; it is excluded from the loss.
|
| 14 |
+
* **reference**: the rendered skeleton flow ``S_{1:T}``, VAE-encoded like the clip, clean, at
|
| 15 |
+
``1 / ref_downscale`` resolution with its spatial coordinates scaled back onto the target
|
| 16 |
+
grid.
|
| 17 |
+
|
| 18 |
+
Stereo clips hold both views in every group, each view encoded separately and laid end to end
|
| 19 |
+
inside the group; a fourth RoPE axis (rope4d.py) tells the views apart. Mono clips use plain
|
| 20 |
+
three-axis coordinates. The transformer is bidirectional, so a keyframe appended at the end
|
| 21 |
+
of the sequence conditions every frame.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
|
| 28 |
+
AUDIO_TOKENS = 1 # LTX-2 is audio-video; training feeds a one-token silent audio stream
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def sample_keyframe_indices(num_frames: int, count_weights: dict[int, float],
|
| 32 |
+
include_last_p: float, generator: torch.Generator | None = None
|
| 33 |
+
) -> list[int]:
|
| 34 |
+
"""Keyframes of one training clip (paper App. B.2.4): 1 to 4, the first frame always.
|
| 35 |
+
|
| 36 |
+
The count is drawn from ``count_weights``; the remaining keyframes sit at random frames.
|
| 37 |
+
With probability ``include_last_p`` one of them is the clip's last frame, which is where
|
| 38 |
+
inference always places the shared boundary keyframe of a chunk.
|
| 39 |
+
"""
|
| 40 |
+
counts = sorted(count_weights)
|
| 41 |
+
probs = torch.tensor([float(count_weights[c]) for c in counts])
|
| 42 |
+
n = min(counts[int(torch.multinomial(probs, 1, generator=generator))], num_frames)
|
| 43 |
+
idx = {0}
|
| 44 |
+
if n >= 2 and torch.rand((), generator=generator) < include_last_p:
|
| 45 |
+
idx.add(num_frames - 1)
|
| 46 |
+
pool = torch.randperm(num_frames - 1, generator=generator) + 1 # frames 1..F-1
|
| 47 |
+
for f in pool.tolist():
|
| 48 |
+
if len(idx) >= n:
|
| 49 |
+
break
|
| 50 |
+
idx.add(f)
|
| 51 |
+
return sorted(idx)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _per_view(coords: torch.Tensor, num_views: int) -> list[torch.Tensor]:
|
| 55 |
+
if num_views == 1:
|
| 56 |
+
return [coords] # mono: no view axis, pretrained RoPE untouched
|
| 57 |
+
from .rope4d import add_view_coord
|
| 58 |
+
return [add_view_coord(coords, v) for v in range(num_views)]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_video_coords(rope, num_latent_frames: int, height: int, width: int,
|
| 62 |
+
ref_downscale: int, device, fps: float, keyframe_indices: list[int],
|
| 63 |
+
num_views: int = 1) -> torch.Tensor:
|
| 64 |
+
"""RoPE coordinates for ``[target | keyframes | reference]``, matching ``build_step``.
|
| 65 |
+
|
| 66 |
+
``height``/``width`` are one view's latent grid. Keyframe tokens are one pixel frame wide
|
| 67 |
+
at ``[f, f+1)`` (already divided by fps, like ``prepare_video_coords``).
|
| 68 |
+
"""
|
| 69 |
+
tgt = rope.prepare_video_coords(1, num_latent_frames, height, width, device=device, fps=fps)
|
| 70 |
+
parts = _per_view(tgt, num_views)
|
| 71 |
+
if keyframe_indices:
|
| 72 |
+
one = rope.prepare_video_coords(1, 1, height, width, device=device, fps=fps)
|
| 73 |
+
for f in keyframe_indices:
|
| 74 |
+
c = one.clone()
|
| 75 |
+
c[:, 0, :, 0] = float(f) / fps
|
| 76 |
+
c[:, 0, :, 1] = float(f + 1) / fps
|
| 77 |
+
parts += _per_view(c, num_views)
|
| 78 |
+
if ref_downscale == 1:
|
| 79 |
+
ref = tgt.clone()
|
| 80 |
+
else:
|
| 81 |
+
ref = rope.prepare_video_coords(1, num_latent_frames, height // ref_downscale,
|
| 82 |
+
width // ref_downscale, device=device, fps=fps)
|
| 83 |
+
ref[:, 1:3] *= ref_downscale
|
| 84 |
+
parts += _per_view(ref, num_views)
|
| 85 |
+
return torch.cat(parts, dim=2)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def build_step(target: torch.Tensor, reference: torch.Tensor,
|
| 89 |
+
keyframes: dict[int, torch.Tensor], sigma: torch.Tensor,
|
| 90 |
+
tokens_per_frame: int, num_views: int = 1):
|
| 91 |
+
"""Noise the target and lay out one sequence.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
target: ``(1, V * seq, C)`` packed clean target tokens, view-major.
|
| 95 |
+
reference: ``(1, V * ref_seq, C)`` packed skeleton-flow tokens.
|
| 96 |
+
keyframes: ``{pixel frame: (1, V * tokens_per_frame, C)}``, one-frame encodes.
|
| 97 |
+
sigma: ``(1,)`` flow-matching noise level in [0, 1].
|
| 98 |
+
tokens_per_frame: spatial tokens of one view's latent frame.
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
``(hidden, timestep, velocity, pinned, appended)``: the input tokens, per-token
|
| 102 |
+
timesteps (``sigma * 1000`` on noised tokens, 0 on clean ones), the velocity target
|
| 103 |
+
for the first ``V * seq`` outputs, a mask of target tokens replaced by the frame-0
|
| 104 |
+
keyframe, and the appended keyframe indices in sequence order.
|
| 105 |
+
"""
|
| 106 |
+
b, seq, _ = target.shape
|
| 107 |
+
per_view = seq // num_views
|
| 108 |
+
noise = torch.randn_like(target)
|
| 109 |
+
s = sigma.view(-1, 1, 1).to(target.dtype)
|
| 110 |
+
noisy = (1.0 - s) * target + s * noise
|
| 111 |
+
pinned = torch.zeros(b, seq, dtype=torch.bool, device=target.device)
|
| 112 |
+
if 0 in keyframes: # frame 0 replaces latent frame 0 of each view
|
| 113 |
+
noisy = noisy.clone()
|
| 114 |
+
for v in range(num_views):
|
| 115 |
+
dst, src = v * per_view, v * tokens_per_frame
|
| 116 |
+
noisy[:, dst:dst + tokens_per_frame] = keyframes[0][:, src:src + tokens_per_frame]
|
| 117 |
+
pinned[:, dst:dst + tokens_per_frame] = True
|
| 118 |
+
appended = sorted(f for f in keyframes if f != 0)
|
| 119 |
+
hidden = torch.cat([noisy] + [keyframes[f] for f in appended] + [reference], dim=1)
|
| 120 |
+
ts = (sigma.view(-1, 1) * 1000.0).to(target.dtype).expand(b, hidden.shape[1]).clone()
|
| 121 |
+
ts[:, :seq] = torch.where(pinned, torch.zeros_like(ts[:, :seq]), ts[:, :seq])
|
| 122 |
+
ts[:, seq:] = 0.0
|
| 123 |
+
return hidden, ts, noise - target, pinned, appended
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def flow_matching_loss(pred: torch.Tensor, velocity: torch.Tensor,
|
| 127 |
+
pinned: torch.Tensor) -> torch.Tensor:
|
| 128 |
+
"""MSE between predicted and true velocity over the noised target tokens."""
|
| 129 |
+
keep = (~pinned).unsqueeze(-1)
|
| 130 |
+
diff = (pred.float() - velocity.float()) ** 2 * keep
|
| 131 |
+
return diff.sum() / keep.expand_as(diff).sum().clamp(min=1)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def shifted_logit_normal_sigma(seq_len: int, device=None, std: float = 1.0, eps: float = 1e-3,
|
| 135 |
+
uniform_prob: float = 0.1) -> torch.Tensor:
|
| 136 |
+
"""Noise level for one sample, as LTX-2's trainer draws it.
|
| 137 |
+
|
| 138 |
+
Adapted from LTX-2 ``ltx_trainer.timestep_samplers.ShiftedLogitNormalTimestepSampler``
|
| 139 |
+
(Apache-2.0): a logit-normal whose mean shifts with the token count, stretched between its
|
| 140 |
+
0.5th and 99.9th percentiles, mixed with 10% uniform samples.
|
| 141 |
+
"""
|
| 142 |
+
m = (2.05 - 0.95) / (4096 - 1024)
|
| 143 |
+
mu = m * seq_len + (0.95 - m * 1024)
|
| 144 |
+
x = torch.sigmoid(torch.randn((1,), device=device) * std + mu)
|
| 145 |
+
p999 = torch.sigmoid(torch.tensor(mu + 3.0902 * std, device=device))
|
| 146 |
+
p005 = torch.sigmoid(torch.tensor(mu - 2.5758 * std, device=device))
|
| 147 |
+
z = (x - p005) / (p999 - p005)
|
| 148 |
+
z = torch.clamp(torch.where(z >= eps, z, 2 * eps - z), 0, 1)
|
| 149 |
+
uniform = (1 - eps) * torch.rand((1,), device=device) + eps
|
| 150 |
+
return torch.where(torch.rand((1,), device=device) > uniform_prob, z, uniform)
|
robotryon/video/dataset.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training clips for the video model: robot video, its skeleton flow, and its caption.
|
| 2 |
+
|
| 3 |
+
Episodes follow the shared format (``<root>/<episode>/meta.json``, ``video_<view>.mp4``,
|
| 4 |
+
``keypoints_<view>.npz``). One item is a random window of one episode:
|
| 5 |
+
|
| 6 |
+
* length ``F`` drawn uniformly from the ``8k + 1`` values in ``frames_range`` that fit,
|
| 7 |
+
start drawn uniformly;
|
| 8 |
+
* stereo episodes give both views (``V = 2``), except with probability ``mono_p`` where one
|
| 9 |
+
random view is used (``V = 1``), so one model learns stereo and mono generation;
|
| 10 |
+
* one crop rectangle (bottom-anchored, scale in ``[min_scale, 1]``) and one horizontal flip
|
| 11 |
+
for every view and for the skeleton. A mirrored stereo pair also swaps its views, since the
|
| 12 |
+
reflected right image is what a left camera would see;
|
| 13 |
+
* the skeleton flow ``S_{1:T}`` is rendered on the fly from the keypoints, after the paper's
|
| 14 |
+
augmentation (``robotryon.skeleton.SkeletonAugment``: shift every keypoint by up to half its
|
| 15 |
+
finger segment, drop the whole skeleton with p=0.2, each keypoint with p=0.2), held fixed
|
| 16 |
+
over the clip. The views of a stereo clip are augmented jointly (the hands of both views
|
| 17 |
+
share one drop-all decision).
|
| 18 |
+
|
| 19 |
+
The skeleton is drawn directly at ``skeleton_size`` (the reference resolution), the same
|
| 20 |
+
resolution inference renders at.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import json
|
| 26 |
+
import random
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import cv2
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
from torch.utils.data import Dataset
|
| 34 |
+
|
| 35 |
+
from ..skeleton import SkeletonAugment, draw_skeleton_video
|
| 36 |
+
from .chunks import allowed_frame_counts
|
| 37 |
+
from .io import read_frames
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class Episode:
|
| 42 |
+
path: Path
|
| 43 |
+
views: tuple[str, ...]
|
| 44 |
+
num_frames: int
|
| 45 |
+
caption: str
|
| 46 |
+
root_index: int
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def caption_text(template: str, meta: dict, embodiment: str | None = None) -> str:
|
| 50 |
+
"""The text prompt: ``template`` filled with the episode's caption (and embodiment)."""
|
| 51 |
+
return template.format(caption=meta.get("caption", ""),
|
| 52 |
+
embodiment=embodiment or meta.get("embodiment", ""))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_episodes(roots: list, min_frames: int, fps: float,
|
| 56 |
+
caption_template: str = "{caption}") -> tuple[list[Episode], list[float]]:
|
| 57 |
+
"""All usable episodes under ``roots`` and their sampling weights.
|
| 58 |
+
|
| 59 |
+
``roots`` items are paths or ``{path, weight}``. A root's weight is split over its
|
| 60 |
+
episodes in proportion to their length.
|
| 61 |
+
"""
|
| 62 |
+
episodes, weights = [], []
|
| 63 |
+
for ri, r in enumerate(roots):
|
| 64 |
+
path, w = (r, 1.0) if isinstance(r, str) else (r["path"], float(r.get("weight", 1.0)))
|
| 65 |
+
found = []
|
| 66 |
+
for m in sorted(Path(path).glob("*/meta.json")):
|
| 67 |
+
meta = json.loads(m.read_text())
|
| 68 |
+
if abs(float(meta["fps"]) - fps) > 1e-6:
|
| 69 |
+
raise ValueError(f"{m}: fps {meta['fps']} != {fps}; resample the episode first")
|
| 70 |
+
if int(meta["num_frames"]) < min_frames:
|
| 71 |
+
continue
|
| 72 |
+
found.append(Episode(m.parent, tuple(meta["views"]), int(meta["num_frames"]),
|
| 73 |
+
caption_text(caption_template, meta), ri))
|
| 74 |
+
if not found:
|
| 75 |
+
raise FileNotFoundError(f"no episode with >= {min_frames} frames under {path}")
|
| 76 |
+
total = sum(e.num_frames for e in found)
|
| 77 |
+
episodes += found
|
| 78 |
+
weights += [w * e.num_frames / total for e in found]
|
| 79 |
+
return episodes, weights
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def crop_rect(W: int, H: int, aspect: float, scale: float, rng: random.Random):
|
| 83 |
+
"""A crop of the given aspect, bottom-anchored (arms enter from the bottom edge)."""
|
| 84 |
+
bw, bh = (int(round(H * aspect)), H) if W / H > aspect else (W, int(round(W / aspect)))
|
| 85 |
+
w = max(8, int(round(bw * scale)))
|
| 86 |
+
h = min(bh, max(8, int(round(w / aspect))))
|
| 87 |
+
return rng.randint(0, max(0, W - w)), H - h, w, h
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def crop_frames(frames: np.ndarray, rect, flip: bool, size) -> np.ndarray:
|
| 91 |
+
x, y, w, h = rect
|
| 92 |
+
out = np.empty((len(frames), size[1], size[0], 3), np.uint8)
|
| 93 |
+
for i, f in enumerate(frames):
|
| 94 |
+
c = f[y:y + h, x:x + w]
|
| 95 |
+
out[i] = cv2.resize(c[:, ::-1] if flip else c, size, interpolation=cv2.INTER_AREA)
|
| 96 |
+
return out
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def crop_keypoints(kp: np.ndarray, rect, flip: bool, size) -> np.ndarray:
|
| 100 |
+
"""Map pixel keypoints through the same crop, flip and resize as the frames."""
|
| 101 |
+
x, y, w, h = rect
|
| 102 |
+
out = np.array(kp, np.float32, copy=True)
|
| 103 |
+
out[..., 0] = (out[..., 0] - x + 0.5) * size[0] / w - 0.5
|
| 104 |
+
out[..., 1] = (out[..., 1] - y + 0.5) * size[1] / h - 0.5
|
| 105 |
+
if flip:
|
| 106 |
+
out[..., 0] = size[0] - 1 - out[..., 0]
|
| 107 |
+
return out
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def to_tensor(a: np.ndarray) -> torch.Tensor:
|
| 111 |
+
"""``(V, F, H, W, 3)`` uint8 RGB -> ``(V, 3, F, H, W)`` float in [-1, 1]."""
|
| 112 |
+
return torch.from_numpy(np.ascontiguousarray(a)).permute(0, 4, 1, 2, 3).float() / 127.5 - 1
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class VideoClipDataset(Dataset):
|
| 116 |
+
"""Random training windows. Items are unbatched dicts (clip lengths differ)."""
|
| 117 |
+
|
| 118 |
+
def __init__(self, episodes: list[Episode], out_size, skeleton_size, frames_range,
|
| 119 |
+
min_scale: float, flip_p: float, mono_p: float, augment: SkeletonAugment,
|
| 120 |
+
draw_kw: dict | None = None, fps: float = 20.0, seed: int = 0):
|
| 121 |
+
self.episodes = episodes
|
| 122 |
+
self.out_size, self.skeleton_size = tuple(out_size), tuple(skeleton_size)
|
| 123 |
+
self.choices = allowed_frame_counts(*frames_range)
|
| 124 |
+
self.min_scale, self.flip_p, self.mono_p = min_scale, flip_p, mono_p
|
| 125 |
+
self.augment, self.draw_kw = augment, dict(draw_kw or {})
|
| 126 |
+
self.fps, self.seed, self.epoch = fps, seed, 0
|
| 127 |
+
|
| 128 |
+
def set_epoch(self, epoch: int) -> None:
|
| 129 |
+
self.epoch = epoch
|
| 130 |
+
|
| 131 |
+
def __len__(self) -> int:
|
| 132 |
+
return len(self.episodes)
|
| 133 |
+
|
| 134 |
+
def __getitem__(self, idx: int) -> dict:
|
| 135 |
+
ep = self.episodes[idx]
|
| 136 |
+
rng = random.Random(hash((self.seed, self.epoch, idx)) & 0xFFFFFFFF)
|
| 137 |
+
nrng = np.random.default_rng(rng.getrandbits(64))
|
| 138 |
+
F = rng.choice([f for f in self.choices if f <= ep.num_frames])
|
| 139 |
+
start = rng.randint(0, ep.num_frames - F)
|
| 140 |
+
views = list(ep.views)
|
| 141 |
+
if len(views) > 1 and rng.random() < self.mono_p:
|
| 142 |
+
views = [rng.choice(views)]
|
| 143 |
+
|
| 144 |
+
frames = [read_frames(ep.path / f"video_{v}.mp4", start, F) for v in views]
|
| 145 |
+
kps, vis = [], []
|
| 146 |
+
for v in views:
|
| 147 |
+
z = np.load(ep.path / f"keypoints_{v}.npz")
|
| 148 |
+
kps.append(z["kp"][start:start + F])
|
| 149 |
+
vis.append(z["vis"][start:start + F].astype(bool))
|
| 150 |
+
|
| 151 |
+
H, W = frames[0].shape[1:3]
|
| 152 |
+
rect = crop_rect(W, H, self.out_size[0] / self.out_size[1],
|
| 153 |
+
rng.uniform(self.min_scale, 1.0), rng)
|
| 154 |
+
flip = rng.random() < self.flip_p
|
| 155 |
+
video = np.stack([crop_frames(f, rect, flip, self.out_size) for f in frames])
|
| 156 |
+
kp = np.concatenate([crop_keypoints(k, rect, flip, self.skeleton_size) for k in kps], 1)
|
| 157 |
+
kp, vs = self.augment(kp, np.concatenate(vis, 1), nrng, temporal=True)
|
| 158 |
+
sw, sh = self.skeleton_size
|
| 159 |
+
skel = np.stack([draw_skeleton_video(kp[:, 2 * i:2 * i + 2], vs[:, 2 * i:2 * i + 2],
|
| 160 |
+
sw, sh, **self.draw_kw)
|
| 161 |
+
for i in range(len(views))])
|
| 162 |
+
if flip and len(views) == 2: # a mirrored stereo pair swaps cameras
|
| 163 |
+
video, skel = video[::-1], skel[::-1]
|
| 164 |
+
return {"video": to_tensor(video), "condition": to_tensor(skel),
|
| 165 |
+
"caption": ep.caption, "num_frames": F, "fps": self.fps,
|
| 166 |
+
"meta": {"episode": ep.path.name, "start": start, "views": len(views),
|
| 167 |
+
"flip": flip}}
|
robotryon/video/io.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal mp4 / png I/O for the video model (RGB uint8 arrays, OpenCV underneath)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def read_frames(path: Path, start: int, count: int, size=None) -> np.ndarray:
|
| 12 |
+
"""Frames ``[start, start + count)`` as ``(count, H, W, 3)`` RGB, optionally resized
|
| 13 |
+
(INTER_AREA) to ``size = (W, H)``."""
|
| 14 |
+
cap = cv2.VideoCapture(str(path))
|
| 15 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start)
|
| 16 |
+
frames = []
|
| 17 |
+
for _ in range(count):
|
| 18 |
+
ok, f = cap.read()
|
| 19 |
+
if not ok:
|
| 20 |
+
break
|
| 21 |
+
if size is not None:
|
| 22 |
+
f = cv2.resize(f, tuple(size), interpolation=cv2.INTER_AREA)
|
| 23 |
+
frames.append(f[..., ::-1])
|
| 24 |
+
cap.release()
|
| 25 |
+
if len(frames) < count:
|
| 26 |
+
raise RuntimeError(f"{path}: wanted {count} frames from {start}, got {len(frames)}")
|
| 27 |
+
return np.ascontiguousarray(np.stack(frames))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def num_frames(path: Path) -> int:
|
| 31 |
+
cap = cv2.VideoCapture(str(path))
|
| 32 |
+
n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 33 |
+
cap.release()
|
| 34 |
+
return n
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def read_image(path: Path) -> np.ndarray:
|
| 38 |
+
"""PNG -> ``(H, W, 3)`` RGB uint8 at its own resolution."""
|
| 39 |
+
img = cv2.imread(str(path))
|
| 40 |
+
if img is None:
|
| 41 |
+
raise FileNotFoundError(path)
|
| 42 |
+
return np.ascontiguousarray(img[..., ::-1])
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def write_video(frames: np.ndarray, path: Path, fps: float) -> None:
|
| 46 |
+
"""``(F, H, W, 3)`` RGB uint8 -> mp4 (mp4v)."""
|
| 47 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 48 |
+
h, w = frames.shape[1:3]
|
| 49 |
+
wr = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
|
| 50 |
+
for f in frames:
|
| 51 |
+
wr.write(np.ascontiguousarray(f[..., ::-1]))
|
| 52 |
+
wr.release()
|
robotryon/video/pipeline.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Video synthesis with the in-context LoRA (paper Eq. 3.4 and App. B.4).
|
| 2 |
+
|
| 3 |
+
``StereoIcLoraPipeline`` extends diffusers' ``LTX2InContextPipeline`` with two things:
|
| 4 |
+
|
| 5 |
+
* **stereo**: ``conditions`` and ``reference_conditions`` are per-view lists. Each view is
|
| 6 |
+
prepared by the parent and the token groups are interleaved by role,
|
| 7 |
+
``[base(L) base(R) | kf(L) kf(R) | ref(L) ref(R)]``, so the reference tokens stay at the end
|
| 8 |
+
of the sequence where the parent's attention mask expects them. A fourth RoPE axis
|
| 9 |
+
(rope4d.py) tells the views apart; mono uses the pipeline's own coordinates.
|
| 10 |
+
* **exact keyframe times**: diffusers places a keyframe at a latent index, which snaps it to
|
| 11 |
+
pixel frame ``8k - 7``. Training places keyframes at their exact pixel frame ``[f, f+1)``,
|
| 12 |
+
so the appended keyframe coordinates are rewritten to the requested pixel frames. The
|
| 13 |
+
keyframe at frame 0 replaces latent frame 0 and needs no rewrite.
|
| 14 |
+
|
| 15 |
+
``generate`` runs the two-stage recipe on one clip. Both stages use the full LTX-2.5
|
| 16 |
+
transformer with the IC-LoRA and Lightricks' distilled LoRA, the published distilled sigma
|
| 17 |
+
schedules (8 steps, then 3 refinement steps from sigma 0.909) and neutral guidance. Both stages
|
| 18 |
+
are conditioned on the same keyframes and skeleton flow. ``two_stage.mode``:
|
| 19 |
+
|
| 20 |
+
* ``same_res``: stage 1 already at the output resolution; stage 2 refines at that resolution.
|
| 21 |
+
* ``upsample``: stage 1 at half the output resolution, the stage-1 latents are upsampled x2 by
|
| 22 |
+
the LTX-2.5 spatial latent upsampler, and stage 2 refines at the output resolution.
|
| 23 |
+
|
| 24 |
+
The skeleton flow is always given at one ``skeleton_size`` (512x288); each stage encodes it
|
| 25 |
+
with ``reference_downscale = stage width / skeleton width`` (1 at 512x288, 2 at 1024x576),
|
| 26 |
+
exactly as the two training stages did.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import glob
|
| 32 |
+
import os
|
| 33 |
+
|
| 34 |
+
import numpy as np
|
| 35 |
+
import torch
|
| 36 |
+
from diffusers.pipelines.ltx2 import LTX2InContextPipeline
|
| 37 |
+
|
| 38 |
+
from .chunks import latent_index
|
| 39 |
+
from .rope4d import add_view_coord, install_view_axis_rope, set_view_coord
|
| 40 |
+
|
| 41 |
+
MAX_SEQ = 256 # text tokens; training used the same
|
| 42 |
+
DISTILLED_LORA = "ltx-2.5-22b-distilled-lora-450-bf16.safetensors"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def snapshot_dir(repo_or_path: str) -> str:
|
| 46 |
+
"""A local directory for a Hugging Face repo id (newest cached snapshot) or a path."""
|
| 47 |
+
if os.path.isdir(repo_or_path):
|
| 48 |
+
return repo_or_path
|
| 49 |
+
from huggingface_hub import snapshot_download
|
| 50 |
+
from huggingface_hub.constants import HF_HUB_CACHE
|
| 51 |
+
slug = "models--" + repo_or_path.replace("/", "--")
|
| 52 |
+
hits = sorted(glob.glob(os.path.join(HF_HUB_CACHE, slug, "snapshots", "*")))
|
| 53 |
+
return hits[-1] if hits else snapshot_download(repo_or_path)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class StereoIcLoraPipeline(LTX2InContextPipeline):
|
| 57 |
+
"""``LTX2InContextPipeline`` with a view axis and exact-frame keyframes."""
|
| 58 |
+
|
| 59 |
+
num_views = 1
|
| 60 |
+
exact_kf_px: list[int] | None = None # pixel frames of the appended keyframes
|
| 61 |
+
|
| 62 |
+
def _unpack_latents(self, latents, num_frames, height, width, patch_size=1,
|
| 63 |
+
patch_size_t=1):
|
| 64 |
+
"""Unpack the views' base tokens into a batch ``(V, C, F, H, W)`` for decoding."""
|
| 65 |
+
per_view = num_frames * height * width
|
| 66 |
+
views = latents.shape[1] // per_view
|
| 67 |
+
if views < 2:
|
| 68 |
+
return super()._unpack_latents(latents, num_frames, height, width, patch_size,
|
| 69 |
+
patch_size_t)
|
| 70 |
+
return torch.cat([super(StereoIcLoraPipeline, self)._unpack_latents(
|
| 71 |
+
latents[:, v * per_view:(v + 1) * per_view], num_frames, height, width,
|
| 72 |
+
patch_size, patch_size_t) for v in range(views)], dim=0)
|
| 73 |
+
|
| 74 |
+
def _retime_keyframes(self, kf_coords, n_kf, kw):
|
| 75 |
+
"""Move the appended keyframe blocks to their exact pixel frames ``[f, f+1) / fps``."""
|
| 76 |
+
if not self.exact_kf_px or not n_kf:
|
| 77 |
+
return kf_coords
|
| 78 |
+
fps = float(kw["frame_rate"])
|
| 79 |
+
tpf = (int(kw["height"]) // 32) * (int(kw["width"]) // 32)
|
| 80 |
+
if n_kf != len(self.exact_kf_px) * tpf:
|
| 81 |
+
raise ValueError(f"{n_kf} appended keyframe tokens do not match "
|
| 82 |
+
f"{len(self.exact_kf_px)} keyframes of {tpf} tokens")
|
| 83 |
+
out = kf_coords.clone()
|
| 84 |
+
for j, f in enumerate(self.exact_kf_px):
|
| 85 |
+
sl = slice(j * tpf, (j + 1) * tpf)
|
| 86 |
+
out[:, 0, sl, 0] = float(f) / fps
|
| 87 |
+
out[:, 0, sl, 1] = float(f + 1) / fps
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
def prepare_latents(self, conditions=None, reference_conditions=None, **kw):
|
| 91 |
+
views = self.num_views
|
| 92 |
+
conditions = conditions or [None] * views
|
| 93 |
+
reference_conditions = reference_conditions or [None] * views
|
| 94 |
+
lat_in = kw.pop("latents", None) # stage 2: stage-1 (upsampled) latents per view
|
| 95 |
+
|
| 96 |
+
def view_latents(v):
|
| 97 |
+
if lat_in is None:
|
| 98 |
+
return None
|
| 99 |
+
if lat_in.dim() == 5 and lat_in.shape[0] == views:
|
| 100 |
+
return lat_in[v:v + 1]
|
| 101 |
+
raise ValueError(f"latents {tuple(lat_in.shape)} do not split into {views} views")
|
| 102 |
+
|
| 103 |
+
self._single_view_coords = True # reference coords from the parent stay 3-axis
|
| 104 |
+
try:
|
| 105 |
+
per = [super(StereoIcLoraPipeline, self).prepare_latents(
|
| 106 |
+
conditions=conditions[v], reference_conditions=reference_conditions[v],
|
| 107 |
+
latents=view_latents(v), **kw) for v in range(views)]
|
| 108 |
+
finally:
|
| 109 |
+
self._single_view_coords = False
|
| 110 |
+
|
| 111 |
+
groups = {k: [[] for _ in range(3)] for k in ("lat", "mask", "clean")}
|
| 112 |
+
kf_co, ref_co, ref_masks, ref_counts = [], [], [], []
|
| 113 |
+
for v, (lat, mask, clean, acoords, nref, rmask, rcounts) in enumerate(per):
|
| 114 |
+
n_app = 0 if acoords is None else acoords.shape[2]
|
| 115 |
+
n_kf, n_base = n_app - nref, lat.shape[1] - n_app
|
| 116 |
+
bounds = (slice(0, n_base), slice(n_base, n_base + n_kf),
|
| 117 |
+
slice(n_base + n_kf, lat.shape[1]))
|
| 118 |
+
for name, src in (("lat", lat), ("mask", mask), ("clean", clean)):
|
| 119 |
+
for g, sl in enumerate(bounds):
|
| 120 |
+
groups[name][g].append(src[:, sl])
|
| 121 |
+
if acoords is not None:
|
| 122 |
+
a4 = set_view_coord(acoords, v) if views > 1 else acoords
|
| 123 |
+
kf_co.append(self._retime_keyframes(a4[:, :, :n_kf], n_kf, kw))
|
| 124 |
+
ref_co.append(a4[:, :, n_kf:])
|
| 125 |
+
if rmask is not None:
|
| 126 |
+
ref_masks.append(rmask)
|
| 127 |
+
ref_counts.extend(rcounts or [])
|
| 128 |
+
|
| 129 |
+
def flat(g):
|
| 130 |
+
return torch.cat(g[0] + g[1] + g[2], dim=1)
|
| 131 |
+
coords = torch.cat(kf_co + ref_co, dim=2) if (kf_co or ref_co) else None
|
| 132 |
+
return (flat(groups["lat"]), flat(groups["mask"]), flat(groups["clean"]), coords,
|
| 133 |
+
sum(x.shape[1] for x in groups["lat"][2]),
|
| 134 |
+
torch.cat(ref_masks, dim=1) if ref_masks else None, ref_counts)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _install_stereo(pipe: StereoIcLoraPipeline) -> None:
|
| 138 |
+
"""View-axis RoPE, and a base grid that covers every view (view axis stamped per view)."""
|
| 139 |
+
install_view_axis_rope(pipe.transformer)
|
| 140 |
+
pipe._single_view_coords = False
|
| 141 |
+
rope = pipe.transformer.rope
|
| 142 |
+
orig = rope.prepare_video_coords
|
| 143 |
+
|
| 144 |
+
def widened(*args, **kwargs):
|
| 145 |
+
plain = orig(*args, **kwargs)
|
| 146 |
+
if pipe._single_view_coords:
|
| 147 |
+
return plain
|
| 148 |
+
return torch.cat([add_view_coord(plain, v) for v in range(pipe.num_views)], dim=2)
|
| 149 |
+
|
| 150 |
+
rope.prepare_video_coords = widened
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_pipeline(lora: str, *, base: str = "Lightricks/LTX-2.5-Diffusers",
|
| 154 |
+
transformer_subfolder: str = "transformer_full", views: int = 1,
|
| 155 |
+
attention_backend: str | None = "native", upsample: bool = False,
|
| 156 |
+
device: str = "cuda") -> StereoIcLoraPipeline:
|
| 157 |
+
"""Load LTX-2.5 + IC-LoRA + distilled LoRA, without the text encoder.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
lora: checkpoint directory holding ``pytorch_lora_weights.safetensors`` (or the file).
|
| 161 |
+
views: views generated jointly in one sequence (2 = stereo, 1 = mono).
|
| 162 |
+
upsample: also load the spatial latent upsampler (``two_stage.mode: upsample``).
|
| 163 |
+
"""
|
| 164 |
+
from diffusers import LTX2VideoTransformer3DModel
|
| 165 |
+
|
| 166 |
+
root = snapshot_dir(base)
|
| 167 |
+
pipe = StereoIcLoraPipeline.from_pretrained(
|
| 168 |
+
root, transformer=LTX2VideoTransformer3DModel.from_pretrained(
|
| 169 |
+
root, subfolder=transformer_subfolder, dtype=torch.bfloat16),
|
| 170 |
+
text_encoder=None, tokenizer=None, dtype=torch.bfloat16)
|
| 171 |
+
pipe.num_views = int(views)
|
| 172 |
+
if views > 1:
|
| 173 |
+
_install_stereo(pipe)
|
| 174 |
+
lora_dir, name = ((os.path.dirname(lora), os.path.basename(lora)) if os.path.isfile(lora)
|
| 175 |
+
else (lora, "pytorch_lora_weights.safetensors"))
|
| 176 |
+
pipe.load_lora_weights(lora_dir, weight_name=name, adapter_name="iclora")
|
| 177 |
+
if not any("lora" in k for k, _ in pipe.transformer.named_parameters()):
|
| 178 |
+
raise RuntimeError(f"no LoRA weights attached from {lora}")
|
| 179 |
+
pipe.load_lora_weights(root, weight_name=DISTILLED_LORA, adapter_name="distilled")
|
| 180 |
+
pipe.set_adapters(["iclora", "distilled"], [1.0, 1.0])
|
| 181 |
+
if attention_backend:
|
| 182 |
+
pipe.transformer.set_attention_backend(attention_backend)
|
| 183 |
+
pipe.vae.enable_tiling()
|
| 184 |
+
pipe.vae.enable_slicing()
|
| 185 |
+
pipe.to(device)
|
| 186 |
+
pipe.upsampler = None
|
| 187 |
+
if upsample:
|
| 188 |
+
from diffusers.pipelines.ltx2 import LTX2LatentUpsamplePipeline
|
| 189 |
+
from diffusers.pipelines.ltx2.latent_upsampler import LTX2LatentUpsamplerModel
|
| 190 |
+
model = LTX2LatentUpsamplerModel.from_pretrained(
|
| 191 |
+
root, subfolder="latent_upsampler", dtype=torch.bfloat16).to(device)
|
| 192 |
+
pipe.upsampler = LTX2LatentUpsamplePipeline(vae=pipe.vae, latent_upsampler=model)
|
| 193 |
+
return pipe
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _u8(img: torch.Tensor) -> np.ndarray:
|
| 197 |
+
"""``(3, H, W)`` in [-1, 1] -> ``(1, H, W, 3)`` uint8 (keyframe convention)."""
|
| 198 |
+
return ((img.clamp(-1, 1) + 1) * 127.5).byte().permute(1, 2, 0)[None].cpu().numpy()
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _f01(video: torch.Tensor) -> np.ndarray:
|
| 202 |
+
"""``(3, F, H, W)`` in [-1, 1] -> ``(F, H, W, 3)`` float in [0, 1] (reference convention)."""
|
| 203 |
+
return ((video.clamp(-1, 1) + 1) / 2).permute(1, 2, 3, 0).float().cpu().numpy()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _to_uint8(out: torch.Tensor, views: int) -> torch.Tensor:
|
| 207 |
+
"""Pipeline ``pt`` output in [0, 1], ``(V, F, 3, H, W)`` -> ``(V, F, H, W, 3)`` uint8."""
|
| 208 |
+
v = out if out.shape[0] == views else out[0]
|
| 209 |
+
frames = [x if x.shape[0] == 3 else x.permute(1, 0, 2, 3) for x in v] # -> (3, F, H, W)
|
| 210 |
+
return torch.stack([(x.float().clamp(0, 1) * 255).byte().permute(1, 2, 3, 0).cpu()
|
| 211 |
+
for x in frames])
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@torch.no_grad()
|
| 215 |
+
def generate(pipe: StereoIcLoraPipeline, *, skeleton: torch.Tensor,
|
| 216 |
+
keyframes: list[tuple[int, torch.Tensor]], text: tuple, num_frames: int,
|
| 217 |
+
size: tuple[int, int], fps: float, seed: int = 0,
|
| 218 |
+
mode: str = "same_res") -> torch.Tensor:
|
| 219 |
+
"""Synthesize one clip.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
skeleton: ``(V, 3, F, h, w)`` skeleton flow in [-1, 1] at the reference resolution.
|
| 223 |
+
keyframes: ``[(pixel frame, (V, 3, H, W) in [-1, 1])]`` sorted, first at frame 0. Any
|
| 224 |
+
resolution: the pipeline resizes them for each stage.
|
| 225 |
+
text: ``(prompt_embeds, mask, negative_embeds, negative_mask)`` (see text.py).
|
| 226 |
+
size: output ``(width, height)`` of one view.
|
| 227 |
+
mode: ``same_res`` or ``upsample``.
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
``(V, F, height, width, 3)`` uint8 RGB.
|
| 231 |
+
"""
|
| 232 |
+
from diffusers.pipelines.ltx2 import LTX2ReferenceCondition, LTX2VideoCondition
|
| 233 |
+
from diffusers.pipelines.ltx2.utils import (DISTILLED_SIGMA_VALUES,
|
| 234 |
+
STAGE_2_DISTILLED_SIGMA_VALUES)
|
| 235 |
+
if mode not in ("same_res", "upsample"):
|
| 236 |
+
raise ValueError(f"two_stage.mode must be same_res or upsample, got {mode!r}")
|
| 237 |
+
views = int(skeleton.shape[0])
|
| 238 |
+
if views != pipe.num_views:
|
| 239 |
+
raise ValueError(f"{views} views given to a {pipe.num_views}-view pipeline")
|
| 240 |
+
px = [p for p, _ in keyframes]
|
| 241 |
+
lat = [latent_index(p, num_frames) for p in px]
|
| 242 |
+
if px != sorted(set(px)) or len(set(lat)) != len(lat):
|
| 243 |
+
raise ValueError(f"keyframes must be sorted and in distinct latent frames: {px}")
|
| 244 |
+
pipe.exact_kf_px = [p for p, li in zip(px, lat) if li != 0]
|
| 245 |
+
|
| 246 |
+
W, H = size
|
| 247 |
+
skel_w = skeleton.shape[-1]
|
| 248 |
+
W1, H1 = (W // 2, H // 2) if mode == "upsample" else (W, H)
|
| 249 |
+
pe, pm, npe, npm = (x.cuda() for x in text)
|
| 250 |
+
kw = dict(prompt_embeds=pe, prompt_attention_mask=pm, negative_prompt_embeds=npe,
|
| 251 |
+
negative_prompt_attention_mask=npm,
|
| 252 |
+
conditions=[[LTX2VideoCondition(frames=_u8(img[v]), index=li, crf=0)
|
| 253 |
+
for li, (_, img) in zip(lat, keyframes)] for v in range(views)],
|
| 254 |
+
reference_conditions=[[LTX2ReferenceCondition(frames=_f01(skeleton[v]))]
|
| 255 |
+
for v in range(views)],
|
| 256 |
+
reference_downscale_factor=W1 // skel_w, max_sequence_length=MAX_SEQ,
|
| 257 |
+
height=H1, width=W1, num_frames=num_frames, frame_rate=fps,
|
| 258 |
+
generator=torch.Generator("cuda").manual_seed(seed), return_dict=False,
|
| 259 |
+
audio_latents=torch.zeros(1, pipe.audio_latent_channels, 1,
|
| 260 |
+
pipe.audio_mel_bins // pipe.audio_vae_mel_compression_ratio,
|
| 261 |
+
device="cuda", dtype=torch.float32),
|
| 262 |
+
guidance_scale=1.0, stg_scale=0.0, modality_scale=1.0, audio_guidance_scale=1.0,
|
| 263 |
+
audio_stg_scale=0.0, audio_modality_scale=1.0)
|
| 264 |
+
|
| 265 |
+
lat1 = pipe(sigmas=list(DISTILLED_SIGMA_VALUES), output_type="latent", **kw)[0]
|
| 266 |
+
if mode == "upsample":
|
| 267 |
+
lat1 = pipe.upsampler(latents=lat1, output_type="latent", return_dict=False)[0]
|
| 268 |
+
s2 = list(STAGE_2_DISTILLED_SIGMA_VALUES)
|
| 269 |
+
kw.update(height=H, width=W, reference_downscale_factor=W // skel_w)
|
| 270 |
+
out = pipe(sigmas=s2, latents=lat1, noise_scale=s2[0], output_type="pt", **kw)[0]
|
| 271 |
+
return _to_uint8(out, views)
|
robotryon/video/rope4d.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A fourth RoPE axis for the camera view.
|
| 2 |
+
|
| 3 |
+
Both stereo views are generated in one sequence: each view is encoded by the VAE on its own
|
| 4 |
+
and the token runs are concatenated. To let attention relate the same (t, h, w) position in the
|
| 5 |
+
two cameras, every token also carries a view coordinate.
|
| 6 |
+
|
| 7 |
+
Passing four axes to the stock LTX-2 rotary embedding would re-cut its channel-to-frequency
|
| 8 |
+
map (``dim // (2 * num_axes)`` frequencies per axis) and perturb every pretrained head at once.
|
| 9 |
+
Instead the t/h/w frequencies stay exactly where they were, and the view rotation is written
|
| 10 |
+
into the last ``VIEW_CHANNELS_PER_HEAD`` channels of every head's slice (64 of 2048 channels),
|
| 11 |
+
using the lowest frequency of the table. With two views the coordinates sit a quarter turn
|
| 12 |
+
apart. Three-axis coordinates fall through to the original implementation unchanged, so mono
|
| 13 |
+
clips see the pretrained embedding exactly.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import types
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
VIEW_CHANNELS_PER_HEAD = 2
|
| 23 |
+
BASE_VIEWS = 2
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def view_channel_indices(dim: int, num_heads: int, per_head: int, device=None) -> torch.Tensor:
|
| 27 |
+
"""Channels taken by the view axis: the last ``per_head`` of each head's slice, head-major."""
|
| 28 |
+
head_half = (dim // 2) // num_heads
|
| 29 |
+
if per_head >= head_half:
|
| 30 |
+
raise ValueError(f"per_head={per_head} would take a head's whole {head_half} channels")
|
| 31 |
+
return torch.tensor([h * head_half + head_half - per_head + c
|
| 32 |
+
for h in range(num_heads) for c in range(per_head)],
|
| 33 |
+
dtype=torch.long, device=device)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _forward_with_view_axis(self, coords: torch.Tensor, device=None):
|
| 37 |
+
"""Drop-in for ``LTX2AudioVideoRotaryPosEmbed.forward`` accepting a 4th (view) axis."""
|
| 38 |
+
device = device or coords.device
|
| 39 |
+
if coords.shape[1] != 4:
|
| 40 |
+
return self._forward_3axis(coords, device)
|
| 41 |
+
if self.rope_type != "split":
|
| 42 |
+
raise NotImplementedError(f"view axis needs rope_type='split', got {self.rope_type!r}")
|
| 43 |
+
if coords.ndim == 4: # [start, end) -> midpoints
|
| 44 |
+
start, end = coords.chunk(2, dim=-1)
|
| 45 |
+
coords = ((start + end) / 2.0).squeeze(-1)
|
| 46 |
+
spatial, view = coords[:, :3], coords[:, 3]
|
| 47 |
+
|
| 48 |
+
# t/h/w exactly as the original: three axes, dim // 6 frequencies each
|
| 49 |
+
max_positions = (self.base_num_frames, self.base_height, self.base_width)
|
| 50 |
+
grid = torch.stack([spatial[:, i] / max_positions[i] for i in range(3)], dim=-1).to(device)
|
| 51 |
+
freqs_dtype = torch.float64 if self.double_precision else torch.float32
|
| 52 |
+
pow_indices = torch.pow(self.theta, torch.linspace(0.0, 1.0, steps=self.dim // 6,
|
| 53 |
+
dtype=freqs_dtype, device=device))
|
| 54 |
+
freqs_base = (pow_indices * torch.pi / 2.0).to(dtype=torch.float32)
|
| 55 |
+
angles = ((grid.unsqueeze(-1) * 2 - 1) * freqs_base).transpose(-1, -2).flatten(2)
|
| 56 |
+
|
| 57 |
+
# pad the angle (cos 1 / sin 0 in the original) to dim // 2
|
| 58 |
+
pad = self.dim // 2 - angles.shape[-1]
|
| 59 |
+
if pad < 0:
|
| 60 |
+
raise RuntimeError(f"angle width {angles.shape[-1]} exceeds dim//2")
|
| 61 |
+
if pad > 0:
|
| 62 |
+
angles = torch.cat([torch.zeros_like(angles[:, :, :pad]), angles], dim=-1)
|
| 63 |
+
|
| 64 |
+
# the view rotation in the tail channels of every head
|
| 65 |
+
idx = view_channel_indices(self.dim, self.num_attention_heads, VIEW_CHANNELS_PER_HEAD,
|
| 66 |
+
device=device)
|
| 67 |
+
view_angle = ((view.to(device).unsqueeze(-1) / BASE_VIEWS * 2 - 1)
|
| 68 |
+
* freqs_base[:VIEW_CHANNELS_PER_HEAD])
|
| 69 |
+
angles = angles.clone()
|
| 70 |
+
angles[:, :, idx] = view_angle.repeat(1, 1, self.num_attention_heads).to(angles.dtype)
|
| 71 |
+
|
| 72 |
+
b, n = angles.shape[:2]
|
| 73 |
+
cos = angles.cos().reshape(b, n, self.num_attention_heads, -1).swapaxes(1, 2)
|
| 74 |
+
sin = angles.sin().reshape(b, n, self.num_attention_heads, -1).swapaxes(1, 2)
|
| 75 |
+
return cos, sin
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def install_view_axis_rope(transformer_or_rope) -> None:
|
| 79 |
+
"""Patch the video rope (only) to accept view coordinates. Idempotent."""
|
| 80 |
+
rope = getattr(transformer_or_rope, "rope", transformer_or_rope)
|
| 81 |
+
if getattr(rope, "_view_axis_installed", False):
|
| 82 |
+
return
|
| 83 |
+
rope._forward_3axis = types.MethodType(type(rope).forward, rope)
|
| 84 |
+
rope.forward = types.MethodType(_forward_with_view_axis, rope)
|
| 85 |
+
rope._view_axis_installed = True
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def add_view_coord(coords: torch.Tensor, view: int) -> torch.Tensor:
|
| 89 |
+
"""``(B, 3, N, 2)`` -> ``(B, 4, N, 2)`` with the view axis constant at ``[view, view+1)``."""
|
| 90 |
+
b, _, n, _ = coords.shape
|
| 91 |
+
v = torch.empty(b, 1, n, 2, dtype=coords.dtype, device=coords.device)
|
| 92 |
+
v[..., 0] = float(view)
|
| 93 |
+
v[..., 1] = float(view + 1)
|
| 94 |
+
return torch.cat([coords, v], dim=1)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def set_view_coord(coords: torch.Tensor, view: int) -> torch.Tensor:
|
| 98 |
+
"""Set the view axis, appending it if the coordinates have only three axes."""
|
| 99 |
+
if coords.shape[1] != 4:
|
| 100 |
+
return add_view_coord(coords, view)
|
| 101 |
+
out = coords.clone()
|
| 102 |
+
out[:, 3, :, 0] = float(view)
|
| 103 |
+
out[:, 3, :, 1] = float(view + 1)
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@torch.no_grad()
|
| 108 |
+
def check_bands_preserved(rope, device=None, atol: float = 1e-3) -> dict:
|
| 109 |
+
"""Verify the patched rope changes only the view channels (run before training).
|
| 110 |
+
|
| 111 |
+
Channels count as changed when they differ by more than ``atol``: the 3-axis and 4-axis
|
| 112 |
+
paths are computed separately and can differ by float rounding on some CPUs."""
|
| 113 |
+
device = device or torch.device("cpu")
|
| 114 |
+
base = rope.prepare_video_coords(1, 4, 9, 16, device=device, fps=20.0)
|
| 115 |
+
cos3, sin3 = rope._forward_3axis(base, device)
|
| 116 |
+
cos4, sin4 = rope(add_view_coord(base, 0))
|
| 117 |
+
idx = view_channel_indices(rope.dim, rope.num_attention_heads, VIEW_CHANNELS_PER_HEAD,
|
| 118 |
+
device=cos3.device)
|
| 119 |
+
keep = torch.ones(rope.dim // 2, dtype=torch.bool, device=cos3.device)
|
| 120 |
+
keep[idx] = False
|
| 121 |
+
keep = keep.view(rope.num_attention_heads, -1)[None, :, None, :]
|
| 122 |
+
d_cos, d_sin = (cos4 - cos3).abs(), (sin4 - sin3).abs()
|
| 123 |
+
changed = ((d_cos > atol) | (d_sin > atol)).any(dim=2).squeeze(0)
|
| 124 |
+
return {"n_changed": int(changed.sum()),
|
| 125 |
+
"n_expected": rope.num_attention_heads * VIEW_CHANNELS_PER_HEAD,
|
| 126 |
+
"heads_touched": int(changed.any(dim=-1).sum()),
|
| 127 |
+
"num_heads": rope.num_attention_heads,
|
| 128 |
+
"max_abs_diff_off_view": float(torch.max((d_cos * keep).max(),
|
| 129 |
+
(d_sin * keep).max()))}
|