multimodalart HF Staff commited on
Commit
0cde9e0
·
verified ·
1 Parent(s): ebf5f15

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,28 @@
1
  ---
2
- title: Latent Dynamics Reasoning
3
- emoji: 👀
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Latent Dynamics Reasoning (LDR)
3
+ emoji: 🎬
4
+ colorFrom: red
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 6.15.1
 
8
  app_file: app.py
9
+ short_description: Extrapolate physical video from 3 conditioning frames
10
+ python_version: "3.10"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # Latent Dynamics Reasoning (LDR)
15
+
16
+ Extrapolative video world model that rolls out future video frames from three
17
+ initial conditioning images, reasoning about latent physical dynamics.
18
+
19
+ Upload three frames of a physical scene, pick the task type (uniform,
20
+ parabola, collision, looming, bouncing), and the model generates ~29 future
21
+ frames of predicted motion.
22
+
23
+ Based on: [Learning How the World Evolves: Extrapolative Video World Models
24
+ via Latent Dynamics Reasoning](https://lat-dyn-reason.github.io/)
25
+
26
+ - [Model](https://huggingface.co/haodongli/LDR) (Apache-2.0)
27
+ - [Code](https://github.com/Lat-Dyn-Reason/Lat-Dyn-Reason)
28
+ - [Project Page](https://lat-dyn-reason.github.io/)
app.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LDR — Extrapolative Video World Models via Latent Dynamics Reasoning.
2
+
3
+ Upload three conditioning frames, pick a task type, and the model rolls out
4
+ ~29 future frames of physical motion (uniform, parabola, collision, looming,
5
+ or bouncing) and returns them as a video.
6
+ """
7
+ import os
8
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
9
+
10
+ import spaces # MUST be before torch
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ import numpy as np
15
+ import gradio as gr
16
+ import tempfile
17
+ import imageio
18
+ import imageio.v3 as iio
19
+ from pathlib import Path
20
+ from huggingface_hub import hf_hub_download
21
+
22
+ from ldr import build_ldr
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Model loading (module scope — ZeroGPU intercepts .to("cuda"))
26
+ # ---------------------------------------------------------------------------
27
+
28
+ MODEL_ID = "haodongli/LDR"
29
+
30
+ # The joint-5task checkpoint handles all five tasks in one model.
31
+ # Single-task checkpoints are smaller but only work for one task.
32
+ # We load both 256x256 single-task and joint so users can pick.
33
+ CKPT_CONFIGS = {
34
+ "uniform": ("256x256/single_task/uniform.pt", 256),
35
+ "parabola": ("256x256/single_task/parabola.pt", 256),
36
+ "collision": ("256x256/single_task/collision.pt", 256),
37
+ "looming": ("256x256/single_task/looming.pt", 256),
38
+ "bouncing": ("256x256/single_task/bouncing.pt", 256),
39
+ "joint": ("256x256/joint_task/joint_5task.pt", 256),
40
+ }
41
+
42
+ OURS_ARCH = dict(num_pred=29, width=256, accel_scale=0.5, n_kp=16,
43
+ warp_flow_res=64, kappa_init=0.15)
44
+
45
+
46
+ def _load_checkpoint(ckpt_path, img_size):
47
+ """Load a .pt checkpoint into an LDR model (mirrors eval.build_model)."""
48
+ ck = torch.load(ckpt_path, map_location="cpu", weights_only=False)
49
+ arch = dict(OURS_ARCH)
50
+ cargs = ck.get("args") if isinstance(ck, dict) else None
51
+ if cargs:
52
+ arch.update(width=cargs.get("accel_width", 256),
53
+ n_kp=cargs.get("n_kp", 16),
54
+ accel_scale=cargs.get("accel_scale", 0.5),
55
+ kappa_init=cargs.get("kappa_init", cargs.get("accel_init_damp", 0.15)))
56
+ model = build_ldr(**arch).to("cuda").eval()
57
+ sd = ck["model"] if (isinstance(ck, dict) and "model" in ck) else ck
58
+ sd = {k[7:] if k.startswith("module.") else k: v for k, v in sd.items()}
59
+ model.load_state_dict(sd)
60
+ return model
61
+
62
+
63
+ # Pre-download all checkpoints at module scope so they are on disk for the
64
+ # worker to stream.
65
+ _ckpt_cache = {} # task_name -> (model, img_size)
66
+ for _task, (_rel, _img) in CKPT_CONFIGS.items():
67
+ _path = hf_hub_download(MODEL_ID, _rel, repo_type="model")
68
+ _model = _load_checkpoint(_path, _img)
69
+ _ckpt_cache[_task] = (_model, _img)
70
+
71
+
72
+ def _get_model(task):
73
+ return _ckpt_cache[task]
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Frame utilities (ported from infer.py)
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def _resize(ft, fr, img_size):
81
+ """Resize a frame tensor (N, 3, H, W) and its numpy companion to img_size."""
82
+ if ft.shape[-1] != img_size:
83
+ ft = nn.functional.interpolate(ft, size=(img_size, img_size),
84
+ mode="bilinear", align_corners=False)
85
+ fr = (((ft + 1) * 127.5).clamp(0, 255).byte().permute(0, 2, 3, 1).numpy())
86
+ return ft, fr
87
+
88
+
89
+ def _load_frames(images, img_size):
90
+ """Load a list of PIL images → (float_tensor [-1,1], numpy uint8 frames)."""
91
+ fr = np.stack([np.asarray(img.convert("RGB")) for img in images], 0)
92
+ ft = (torch.from_numpy(fr.astype(np.float32)).permute(0, 3, 1, 2) / 127.5 - 1.0)
93
+ return _resize(ft, fr, img_size)
94
+
95
+
96
+ def _gen(model, frames_t, frames_np, nc):
97
+ """Run the model and concatenate conditioning frames with predictions."""
98
+ cond_t = frames_t[:nc].to("cuda")
99
+ with torch.no_grad():
100
+ pred = model(cond_t.unsqueeze(0), nc, full=False,
101
+ cond_img=cond_t[nc - 1:nc])
102
+ pf = ((pred.squeeze(0).clamp(-1, 1) + 1) * 127.5).byte().cpu() \
103
+ .permute(0, 2, 3, 1).numpy()
104
+ return np.concatenate([frames_np[:nc], pf], axis=0)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Inference function
109
+ # ---------------------------------------------------------------------------
110
+
111
+ @spaces.GPU(duration=120)
112
+ def generate(frame1, frame2, frame3, task="uniform",
113
+ fps=8, progress=gr.Progress(track_tqdm=True)):
114
+ """Roll out future video frames from three conditioning images.
115
+
116
+ Args:
117
+ frame1: First conditioning frame (earliest in the sequence).
118
+ frame2: Second conditioning frame.
119
+ frame3: Third conditioning frame (latest in the sequence).
120
+ task: Physics task type — uniform, parabola, collision, looming, or bouncing.
121
+ Use "joint" for the model trained on all five tasks.
122
+ fps: Output video frames per second.
123
+ Returns:
124
+ Path to the generated MP4 video file.
125
+ """
126
+ model, img_size = _get_model(task)
127
+ images = [frame1, frame2, frame3]
128
+ ft, fr = _load_frames(images, img_size)
129
+ all_frames = _gen(model, ft, fr, num_cond=3)
130
+
131
+ # Write to a unique temp file (concurrency-safe)
132
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False, dir="/tmp")
133
+ tmp.close()
134
+ imageio.mimwrite(tmp.name, list(all_frames), fps=fps, codec="libx264",
135
+ quality=8, output_params=["-pix_fmt", "yuv420p"])
136
+ return tmp.name
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Gradio UI
141
+ # ---------------------------------------------------------------------------
142
+
143
+ CSS = """
144
+ #col-container { max-width: 1100px; margin: 0 auto; }
145
+ .dark .gradio-container { color: var(--body-text-color); }
146
+ """
147
+
148
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
149
+ gr.Markdown("""
150
+ # Latent Dynamics Reasoning (LDR)
151
+ Upload three conditioning frames of a physical scene and the model
152
+ extrapolates ~29 future frames as video. Pick the physics task type that
153
+ matches your input (uniform motion, parabola, collision, looming, or
154
+ bouncing), or use the **joint** model trained on all five.
155
+
156
+ [Model](https://huggingface.co/haodongli/LDR) ·
157
+ [Code](https://github.com/Lat-Dyn-Reason/Lat-Dyn-Reason) ·
158
+ [Project Page](https://lat-dyn-reason.github.io/)
159
+ """)
160
+
161
+ with gr.Row():
162
+ with gr.Column(scale=3):
163
+ frame1 = gr.Image(label="Frame 1 (t=0)", type="pil",
164
+ height=200)
165
+ frame2 = gr.Image(label="Frame 2 (t=1)", type="pil",
166
+ height=200)
167
+ frame3 = gr.Image(label="Frame 3 (t=2)", type="pil",
168
+ height=200)
169
+ with gr.Column(scale=2):
170
+ task = gr.Dropdown(
171
+ choices=["uniform", "parabola", "collision", "looming",
172
+ "bouncing", "joint"],
173
+ value="uniform",
174
+ label="Task / Checkpoint",
175
+ info="Match the physics type of your scene. "
176
+ "'joint' handles all five but slightly lower quality.")
177
+ fps = gr.Slider(4, 16, value=8, step=1, label="Output FPS")
178
+ run = gr.Button("Generate Video", variant="primary")
179
+
180
+ output = gr.Video(label="Generated Video (conditioning + prediction)")
181
+
182
+ with gr.Accordion("Examples", open=True):
183
+ gr.Examples(
184
+ examples=[
185
+ # [frame1, frame2, frame3, task, fps]
186
+ ["examples/uniform/00.png", "examples/uniform/01.png",
187
+ "examples/uniform/02.png", "uniform", 8],
188
+ ["examples/parabola/00.png", "examples/parabola/01.png",
189
+ "examples/parabola/02.png", "parabola", 8],
190
+ ["examples/collision/00.png", "examples/collision/01.png",
191
+ "examples/collision/02.png", "collision", 8],
192
+ ["examples/looming/00.png", "examples/looming/01.png",
193
+ "examples/looming/02.png", "looming", 8],
194
+ ["examples/bouncing/00.png", "examples/bouncing/01.png",
195
+ "examples/bouncing/02.png", "bouncing", 8],
196
+ ["examples/uniform_pikachu/00.png",
197
+ "examples/uniform_pikachu/01.png",
198
+ "examples/uniform_pikachu/02.png", "uniform", 8],
199
+ ["examples/uniform_soccer/00.png",
200
+ "examples/uniform_soccer/01.png",
201
+ "examples/uniform_soccer/02.png", "uniform", 8],
202
+ ],
203
+ inputs=[frame1, frame2, frame3, task, fps],
204
+ outputs=output,
205
+ fn=generate,
206
+ cache_examples=True,
207
+ cache_mode="lazy",
208
+ )
209
+
210
+ run.click(
211
+ fn=generate,
212
+ inputs=[frame1, frame2, frame3, task, fps],
213
+ outputs=output,
214
+ api_name="generate",
215
+ )
216
+
217
+
218
+ if __name__ == "__main__":
219
+ demo.launch(mcp_server=True)
examples/bouncing/00.png ADDED
examples/bouncing/01.png ADDED
examples/bouncing/02.png ADDED
examples/collision/00.png ADDED
examples/collision/01.png ADDED
examples/collision/02.png ADDED
examples/looming/00.png ADDED
examples/looming/01.png ADDED
examples/looming/02.png ADDED
examples/parabola/00.png ADDED
examples/parabola/01.png ADDED
examples/parabola/02.png ADDED
examples/uniform/00.png ADDED
examples/uniform/01.png ADDED
examples/uniform/02.png ADDED
examples/uniform_pikachu/00.png ADDED
examples/uniform_pikachu/01.png ADDED
examples/uniform_pikachu/02.png ADDED
examples/uniform_soccer/00.png ADDED
examples/uniform_soccer/01.png ADDED
examples/uniform_soccer/02.png ADDED
ldr/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Latent Dynamics Reasoning (LDR). See ldr/rollout.md for the rollout derivation."""
2
+ from .model import build_ldr, LDR, PerceptualPyramidLoss
3
+
4
+ __all__ = ["build_ldr", "LDR", "PerceptualPyramidLoss"]
ldr/metrics.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PhyWorld white-box parser: extract each ball's center/radius from predicted frames and score vs GT."""
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ left_color = (255, 0, 0)
6
+ right_color = (0, 0, 255)
7
+ COLORS = [left_color, right_color]
8
+ WORLD_SCALE = 10.0
9
+
10
+
11
+ def parse_state_from_image(image_rgb, default_y, default_r1, thres=0.15, color=0):
12
+ image_copy = image_rgb.copy()
13
+ all_scaled_circles = []
14
+ if color == 0:
15
+ circle_mask = (image_copy[:, :, 0] > 127) & (image_copy[:, :, 1] < 127) & (image_copy[:, :, 2] < 127)
16
+ elif color == 1:
17
+ circle_mask = (image_copy[:, :, 0] < 127) & (image_copy[:, :, 1] < 127) & (image_copy[:, :, 2] > 127)
18
+ else:
19
+ raise ValueError("Invalid color")
20
+
21
+ area = np.sum(circle_mask)
22
+ radius = np.sqrt(area / np.pi)
23
+ scaled_radius = radius / image_copy.shape[1] * WORLD_SCALE
24
+ if area > thres:
25
+ center = (
26
+ np.mean(np.nonzero(circle_mask)[1]),
27
+ np.mean(np.nonzero(circle_mask)[0]),
28
+ )
29
+ scaled_center = (
30
+ center[0] / image_copy.shape[1] * WORLD_SCALE,
31
+ center[1] / image_copy.shape[1] * WORLD_SCALE,
32
+ )
33
+ else:
34
+ scaled_center = (WORLD_SCALE, default_y)
35
+ scaled_radius = default_r1
36
+
37
+ all_scaled_circles.append([*scaled_center, scaled_radius])
38
+ return np.array(all_scaled_circles)
39
+
40
+
41
+ def parse_state_from_image_collision(image_rgb, default_y, default_r1, default_r2, thres=0.15):
42
+ image_copy = image_rgb.copy()
43
+ all_scaled_circles = []
44
+ for ball_id, color in enumerate(COLORS):
45
+ if color == (255, 0, 0):
46
+ circle_mask = (image_copy[:, :, 0] > 127) & (image_copy[:, :, 1] < 127) & (image_copy[:, :, 2] < 127)
47
+ elif color == (0, 0, 255):
48
+ circle_mask = (image_copy[:, :, 0] < 127) & (image_copy[:, :, 1] < 127) & (image_copy[:, :, 2] > 127)
49
+ else:
50
+ raise ValueError("Invalid color")
51
+
52
+ area = np.sum(circle_mask)
53
+ radius = np.sqrt(area / np.pi)
54
+ scaled_radius = radius / image_copy.shape[1] * WORLD_SCALE
55
+ if area > thres:
56
+ center = (
57
+ np.mean(np.nonzero(circle_mask)[1]),
58
+ np.mean(np.nonzero(circle_mask)[0]),
59
+ )
60
+ scaled_center = (
61
+ center[0] / image_copy.shape[1] * WORLD_SCALE,
62
+ center[1] / image_copy.shape[1] * WORLD_SCALE,
63
+ )
64
+ else:
65
+ if ball_id == 0:
66
+ scaled_center = (0, default_y)
67
+ scaled_radius = default_r1
68
+ else:
69
+ scaled_center = (WORLD_SCALE, default_y)
70
+ scaled_radius = default_r2
71
+
72
+ all_scaled_circles.append([*scaled_center, scaled_radius])
73
+ return np.array(all_scaled_circles)
74
+
75
+
76
+ def get_last_ema(values_list, span):
77
+ series = pd.Series(values_list)
78
+ ema = series.ewm(span=span, adjust=False).mean()
79
+ return ema.iloc[-1]
80
+
81
+
82
+ def xy_metrics(list_a, list_b):
83
+ distances_x, distances_y = [], []
84
+ assert len(list_a) == len(list_b) == 1
85
+ for elem_a, elem_b in zip(list_a, list_b):
86
+ if not np.any(np.isnan(elem_a)) and not np.any(np.isnan(elem_b)):
87
+ distances_x.append(np.abs(elem_a[0] - elem_b[0]))
88
+ distances_y.append(np.abs(elem_a[1] - elem_b[1]))
89
+ x_error_avg = np.mean(distances_x) if distances_x else np.nan
90
+ y_error_avg = np.mean(distances_y) if distances_y else np.nan
91
+ return x_error_avg, y_error_avg
92
+
93
+
94
+ def evaluate_xy(rollout_frames, gt_features, init, mode, gamma=0.98, sample_freq=1, pred_states=None):
95
+ assert sample_freq == 1, 'there may be some bugs if it is greater than 1'
96
+
97
+ # keep only frames where the ball is fully in view
98
+ left_ball_r = init[0]
99
+ left_ball_init_v = init[1]
100
+ left_ball_m = init[0]**2
101
+ index = []
102
+ CONDITION_FRAMES = 4
103
+ for i, state in enumerate(gt_features):
104
+ if i < CONDITION_FRAMES-1:
105
+ continue
106
+ left_ball_x, left_ball_y = state[0], state[1]
107
+ if left_ball_x - left_ball_r >= 0 and left_ball_x + left_ball_r <= WORLD_SCALE \
108
+ and left_ball_y - left_ball_r >= 0 and left_ball_y + left_ball_r <= WORLD_SCALE:
109
+ index.append(i)
110
+
111
+ if rollout_frames is not None:
112
+ assert len(rollout_frames) == len(gt_features), f'{len(rollout_frames)}, {len(gt_features)}'
113
+ rollout_frames = rollout_frames[index]
114
+ gt_features = gt_features[index]
115
+
116
+ x_error_list, y_error_list, r_list = [], [], []
117
+ x_pos_list, y_pos_list = [], []
118
+ default_y, default_r1 = np.nan, np.nan
119
+ for rollout_frame, gt_feature in zip(rollout_frames[sample_freq-1::sample_freq], gt_features[sample_freq-1::sample_freq]):
120
+ parsed_state = parse_state_from_image(rollout_frame, default_y, default_r1, color=init[2] if len(init) >= 3 else 0)
121
+ default_y, default_r1 = parsed_state[0][1], parsed_state[0][2]
122
+ x_pos_list.append(parsed_state[:, 0])
123
+ y_pos_list.append(parsed_state[:, 1])
124
+ r_list.append(parsed_state[:, 2])
125
+ parsed_state = parsed_state[:, :2]
126
+ x_error, y_error = xy_metrics([gt_feature], parsed_state)
127
+ x_error_list.append(x_error)
128
+ y_error_list.append(y_error)
129
+ span = len(rollout_frames[sample_freq-1::sample_freq])
130
+ ema_x_error = get_last_ema(x_error_list, span=span)
131
+ ema_y_error = get_last_ema(y_error_list, span=span)
132
+
133
+ else:
134
+ assert pred_states is not None and len(pred_states) == len(gt_features), f'{len(pred_states)}, {len(gt_features)}'
135
+ pred_states = pred_states[index]
136
+ gt_features = gt_features[index]
137
+ x_pos_list = pred_states[:, 0]
138
+ y_pos_list = pred_states[:, 1]
139
+ r_list = pred_states[:, 2]
140
+
141
+ r1_list = r_list
142
+ r1_min, r1_max = min(r1_list), max(r1_list)
143
+ delta_r = r1_max - r1_min
144
+
145
+ gt_x_pos = gt_features[:, 0]
146
+ gt_x_vel = np.diff(gt_x_pos, axis=0) / (0.1 * sample_freq)
147
+ gt_y_pos = gt_features[:, 1]
148
+ gt_y_vel = np.diff(gt_y_pos, axis=0) / (0.1 * sample_freq)
149
+
150
+ x_pos = np.array(x_pos_list)
151
+ y_pos = np.array(y_pos_list)
152
+ if x_pos.shape[-1] == 1:
153
+ x_pos = np.squeeze(x_pos, -1)
154
+ y_pos = np.squeeze(y_pos, -1)
155
+
156
+ x_vel = np.diff(x_pos, axis=0) / (0.1 * sample_freq)
157
+ y_vel = np.diff(y_pos, axis=0) / (0.1 * sample_freq)
158
+
159
+ x_err_avg = np.mean(np.abs(x_pos - gt_x_pos))
160
+ y_err_avg = np.mean(np.abs(y_pos - gt_y_pos))
161
+
162
+ x_vel_err_avg = np.mean(np.abs(x_vel - gt_x_vel))
163
+ y_vel_err_avg = np.mean(np.abs(y_vel - gt_y_vel))
164
+
165
+ if mode == 'all':
166
+ return {
167
+ 'init': init,
168
+ 'x_pos': x_pos,
169
+ 'y_pos': y_pos,
170
+ 'x_vel': x_vel,
171
+ 'y_vel': y_vel,
172
+ 'r': r_list,
173
+ 'gt_x_pos': gt_x_pos,
174
+ 'gt_y_pos': gt_y_pos,
175
+ 'abs_x_err_avg': x_err_avg,
176
+ 'abs_y_err_avg': y_err_avg,
177
+ 'delta_r': delta_r,
178
+ 'abs_x_vel_err_avg': x_vel_err_avg,
179
+ 'abs_y_vel_err_avg': y_vel_err_avg,
180
+ }
181
+ else:
182
+ return ema_x_error, ema_y_error
183
+
184
+
185
+ def evaluate_xy_collision(rollout_frames, gt_features, init, mode, gamma=0.98, sample_freq=1, pred_states=None):
186
+ assert sample_freq == 1, 'there may be some bugs if it is greater than 1'
187
+
188
+ left_ball_r, right_ball_r = init[:2]
189
+ left_ball_init_v, right_ball_init_v = init[2:4]
190
+ left_ball_m, right_ball_m = init[:2]**2
191
+ index = []
192
+ CONDITION_FRAMES = 4
193
+ for i, state in enumerate(gt_features):
194
+ if i < CONDITION_FRAMES-1:
195
+ continue
196
+ left_ball_x = state[0, 0]
197
+ right_ball_x = state[1, 0]
198
+ if left_ball_x - left_ball_r >= 0 and right_ball_x + right_ball_r <= WORLD_SCALE:
199
+ index.append(i)
200
+
201
+ if rollout_frames is not None:
202
+ assert len(rollout_frames) == len(gt_features), f'{len(rollout_frames)}, {len(gt_features)}'
203
+ rollout_frames = rollout_frames[index]
204
+ gt_features = gt_features[index]
205
+
206
+ r_list = []
207
+ x_pos_list = []
208
+ y_pos_list = []
209
+ default_y, default_r1, default_r2 = np.nan, np.nan, np.nan
210
+ for rollout_frame, gt_feature in zip(rollout_frames[sample_freq-1::sample_freq], gt_features[sample_freq-1::sample_freq]):
211
+ parsed_state = parse_state_from_image_collision(rollout_frame, default_y, default_r1, default_r2)
212
+ default_y, default_r1, default_r2 = parsed_state[0][1], parsed_state[0][2], parsed_state[1][2]
213
+ x_pos_list.append(parsed_state[:, 0])
214
+ y_pos_list.append(parsed_state[:, 1])
215
+ r_list.append(parsed_state[:, 2])
216
+ parsed_state = parsed_state[:, :2]
217
+
218
+ else:
219
+ assert pred_states is not None and len(pred_states) == len(gt_features), f'{len(pred_states)}, {len(gt_features)}'
220
+ pred_states = pred_states[index]
221
+ gt_features = gt_features[index]
222
+ x_pos_list = pred_states[:, :2]
223
+ y_pos_list = pred_states[:, 2:4]
224
+ r_list = pred_states[:, 4:]
225
+
226
+ # only use frames before collision to avoid out-of-vision region
227
+ MIN_POST_FRAMES = 8
228
+ r_list = r_list[:(MIN_POST_FRAMES-CONDITION_FRAMES+1)//sample_freq]
229
+ r1_list = [x[0] for x in r_list]
230
+ r2_list = [x[1] for x in r_list]
231
+ r1_min, r1_max = min(r1_list), max(r1_list)
232
+ r2_min, r2_max = min(r2_list), max(r2_list)
233
+ delta_r = max(r1_max - r1_min, r2_max - r2_min)
234
+
235
+ gt_x_pos = gt_features[:, :, 0]
236
+ gt_y_pos = gt_features[:, :, 1]
237
+ gt_x_vel = np.diff(gt_x_pos, axis=0) / (0.1 * sample_freq)
238
+
239
+ # collision frame = first large change in GT x-velocity
240
+ collision_index = len(gt_x_vel) - 1
241
+ for i in range(1, len(gt_x_vel)):
242
+ if np.abs(gt_x_vel[i, 0] - gt_x_vel[i-1, 0]) > 0.1 or np.abs(gt_x_vel[i, 1] - gt_x_vel[i-1, 1]) > 0.1:
243
+ collision_index = i
244
+ break
245
+
246
+ x_pos = np.array(x_pos_list)
247
+ x_vel = np.diff(x_pos, axis=0) / (0.1 * sample_freq)
248
+
249
+ pre_x_err_avg = np.mean(np.abs(x_pos[:collision_index+1] - gt_x_pos[:collision_index+1]))
250
+ post_x_err_avg = np.mean(np.abs(x_pos[collision_index+1:] - gt_x_pos[collision_index+1:]))
251
+
252
+ y_pos = np.array(y_pos_list)
253
+ pre_y_err_avg = np.mean(np.abs(y_pos[:collision_index+1] - gt_y_pos[:collision_index+1]))
254
+ post_y_err_avg = np.mean(np.abs(y_pos[collision_index+1:] - gt_y_pos[collision_index+1:]))
255
+
256
+ pre_vel_err_avg = np.mean(np.abs(x_vel[:collision_index] - gt_x_vel[:collision_index]))
257
+ post_vel_err_avg = np.mean(np.abs(x_vel[collision_index+1:] - gt_x_vel[collision_index+1:]))
258
+
259
+ momentum = left_ball_m * x_vel[:, 0] + right_ball_m * x_vel[:, 1]
260
+ gt_momentum = left_ball_m * left_ball_init_v - right_ball_m * right_ball_init_v
261
+ pre_momentum_error_avg = np.mean(np.abs(momentum[:collision_index] - gt_momentum))
262
+ post_momentum_error_avg = np.mean(np.abs(momentum[collision_index+1:] - gt_momentum))
263
+
264
+ energy = left_ball_m * x_vel[:, 0]**2 / 2 + right_ball_m * x_vel[:, 1]**2 / 2
265
+ gt_energy = left_ball_m * left_ball_init_v**2 / 2 + right_ball_m * right_ball_init_v**2 / 2
266
+ pre_energy_error_avg = np.mean(np.abs(energy[:collision_index] - gt_energy))
267
+ post_energy_error_avg = np.mean(np.abs(energy[collision_index+1:] - gt_energy))
268
+
269
+ return {
270
+ 'init': init,
271
+ 'collision_index': int(collision_index),
272
+ 'pre_x_vel': x_vel[:collision_index],
273
+ 'gt_pre_x_vel': gt_x_vel[:collision_index],
274
+ 'post_x_vel': x_vel[collision_index+1:],
275
+ 'gt_post_x_vel': gt_x_vel[collision_index+1:],
276
+ 'x_pos': x_pos,
277
+ 'gt_x_pos': gt_x_pos,
278
+ 'x_vel': x_vel,
279
+ 'abs_x_err_avg': float(np.mean(np.abs(x_pos - gt_x_pos))),
280
+ 'abs_y_err_avg': float(np.mean(np.abs(y_pos - gt_y_pos))),
281
+ 'y_pos': y_pos,
282
+ 'gt_y_pos': gt_y_pos,
283
+ 'delta_r': delta_r,
284
+ 'pre_x_err_avg': pre_x_err_avg,
285
+ 'post_x_err_avg': post_x_err_avg,
286
+ 'pre_y_err_avg': pre_y_err_avg,
287
+ 'post_y_err_avg': post_y_err_avg,
288
+ 'pre_vel_err_avg': pre_vel_err_avg,
289
+ 'post_vel_err_avg': post_vel_err_avg,
290
+ 'pre_momentum_error_avg': pre_momentum_error_avg,
291
+ 'post_momentum_error_avg': post_momentum_error_avg,
292
+ 'pre_energy_error_avg': pre_energy_error_avg,
293
+ 'post_energy_error_avg': post_energy_error_avg,
294
+ }
295
+
296
+
297
+ def evaluate_xy_looming(rollout_frames, gt_pos, gt_radius, init, mode='all', sample_freq=1):
298
+ """Looming (scale dynamics): parsed ball radius r(t) vs GT radius, plus x/y position; primary axis = radius."""
299
+ CONDITION_FRAMES = 4
300
+ m = min(len(rollout_frames), len(gt_pos), len(gt_radius))
301
+ idx = []
302
+ for i in range(m):
303
+ if i < CONDITION_FRAMES - 1:
304
+ continue
305
+ x, y, r = float(gt_pos[i, 0]), float(gt_pos[i, 1]), float(gt_radius[i])
306
+ if x - r >= 0 and x + r <= WORLD_SCALE and y - r >= 0 and y + r <= WORLD_SCALE:
307
+ idx.append(i)
308
+ if len(idx) == 0:
309
+ return {'abs_r_err_avg': np.nan, 'abs_x_err_avg': np.nan, 'abs_y_err_avg': np.nan, 'abs_r_vel_err_avg': np.nan}
310
+ xs, ys, rs = [], [], []
311
+ default_y, default_r = np.nan, np.nan
312
+ for i in idx:
313
+ ps = parse_state_from_image(rollout_frames[i], default_y, default_r, color=0)
314
+ default_y, default_r = ps[0][1], ps[0][2]
315
+ xs.append(float(ps[0][0])); ys.append(float(ps[0][1])); rs.append(float(ps[0][2]))
316
+ xs, ys, rs = np.array(xs), np.array(ys), np.array(rs)
317
+ gx = np.array([float(gt_pos[i, 0]) for i in idx]); gy = np.array([float(gt_pos[i, 1]) for i in idx])
318
+ gr = np.array([float(gt_radius[i]) for i in idx])
319
+ r_vel_err = float(np.mean(np.abs(np.diff(rs) - np.diff(gr)) / (0.1 * sample_freq))) if len(rs) > 1 else np.nan
320
+ return {
321
+ 'init': init, 'r': rs, 'gt_r': gr, 'x_pos': xs, 'y_pos': ys,
322
+ 'abs_r_err_avg': float(np.mean(np.abs(rs - gr))),
323
+ 'abs_x_err_avg': float(np.mean(np.abs(xs - gx))),
324
+ 'abs_y_err_avg': float(np.mean(np.abs(ys - gy))),
325
+ 'abs_r_vel_err_avg': r_vel_err,
326
+ }
ldr/model.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LDR model: structured-latent encoder, kinematic-integration rollout, warp-render decoder, perceptual loss."""
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+
6
+
7
+ class _KeypointEnc(nn.Module):
8
+ """Frame (N,3,H,W) -> K heatmaps at H/8 -> marginal soft-argmax -> structured latent (N,K,3)=(mu_x,mu_y,sigma)."""
9
+ def __init__(self, n_kp, gn=8):
10
+ super().__init__()
11
+ self.n_kp = n_kp
12
+ self.net = nn.Sequential(
13
+ nn.Conv2d(3, 32, 7, 1, 3), nn.GroupNorm(gn, 32), nn.SiLU(),
14
+ nn.Conv2d(32, 64, 3, 2, 1), nn.GroupNorm(gn, 64), nn.SiLU(),
15
+ nn.Conv2d(64, 64, 3, 1, 1), nn.GroupNorm(gn, 64), nn.SiLU(),
16
+ nn.Conv2d(64, 96, 3, 2, 1), nn.GroupNorm(gn, 96), nn.SiLU(),
17
+ nn.Conv2d(96, 96, 3, 1, 1), nn.GroupNorm(gn, 96), nn.SiLU(),
18
+ nn.Conv2d(96, 128, 3, 2, 1), nn.GroupNorm(gn, 128), nn.SiLU(),
19
+ nn.Conv2d(128, 128, 3, 1, 1), nn.GroupNorm(gn, 128), nn.SiLU(),
20
+ nn.Conv2d(128, n_kp, 1))
21
+
22
+ def forward(self, x):
23
+ hm = self.net(x)
24
+ H, W = hm.shape[-2:]
25
+ gx = torch.linspace(-1, 1, W, device=x.device).view(1, 1, W)
26
+ gy = torch.linspace(-1, 1, H, device=x.device).view(1, 1, H)
27
+ px = torch.softmax(hm.mean(2), dim=-1)
28
+ py = torch.softmax(hm.mean(3), dim=-1)
29
+ cx = (px * gx).sum(-1); cy = (py * gy).sum(-1) # centroid mu
30
+ vx = (px * (gx - cx.unsqueeze(-1)) ** 2).sum(-1)
31
+ vy = (py * (gy - cy.unsqueeze(-1)) ** 2).sum(-1)
32
+ s = torch.sqrt(0.5 * (vx + vy) + 1e-6) # extent sigma
33
+ return torch.stack([cx, cy, s], dim=-1)
34
+
35
+
36
+ def _make_coord_grid(h, w, device, dtype=torch.float32):
37
+ y = torch.linspace(-1, 1, h, device=device, dtype=dtype)
38
+ x = torch.linspace(-1, 1, w, device=device, dtype=dtype)
39
+ xx = x.view(1, w).expand(h, w)
40
+ yy = y.view(h, 1).expand(h, w)
41
+ return torch.stack([xx, yy], dim=-1)
42
+
43
+
44
+ def _region_gaussian(centers, stds, res, device):
45
+ grid = _make_coord_grid(res, res, device).view(1, 1, res, res, 2)
46
+ c = centers.view(*centers.shape[:-1], 1, 1, 2)
47
+ var = (stds ** 2).view(*stds.shape, 1, 1).clamp(min=1e-6)
48
+ d2 = ((grid - c) ** 2).sum(-1)
49
+ return torch.exp(-0.5 * d2 / var)
50
+
51
+
52
+ class _GNSameBlock(nn.Module):
53
+ def __init__(self, cin, cout, kernel_size=3, padding=1, gn=8):
54
+ super().__init__()
55
+ self.conv = nn.Conv2d(cin, cout, kernel_size, padding=padding)
56
+ self.norm = nn.GroupNorm(gn, cout)
57
+
58
+ def forward(self, x):
59
+ return F.relu(self.norm(self.conv(x)))
60
+
61
+
62
+ class _GNDownBlock(nn.Module):
63
+ def __init__(self, cin, cout, gn=8):
64
+ super().__init__()
65
+ self.conv = nn.Conv2d(cin, cout, 3, padding=1)
66
+ self.norm = nn.GroupNorm(gn, cout)
67
+ self.pool = nn.AvgPool2d(2)
68
+
69
+ def forward(self, x):
70
+ return self.pool(F.relu(self.norm(self.conv(x))))
71
+
72
+
73
+ class _GNUpBlock(nn.Module):
74
+ def __init__(self, cin, cout, gn=8):
75
+ super().__init__()
76
+ self.conv = nn.Conv2d(cin, cout, 3, padding=1)
77
+ self.norm = nn.GroupNorm(gn, cout)
78
+
79
+ def forward(self, x):
80
+ return F.relu(self.norm(self.conv(F.interpolate(x, scale_factor=2))))
81
+
82
+
83
+ class _GNResBlock(nn.Module):
84
+ def __init__(self, c, gn=8):
85
+ super().__init__()
86
+ self.n1 = nn.GroupNorm(gn, c); self.c1 = nn.Conv2d(c, c, 3, padding=1)
87
+ self.n2 = nn.GroupNorm(gn, c); self.c2 = nn.Conv2d(c, c, 3, padding=1)
88
+
89
+ def forward(self, x):
90
+ out = self.c1(F.relu(self.n1(x)))
91
+ out = self.c2(F.relu(self.n2(out)))
92
+ return out + x
93
+
94
+
95
+ class _HGEncoder(nn.Module):
96
+ def __init__(self, block_expansion, in_features, num_blocks, max_features, gn=8):
97
+ super().__init__()
98
+ blocks = []
99
+ for i in range(num_blocks):
100
+ cin = in_features if i == 0 else min(max_features, block_expansion * (2 ** i))
101
+ cout = min(max_features, block_expansion * (2 ** (i + 1)))
102
+ blocks.append(_GNDownBlock(cin, cout, gn=gn))
103
+ self.blocks = nn.ModuleList(blocks)
104
+
105
+ def forward(self, x):
106
+ outs = [x]
107
+ for b in self.blocks:
108
+ outs.append(b(outs[-1]))
109
+ return outs
110
+
111
+
112
+ class _HGDecoder(nn.Module):
113
+ def __init__(self, block_expansion, in_features, num_blocks, max_features, gn=8):
114
+ super().__init__()
115
+ ups = []
116
+ for i in range(num_blocks)[::-1]:
117
+ cin = (1 if i == num_blocks - 1 else 2) * min(max_features, block_expansion * (2 ** (i + 1)))
118
+ cout = min(max_features, block_expansion * (2 ** i))
119
+ ups.append(_GNUpBlock(cin, cout, gn=gn))
120
+ self.ups = nn.ModuleList(ups)
121
+ self.out_filters = block_expansion + in_features
122
+
123
+ def forward(self, x):
124
+ out = x.pop()
125
+ for up in self.ups:
126
+ out = up(out)
127
+ out = torch.cat([out, x.pop()], dim=1)
128
+ return out
129
+
130
+
131
+ class _Hourglass(nn.Module):
132
+ """Small GroupNorm U-Net used by the dense-motion mask predictor."""
133
+ def __init__(self, block_expansion, in_features, num_blocks, max_features, gn=8):
134
+ super().__init__()
135
+ self.enc = _HGEncoder(block_expansion, in_features, num_blocks, max_features, gn=gn)
136
+ self.dec = _HGDecoder(block_expansion, in_features, num_blocks, max_features, gn=gn)
137
+ self.out_filters = self.dec.out_filters
138
+
139
+ def forward(self, x):
140
+ return self.dec(self.enc(x))
141
+
142
+
143
+ class _MeasuredWarpRenderer(nn.Module):
144
+ """Warp the fixed cond frame to the predicted pose (FOMM/MRAA similarity flow + Gao splat occlusion)."""
145
+
146
+ def __init__(self, num_kp, block_expansion=32, max_features=256, num_down_blocks=2,
147
+ num_bottleneck_blocks=3, flow_res=64, mask_block_expansion=32,
148
+ mask_num_blocks=4, mask_max_features=256,
149
+ occ_lo=1e-3, occ_hi=2.0, scale_min=0.04, bg_resp=0.1, gn=8):
150
+ super().__init__()
151
+ self.num_kp = num_kp; self.flow_res = flow_res
152
+ self.occ_lo = occ_lo; self.occ_hi = occ_hi
153
+ self.scale_min = scale_min; self.bg_resp = bg_resp
154
+ in_feat = (num_kp + 1) * (3 + 1)
155
+ self.hourglass = _Hourglass(mask_block_expansion, in_feat, mask_num_blocks, mask_max_features, gn=gn)
156
+ self.mask = nn.Conv2d(self.hourglass.out_filters, num_kp + 1, 7, padding=3)
157
+ self.first = _GNSameBlock(3, block_expansion, kernel_size=7, padding=3, gn=gn)
158
+ down, up = [], []
159
+ for i in range(num_down_blocks):
160
+ down.append(_GNDownBlock(min(max_features, block_expansion * (2 ** i)),
161
+ min(max_features, block_expansion * (2 ** (i + 1))), gn=gn))
162
+ for i in range(num_down_blocks):
163
+ up.append(_GNUpBlock(min(max_features, block_expansion * (2 ** (num_down_blocks - i))),
164
+ min(max_features, block_expansion * (2 ** (num_down_blocks - i - 1))), gn=gn))
165
+ self.down_blocks = nn.ModuleList(down)
166
+ self.up_blocks = nn.ModuleList(up)
167
+ self.bottleneck = nn.Sequential()
168
+ bc = min(max_features, block_expansion * (2 ** num_down_blocks))
169
+ for i in range(num_bottleneck_blocks):
170
+ self.bottleneck.add_module('r' + str(i), _GNResBlock(bc, gn=gn))
171
+ self.final = nn.Conv2d(block_expansion, 3, 7, padding=3)
172
+
173
+ # (1) per-region similarity backward flows: output(driving) grid -> source(cond) grid
174
+ def _sparse_backward(self, coords_t, coords_cond, res, device):
175
+ N = coords_t.shape[0]
176
+ grid = _make_coord_grid(res, res, device).view(1, 1, res, res, 2)
177
+ kp_t = coords_t[..., :2].reshape(N, self.num_kp, 1, 1, 2)
178
+ kp_c = coords_cond[..., :2].reshape(N, self.num_kp, 1, 1, 2)
179
+ s_t = coords_t[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
180
+ s_c = coords_cond[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
181
+ region = kp_c + (s_c / s_t) * (grid - kp_t)
182
+ bg = grid.expand(N, 1, res, res, 2)
183
+ return torch.cat([bg, region], dim=1)
184
+
185
+ def _heatmaps(self, coords_t, coords_cond, res, device):
186
+ gt = _region_gaussian(coords_t[..., :2], coords_t[..., 2].clamp(min=self.scale_min), res, device)
187
+ gc = _region_gaussian(coords_cond[..., :2], coords_cond[..., 2].clamp(min=self.scale_min), res, device)
188
+ hm = gt - gc
189
+ bg = torch.zeros(hm.shape[0], 1, res, res, device=device, dtype=hm.dtype)
190
+ return torch.cat([bg, hm], dim=1).unsqueeze(2)
191
+
192
+ # (2) dense flow = softmax-mask blend of the K+1 sparse flows (MRAA)
193
+ def _dense_flow(self, cond_img, coords_t, coords_cond):
194
+ N = coords_t.shape[0]; r = self.flow_res; device = cond_img.device
195
+ src = F.interpolate(cond_img, size=(r, r), mode='bilinear', align_corners=False)
196
+ sparse = self._sparse_backward(coords_t, coords_cond, r, device)
197
+ src_rep = src.unsqueeze(1).expand(N, self.num_kp + 1, 3, r, r).reshape(N * (self.num_kp + 1), 3, r, r)
198
+ deformed = F.grid_sample(src_rep, sparse.reshape(N * (self.num_kp + 1), r, r, 2),
199
+ align_corners=True, padding_mode='border')
200
+ deformed = deformed.view(N, self.num_kp + 1, 3, r, r)
201
+ hm = self._heatmaps(coords_t, coords_cond, r, device)
202
+ inp = torch.cat([hm, deformed], dim=2).reshape(N, (self.num_kp + 1) * 4, r, r)
203
+ mask = F.softmax(self.mask(self.hourglass(inp)), dim=1)
204
+ flow = (sparse.permute(0, 1, 4, 2, 3) * mask.unsqueeze(2)).sum(1)
205
+ return flow.permute(0, 2, 3, 1)
206
+
207
+ # (3) deterministic occlusion via forward splat (Gao), a measurement (no grad)
208
+ @torch.no_grad()
209
+ def _occlusion(self, coords_t, coords_cond):
210
+ N = coords_t.shape[0]; r = self.flow_res; device = coords_t.device
211
+ P = _make_coord_grid(r, r, device).view(1, 1, r, r, 2)
212
+ kp_t = coords_t[..., :2].reshape(N, self.num_kp, 1, 1, 2)
213
+ kp_c = coords_cond[..., :2].reshape(N, self.num_kp, 1, 1, 2)
214
+ s_t = coords_t[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
215
+ s_c = coords_cond[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
216
+ fwd = kp_t + (s_t / s_c) * (P - kp_c)
217
+ d2 = ((P - kp_c) ** 2).sum(-1)
218
+ w = torch.exp(-0.5 * d2 / (s_c.squeeze(-1) ** 2))
219
+ denom = self.bg_resp + w.sum(1, keepdim=True)
220
+ a_k = (w / denom).unsqueeze(-1)
221
+ a_bg = (self.bg_resp / denom).unsqueeze(-1)
222
+ Pxy = P.expand(N, 1, r, r, 2)
223
+ F_flow = (a_bg * Pxy + (a_k * fwd).sum(1, keepdim=True)).squeeze(1)
224
+ fx = (F_flow[..., 0] * 0.5 + 0.5) * (r - 1)
225
+ fy = (F_flow[..., 1] * 0.5 + 0.5) * (r - 1)
226
+ E = self._bilinear_splat(fx, fy, r, N)
227
+ m = ((E > self.occ_lo) & (E < self.occ_hi)).float().unsqueeze(1)
228
+ return m, E
229
+
230
+ @staticmethod
231
+ def _bilinear_splat(fx, fy, r, N):
232
+ device = fx.device
233
+ x0 = torch.floor(fx); y0 = torch.floor(fy)
234
+ wx = fx - x0; wy = fy - y0
235
+ x0 = x0.long(); y0 = y0.long(); x1 = x0 + 1; y1 = y0 + 1
236
+ E = torch.zeros(N, r * r, device=device)
237
+
238
+ def scat(xi, yi, wgt):
239
+ xi = xi.clamp(0, r - 1); yi = yi.clamp(0, r - 1)
240
+ E.scatter_add_(1, (yi * r + xi).reshape(N, -1), wgt.reshape(N, -1))
241
+ scat(x0, y0, (1 - wx) * (1 - wy)); scat(x1, y0, wx * (1 - wy))
242
+ scat(x0, y1, (1 - wx) * wy); scat(x1, y1, wx * wy)
243
+ return E.view(N, r, r)
244
+
245
+ @staticmethod
246
+ def _deform(inp, flow):
247
+ _, ho, wo, _ = flow.shape
248
+ _, _, h, w = inp.shape
249
+ if ho != h or wo != w:
250
+ flow = F.interpolate(flow.permute(0, 3, 1, 2), size=(h, w), mode='bilinear',
251
+ align_corners=False).permute(0, 2, 3, 1)
252
+ return F.grid_sample(inp, flow, align_corners=True, padding_mode='border')
253
+
254
+ @staticmethod
255
+ def _gate(warped, prev, occ):
256
+ if occ.shape[2:] != warped.shape[2:]:
257
+ occ = F.interpolate(occ, size=warped.shape[2:], mode='bilinear', align_corners=False)
258
+ if prev is None:
259
+ return warped * occ
260
+ return warped * occ + prev * (1 - occ)
261
+
262
+ # (4) generator: MRAA skips + occlusion-gated warp + final source-pixel blend
263
+ def forward(self, cond_img, coords_t, coords_cond):
264
+ flow = self._dense_flow(cond_img, coords_t, coords_cond)
265
+ occ, _ = self._occlusion(coords_t, coords_cond)
266
+ out = self.first(cond_img)
267
+ skips = [out]
268
+ for db in self.down_blocks:
269
+ out = db(out); skips.append(out)
270
+ out = self._gate(self._deform(out, flow), None, occ)
271
+ out = self.bottleneck(out)
272
+ for i, ub in enumerate(self.up_blocks):
273
+ out = self._gate(self._deform(skips[-(i + 1)], flow), out, occ)
274
+ out = ub(out)
275
+ out = self._gate(self._deform(skips[0], flow), out, occ)
276
+ out = torch.sigmoid(self.final(out))
277
+ return self._gate(self._deform(cond_img, flow), out, occ)
278
+
279
+
280
+ class _AntiAliasInterpolation2d(nn.Module):
281
+ def __init__(self, channels, scale):
282
+ super().__init__()
283
+ sigma = (1 / scale - 1) / 2
284
+ kernel_size = 2 * round(sigma * 4) + 1
285
+ self.ka = kernel_size // 2
286
+ self.kb = self.ka - 1 if kernel_size % 2 == 0 else self.ka
287
+ kernel = 1
288
+ grids = torch.meshgrid([torch.arange(kernel_size, dtype=torch.float32)] * 2, indexing='ij')
289
+ for size, mgrid in zip([kernel_size, kernel_size], grids):
290
+ mean = (size - 1) / 2
291
+ kernel = kernel * torch.exp(-(mgrid - mean) ** 2 / (2 * sigma ** 2))
292
+ kernel = kernel / kernel.sum()
293
+ kernel = kernel.view(1, 1, *kernel.shape).repeat(channels, 1, 1, 1)
294
+ self.register_buffer('weight', kernel)
295
+ self.groups = channels; self.scale = scale
296
+ self.int_inv_scale = int(1 / scale)
297
+
298
+ def forward(self, x):
299
+ if self.scale == 1.0:
300
+ return x
301
+ out = F.pad(x, (self.ka, self.kb, self.ka, self.kb))
302
+ out = F.conv2d(out, weight=self.weight, groups=self.groups)
303
+ return out[:, :, ::self.int_inv_scale, ::self.int_inv_scale]
304
+
305
+
306
+ class _ImagePyramide(nn.Module):
307
+ def __init__(self, scales, num_channels=3):
308
+ super().__init__()
309
+ self.scales = list(scales)
310
+ self.downs = nn.ModuleDict({str(s).replace('.', '-'): _AntiAliasInterpolation2d(num_channels, s)
311
+ for s in scales})
312
+
313
+ def forward(self, x):
314
+ return {'prediction_' + str(s): self.downs[str(s).replace('.', '-')](x) for s in self.scales}
315
+
316
+
317
+ def _load_vgg19_features():
318
+ """Load ImageNet VGG-19 features from PHYWORLD_VGG19_PATH, else timm 'vgg19.tv_in1k' (== torchvision weights)."""
319
+ import os
320
+ from torchvision import models
321
+ net = models.vgg19(weights=None)
322
+ tried = []
323
+ path = os.environ.get('PHYWORLD_VGG19_PATH', '')
324
+ if path and os.path.exists(path):
325
+ try:
326
+ sd = torch.load(path, map_location='cpu', weights_only=False)
327
+ sd = {(k[9:] if k.startswith('features.') else k): v for k, v in sd.items()}
328
+ sd = {k: v for k, v in sd.items() if k in net.features.state_dict()}
329
+ net.features.load_state_dict(sd, strict=True)
330
+ return net.features
331
+ except Exception as e:
332
+ tried.append(f'path={e}')
333
+ try:
334
+ import timm
335
+ m = timm.create_model('vgg19.tv_in1k', pretrained=True)
336
+ sd = {k[9:]: v for k, v in m.state_dict().items() if k.startswith('features.')}
337
+ net.features.load_state_dict(sd, strict=True)
338
+ return net.features
339
+ except Exception as e:
340
+ tried.append(f'timm={e}')
341
+ raise RuntimeError('VGG19 features unavailable (' + ' | '.join(tried) + ')')
342
+
343
+
344
+ class _Vgg19(nn.Module):
345
+ def __init__(self):
346
+ super().__init__()
347
+ f = _load_vgg19_features()
348
+ self.slices = nn.ModuleList()
349
+ for lo, hi in [(0, 2), (2, 7), (7, 12), (12, 21), (21, 30)]:
350
+ s = nn.Sequential()
351
+ for x in range(lo, hi):
352
+ s.add_module(str(x), f[x])
353
+ self.slices.append(s)
354
+ self.register_buffer('mean', torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1))
355
+ self.register_buffer('std', torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1))
356
+ for p in self.parameters():
357
+ p.requires_grad_(False)
358
+
359
+ def forward(self, x):
360
+ x = (x - self.mean) / self.std
361
+ outs = []
362
+ for s in self.slices:
363
+ x = s(x); outs.append(x)
364
+ return outs
365
+
366
+
367
+ class PerceptualPyramidLoss(nn.Module):
368
+ """Multi-scale VGG-19 perceptual loss (FOMM); falls back to multi-scale pixel-L1 if VGG is unavailable."""
369
+ def __init__(self, scales=(1, 0.5, 0.25, 0.125), slice_weights=(1., 1., 1., 1., 1.)):
370
+ super().__init__()
371
+ self.scales = list(scales); self.slice_weights = slice_weights
372
+ self.pyramid = _ImagePyramide(self.scales, 3)
373
+ try:
374
+ self.vgg = _Vgg19(); self.use_vgg = True; self.note = 'vgg19-perceptual'
375
+ except Exception as e:
376
+ self.vgg = None; self.use_vgg = False; self.note = 'MS-L1-fallback:' + str(e)[:100]
377
+
378
+ def forward(self, pred, target):
379
+ pred = (pred.clamp(-1, 1) + 1) * 0.5
380
+ target = (target.clamp(-1, 1) + 1) * 0.5
381
+ pp = self.pyramid(pred); pt = self.pyramid(target)
382
+ total = pred.sum() * 0.0
383
+ for s in self.scales:
384
+ a = pp['prediction_' + str(s)]; b = pt['prediction_' + str(s)]
385
+ if self.use_vgg:
386
+ xv = self.vgg(a); yv = self.vgg(b)
387
+ for i, wgt in enumerate(self.slice_weights):
388
+ total = total + wgt * (xv[i] - yv[i].detach()).abs().mean()
389
+ else:
390
+ total = total + (a - b.detach()).abs().mean()
391
+ return total
392
+
393
+
394
+ class LDR(nn.Module):
395
+ """Encode each frame to a structured latent, roll it forward by kinematic integration, decode by warping the cond frame."""
396
+
397
+ def __init__(self, n_kp=16, num_pred=29, width=256, accel_scale=0.5, warp_flow_res=64, kappa_init=0.15):
398
+ super().__init__()
399
+ self.n_kp = n_kp; self.num_pred = num_pred; self.accel_scale = accel_scale
400
+ self.cdim = 3
401
+ # kappa (softplus of log_kappa): uncertainty gate for the measured second-order init
402
+ self.log_kappa = nn.Parameter(torch.log(torch.expm1(torch.tensor(max(float(kappa_init), 1e-3)))))
403
+ self.enc = _KeypointEnc(n_kp)
404
+ d = n_kp * self.cdim
405
+ self.g = nn.Sequential(nn.Linear(2 * d, width), nn.SiLU(),
406
+ nn.Linear(width, width), nn.SiLU(),
407
+ nn.Linear(width, d))
408
+ nn.init.zeros_(self.g[-1].weight); nn.init.zeros_(self.g[-1].bias) # zero-init: rollout starts as pure inertia
409
+ self.warp = _MeasuredWarpRenderer(num_kp=n_kp, flow_res=warp_flow_res)
410
+
411
+ def _residual(self, s, v):
412
+ return self.accel_scale * torch.tanh(self.g(torch.cat([s, v], dim=1)))
413
+
414
+ def rollout(self, c_cond, n=None):
415
+ n = self.num_pred if n is None else n
416
+ B = c_cond.shape[0]
417
+ assert c_cond.shape[1] >= 3, 'kinematic initialization needs 3 conditioning latents'
418
+ s = c_cond[:, -1].reshape(B, -1)
419
+ v = (c_cond[:, -1] - c_cond[:, -2]).reshape(B, -1)
420
+ a0_raw = (c_cond[:, -1] - 2 * c_cond[:, -2] + c_cond[:, -3]).reshape(B, -1)
421
+ kappa = F.softplus(self.log_kappa)
422
+ a0 = (a0_raw * a0_raw) / (a0_raw * a0_raw + kappa * kappa) * a0_raw
423
+ out = []
424
+ for _ in range(n):
425
+ v = v + a0 + self._residual(s, v)
426
+ s = s + v
427
+ out.append(s)
428
+ return torch.stack(out, 1).view(B, n, self.n_kp, self.cdim)
429
+
430
+ def _enc_seq(self, frames):
431
+ B, L = frames.shape[:2]
432
+ return self.enc(frames.reshape(B * L, *frames.shape[2:])).view(B, L, self.n_kp, self.cdim)
433
+
434
+ def _decode_seq(self, cond_img, coords_seq, coords_cond):
435
+ B, T = coords_seq.shape[:2]; H, W = cond_img.shape[-2:]
436
+ src01 = ((cond_img.clamp(-1, 1) + 1) * 0.5).unsqueeze(1).expand(B, T, 3, H, W).reshape(B * T, 3, H, W)
437
+ ct = coords_seq.reshape(B * T, self.n_kp, self.cdim)
438
+ cc = coords_cond.unsqueeze(1).expand(B, T, self.n_kp, self.cdim).reshape(B * T, self.n_kp, self.cdim)
439
+ out01 = self.warp(src01, ct, cc)
440
+ return (out01 * 2 - 1).view(B, T, 3, H, W)
441
+
442
+ def forward(self, frames, nc, full=False, horizon=None, cond_img=None):
443
+ coords = self._enc_seq(frames)
444
+ coords_cond = coords[:, nc - 1]
445
+ if not full:
446
+ return self._decode_seq(cond_img, self.rollout(coords[:, :nc]), coords_cond)
447
+ dec_ae = self._decode_seq(cond_img, coords, coords_cond)
448
+ roll = self.rollout(coords[:, :nc], horizon)
449
+ dec_roll = self._decode_seq(cond_img, roll, coords_cond)
450
+ return dec_roll, dec_ae, roll, coords
451
+
452
+
453
+ def build_ldr(n_kp=16, num_pred=29, width=256, accel_scale=0.5, warp_flow_res=64, kappa_init=0.15):
454
+ return LDR(n_kp=n_kp, num_pred=num_pred, width=width, accel_scale=accel_scale,
455
+ warp_flow_res=warp_flow_res, kappa_init=kappa_init)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ torch
2
+ torchvision
3
+ numpy
4
+ imageio
5
+ imageio-ffmpeg
6
+ pillow