File size: 20,902 Bytes
0cde9e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
"""LDR model: structured-latent encoder, kinematic-integration rollout, warp-render decoder, perceptual loss."""
import torch
import torch.nn as nn
import torch.nn.functional as F


class _KeypointEnc(nn.Module):
    """Frame (N,3,H,W) -> K heatmaps at H/8 -> marginal soft-argmax -> structured latent (N,K,3)=(mu_x,mu_y,sigma)."""
    def __init__(self, n_kp, gn=8):
        super().__init__()
        self.n_kp = n_kp
        self.net = nn.Sequential(
            nn.Conv2d(3, 32, 7, 1, 3), nn.GroupNorm(gn, 32), nn.SiLU(),
            nn.Conv2d(32, 64, 3, 2, 1), nn.GroupNorm(gn, 64), nn.SiLU(),
            nn.Conv2d(64, 64, 3, 1, 1), nn.GroupNorm(gn, 64), nn.SiLU(),
            nn.Conv2d(64, 96, 3, 2, 1), nn.GroupNorm(gn, 96), nn.SiLU(),
            nn.Conv2d(96, 96, 3, 1, 1), nn.GroupNorm(gn, 96), nn.SiLU(),
            nn.Conv2d(96, 128, 3, 2, 1), nn.GroupNorm(gn, 128), nn.SiLU(),
            nn.Conv2d(128, 128, 3, 1, 1), nn.GroupNorm(gn, 128), nn.SiLU(),
            nn.Conv2d(128, n_kp, 1))

    def forward(self, x):
        hm = self.net(x)
        H, W = hm.shape[-2:]
        gx = torch.linspace(-1, 1, W, device=x.device).view(1, 1, W)
        gy = torch.linspace(-1, 1, H, device=x.device).view(1, 1, H)
        px = torch.softmax(hm.mean(2), dim=-1)
        py = torch.softmax(hm.mean(3), dim=-1)
        cx = (px * gx).sum(-1); cy = (py * gy).sum(-1)             # centroid mu
        vx = (px * (gx - cx.unsqueeze(-1)) ** 2).sum(-1)
        vy = (py * (gy - cy.unsqueeze(-1)) ** 2).sum(-1)
        s = torch.sqrt(0.5 * (vx + vy) + 1e-6)                     # extent sigma
        return torch.stack([cx, cy, s], dim=-1)


def _make_coord_grid(h, w, device, dtype=torch.float32):
    y = torch.linspace(-1, 1, h, device=device, dtype=dtype)
    x = torch.linspace(-1, 1, w, device=device, dtype=dtype)
    xx = x.view(1, w).expand(h, w)
    yy = y.view(h, 1).expand(h, w)
    return torch.stack([xx, yy], dim=-1)


def _region_gaussian(centers, stds, res, device):
    grid = _make_coord_grid(res, res, device).view(1, 1, res, res, 2)
    c = centers.view(*centers.shape[:-1], 1, 1, 2)
    var = (stds ** 2).view(*stds.shape, 1, 1).clamp(min=1e-6)
    d2 = ((grid - c) ** 2).sum(-1)
    return torch.exp(-0.5 * d2 / var)


class _GNSameBlock(nn.Module):
    def __init__(self, cin, cout, kernel_size=3, padding=1, gn=8):
        super().__init__()
        self.conv = nn.Conv2d(cin, cout, kernel_size, padding=padding)
        self.norm = nn.GroupNorm(gn, cout)

    def forward(self, x):
        return F.relu(self.norm(self.conv(x)))


class _GNDownBlock(nn.Module):
    def __init__(self, cin, cout, gn=8):
        super().__init__()
        self.conv = nn.Conv2d(cin, cout, 3, padding=1)
        self.norm = nn.GroupNorm(gn, cout)
        self.pool = nn.AvgPool2d(2)

    def forward(self, x):
        return self.pool(F.relu(self.norm(self.conv(x))))


class _GNUpBlock(nn.Module):
    def __init__(self, cin, cout, gn=8):
        super().__init__()
        self.conv = nn.Conv2d(cin, cout, 3, padding=1)
        self.norm = nn.GroupNorm(gn, cout)

    def forward(self, x):
        return F.relu(self.norm(self.conv(F.interpolate(x, scale_factor=2))))


class _GNResBlock(nn.Module):
    def __init__(self, c, gn=8):
        super().__init__()
        self.n1 = nn.GroupNorm(gn, c); self.c1 = nn.Conv2d(c, c, 3, padding=1)
        self.n2 = nn.GroupNorm(gn, c); self.c2 = nn.Conv2d(c, c, 3, padding=1)

    def forward(self, x):
        out = self.c1(F.relu(self.n1(x)))
        out = self.c2(F.relu(self.n2(out)))
        return out + x


class _HGEncoder(nn.Module):
    def __init__(self, block_expansion, in_features, num_blocks, max_features, gn=8):
        super().__init__()
        blocks = []
        for i in range(num_blocks):
            cin = in_features if i == 0 else min(max_features, block_expansion * (2 ** i))
            cout = min(max_features, block_expansion * (2 ** (i + 1)))
            blocks.append(_GNDownBlock(cin, cout, gn=gn))
        self.blocks = nn.ModuleList(blocks)

    def forward(self, x):
        outs = [x]
        for b in self.blocks:
            outs.append(b(outs[-1]))
        return outs


class _HGDecoder(nn.Module):
    def __init__(self, block_expansion, in_features, num_blocks, max_features, gn=8):
        super().__init__()
        ups = []
        for i in range(num_blocks)[::-1]:
            cin = (1 if i == num_blocks - 1 else 2) * min(max_features, block_expansion * (2 ** (i + 1)))
            cout = min(max_features, block_expansion * (2 ** i))
            ups.append(_GNUpBlock(cin, cout, gn=gn))
        self.ups = nn.ModuleList(ups)
        self.out_filters = block_expansion + in_features

    def forward(self, x):
        out = x.pop()
        for up in self.ups:
            out = up(out)
            out = torch.cat([out, x.pop()], dim=1)
        return out


class _Hourglass(nn.Module):
    """Small GroupNorm U-Net used by the dense-motion mask predictor."""
    def __init__(self, block_expansion, in_features, num_blocks, max_features, gn=8):
        super().__init__()
        self.enc = _HGEncoder(block_expansion, in_features, num_blocks, max_features, gn=gn)
        self.dec = _HGDecoder(block_expansion, in_features, num_blocks, max_features, gn=gn)
        self.out_filters = self.dec.out_filters

    def forward(self, x):
        return self.dec(self.enc(x))


class _MeasuredWarpRenderer(nn.Module):
    """Warp the fixed cond frame to the predicted pose (FOMM/MRAA similarity flow + Gao splat occlusion)."""

    def __init__(self, num_kp, block_expansion=32, max_features=256, num_down_blocks=2,
                 num_bottleneck_blocks=3, flow_res=64, mask_block_expansion=32,
                 mask_num_blocks=4, mask_max_features=256,
                 occ_lo=1e-3, occ_hi=2.0, scale_min=0.04, bg_resp=0.1, gn=8):
        super().__init__()
        self.num_kp = num_kp; self.flow_res = flow_res
        self.occ_lo = occ_lo; self.occ_hi = occ_hi
        self.scale_min = scale_min; self.bg_resp = bg_resp
        in_feat = (num_kp + 1) * (3 + 1)
        self.hourglass = _Hourglass(mask_block_expansion, in_feat, mask_num_blocks, mask_max_features, gn=gn)
        self.mask = nn.Conv2d(self.hourglass.out_filters, num_kp + 1, 7, padding=3)
        self.first = _GNSameBlock(3, block_expansion, kernel_size=7, padding=3, gn=gn)
        down, up = [], []
        for i in range(num_down_blocks):
            down.append(_GNDownBlock(min(max_features, block_expansion * (2 ** i)),
                                     min(max_features, block_expansion * (2 ** (i + 1))), gn=gn))
        for i in range(num_down_blocks):
            up.append(_GNUpBlock(min(max_features, block_expansion * (2 ** (num_down_blocks - i))),
                                 min(max_features, block_expansion * (2 ** (num_down_blocks - i - 1))), gn=gn))
        self.down_blocks = nn.ModuleList(down)
        self.up_blocks = nn.ModuleList(up)
        self.bottleneck = nn.Sequential()
        bc = min(max_features, block_expansion * (2 ** num_down_blocks))
        for i in range(num_bottleneck_blocks):
            self.bottleneck.add_module('r' + str(i), _GNResBlock(bc, gn=gn))
        self.final = nn.Conv2d(block_expansion, 3, 7, padding=3)

    # (1) per-region similarity backward flows: output(driving) grid -> source(cond) grid
    def _sparse_backward(self, coords_t, coords_cond, res, device):
        N = coords_t.shape[0]
        grid = _make_coord_grid(res, res, device).view(1, 1, res, res, 2)
        kp_t = coords_t[..., :2].reshape(N, self.num_kp, 1, 1, 2)
        kp_c = coords_cond[..., :2].reshape(N, self.num_kp, 1, 1, 2)
        s_t = coords_t[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
        s_c = coords_cond[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
        region = kp_c + (s_c / s_t) * (grid - kp_t)
        bg = grid.expand(N, 1, res, res, 2)
        return torch.cat([bg, region], dim=1)

    def _heatmaps(self, coords_t, coords_cond, res, device):
        gt = _region_gaussian(coords_t[..., :2], coords_t[..., 2].clamp(min=self.scale_min), res, device)
        gc = _region_gaussian(coords_cond[..., :2], coords_cond[..., 2].clamp(min=self.scale_min), res, device)
        hm = gt - gc
        bg = torch.zeros(hm.shape[0], 1, res, res, device=device, dtype=hm.dtype)
        return torch.cat([bg, hm], dim=1).unsqueeze(2)

    # (2) dense flow = softmax-mask blend of the K+1 sparse flows (MRAA)
    def _dense_flow(self, cond_img, coords_t, coords_cond):
        N = coords_t.shape[0]; r = self.flow_res; device = cond_img.device
        src = F.interpolate(cond_img, size=(r, r), mode='bilinear', align_corners=False)
        sparse = self._sparse_backward(coords_t, coords_cond, r, device)
        src_rep = src.unsqueeze(1).expand(N, self.num_kp + 1, 3, r, r).reshape(N * (self.num_kp + 1), 3, r, r)
        deformed = F.grid_sample(src_rep, sparse.reshape(N * (self.num_kp + 1), r, r, 2),
                                 align_corners=True, padding_mode='border')
        deformed = deformed.view(N, self.num_kp + 1, 3, r, r)
        hm = self._heatmaps(coords_t, coords_cond, r, device)
        inp = torch.cat([hm, deformed], dim=2).reshape(N, (self.num_kp + 1) * 4, r, r)
        mask = F.softmax(self.mask(self.hourglass(inp)), dim=1)
        flow = (sparse.permute(0, 1, 4, 2, 3) * mask.unsqueeze(2)).sum(1)
        return flow.permute(0, 2, 3, 1)

    # (3) deterministic occlusion via forward splat (Gao), a measurement (no grad)
    @torch.no_grad()
    def _occlusion(self, coords_t, coords_cond):
        N = coords_t.shape[0]; r = self.flow_res; device = coords_t.device
        P = _make_coord_grid(r, r, device).view(1, 1, r, r, 2)
        kp_t = coords_t[..., :2].reshape(N, self.num_kp, 1, 1, 2)
        kp_c = coords_cond[..., :2].reshape(N, self.num_kp, 1, 1, 2)
        s_t = coords_t[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
        s_c = coords_cond[..., 2].clamp(min=self.scale_min).reshape(N, self.num_kp, 1, 1, 1)
        fwd = kp_t + (s_t / s_c) * (P - kp_c)
        d2 = ((P - kp_c) ** 2).sum(-1)
        w = torch.exp(-0.5 * d2 / (s_c.squeeze(-1) ** 2))
        denom = self.bg_resp + w.sum(1, keepdim=True)
        a_k = (w / denom).unsqueeze(-1)
        a_bg = (self.bg_resp / denom).unsqueeze(-1)
        Pxy = P.expand(N, 1, r, r, 2)
        F_flow = (a_bg * Pxy + (a_k * fwd).sum(1, keepdim=True)).squeeze(1)
        fx = (F_flow[..., 0] * 0.5 + 0.5) * (r - 1)
        fy = (F_flow[..., 1] * 0.5 + 0.5) * (r - 1)
        E = self._bilinear_splat(fx, fy, r, N)
        m = ((E > self.occ_lo) & (E < self.occ_hi)).float().unsqueeze(1)
        return m, E

    @staticmethod
    def _bilinear_splat(fx, fy, r, N):
        device = fx.device
        x0 = torch.floor(fx); y0 = torch.floor(fy)
        wx = fx - x0; wy = fy - y0
        x0 = x0.long(); y0 = y0.long(); x1 = x0 + 1; y1 = y0 + 1
        E = torch.zeros(N, r * r, device=device)

        def scat(xi, yi, wgt):
            xi = xi.clamp(0, r - 1); yi = yi.clamp(0, r - 1)
            E.scatter_add_(1, (yi * r + xi).reshape(N, -1), wgt.reshape(N, -1))
        scat(x0, y0, (1 - wx) * (1 - wy)); scat(x1, y0, wx * (1 - wy))
        scat(x0, y1, (1 - wx) * wy);       scat(x1, y1, wx * wy)
        return E.view(N, r, r)

    @staticmethod
    def _deform(inp, flow):
        _, ho, wo, _ = flow.shape
        _, _, h, w = inp.shape
        if ho != h or wo != w:
            flow = F.interpolate(flow.permute(0, 3, 1, 2), size=(h, w), mode='bilinear',
                                 align_corners=False).permute(0, 2, 3, 1)
        return F.grid_sample(inp, flow, align_corners=True, padding_mode='border')

    @staticmethod
    def _gate(warped, prev, occ):
        if occ.shape[2:] != warped.shape[2:]:
            occ = F.interpolate(occ, size=warped.shape[2:], mode='bilinear', align_corners=False)
        if prev is None:
            return warped * occ
        return warped * occ + prev * (1 - occ)

    # (4) generator: MRAA skips + occlusion-gated warp + final source-pixel blend
    def forward(self, cond_img, coords_t, coords_cond):
        flow = self._dense_flow(cond_img, coords_t, coords_cond)
        occ, _ = self._occlusion(coords_t, coords_cond)
        out = self.first(cond_img)
        skips = [out]
        for db in self.down_blocks:
            out = db(out); skips.append(out)
        out = self._gate(self._deform(out, flow), None, occ)
        out = self.bottleneck(out)
        for i, ub in enumerate(self.up_blocks):
            out = self._gate(self._deform(skips[-(i + 1)], flow), out, occ)
            out = ub(out)
        out = self._gate(self._deform(skips[0], flow), out, occ)
        out = torch.sigmoid(self.final(out))
        return self._gate(self._deform(cond_img, flow), out, occ)


class _AntiAliasInterpolation2d(nn.Module):
    def __init__(self, channels, scale):
        super().__init__()
        sigma = (1 / scale - 1) / 2
        kernel_size = 2 * round(sigma * 4) + 1
        self.ka = kernel_size // 2
        self.kb = self.ka - 1 if kernel_size % 2 == 0 else self.ka
        kernel = 1
        grids = torch.meshgrid([torch.arange(kernel_size, dtype=torch.float32)] * 2, indexing='ij')
        for size, mgrid in zip([kernel_size, kernel_size], grids):
            mean = (size - 1) / 2
            kernel = kernel * torch.exp(-(mgrid - mean) ** 2 / (2 * sigma ** 2))
        kernel = kernel / kernel.sum()
        kernel = kernel.view(1, 1, *kernel.shape).repeat(channels, 1, 1, 1)
        self.register_buffer('weight', kernel)
        self.groups = channels; self.scale = scale
        self.int_inv_scale = int(1 / scale)

    def forward(self, x):
        if self.scale == 1.0:
            return x
        out = F.pad(x, (self.ka, self.kb, self.ka, self.kb))
        out = F.conv2d(out, weight=self.weight, groups=self.groups)
        return out[:, :, ::self.int_inv_scale, ::self.int_inv_scale]


class _ImagePyramide(nn.Module):
    def __init__(self, scales, num_channels=3):
        super().__init__()
        self.scales = list(scales)
        self.downs = nn.ModuleDict({str(s).replace('.', '-'): _AntiAliasInterpolation2d(num_channels, s)
                                    for s in scales})

    def forward(self, x):
        return {'prediction_' + str(s): self.downs[str(s).replace('.', '-')](x) for s in self.scales}


def _load_vgg19_features():
    """Load ImageNet VGG-19 features from PHYWORLD_VGG19_PATH, else timm 'vgg19.tv_in1k' (== torchvision weights)."""
    import os
    from torchvision import models
    net = models.vgg19(weights=None)
    tried = []
    path = os.environ.get('PHYWORLD_VGG19_PATH', '')
    if path and os.path.exists(path):
        try:
            sd = torch.load(path, map_location='cpu', weights_only=False)
            sd = {(k[9:] if k.startswith('features.') else k): v for k, v in sd.items()}
            sd = {k: v for k, v in sd.items() if k in net.features.state_dict()}
            net.features.load_state_dict(sd, strict=True)
            return net.features
        except Exception as e:
            tried.append(f'path={e}')
    try:
        import timm
        m = timm.create_model('vgg19.tv_in1k', pretrained=True)
        sd = {k[9:]: v for k, v in m.state_dict().items() if k.startswith('features.')}
        net.features.load_state_dict(sd, strict=True)
        return net.features
    except Exception as e:
        tried.append(f'timm={e}')
    raise RuntimeError('VGG19 features unavailable (' + ' | '.join(tried) + ')')


class _Vgg19(nn.Module):
    def __init__(self):
        super().__init__()
        f = _load_vgg19_features()
        self.slices = nn.ModuleList()
        for lo, hi in [(0, 2), (2, 7), (7, 12), (12, 21), (21, 30)]:
            s = nn.Sequential()
            for x in range(lo, hi):
                s.add_module(str(x), f[x])
            self.slices.append(s)
        self.register_buffer('mean', torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1))
        self.register_buffer('std', torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1))
        for p in self.parameters():
            p.requires_grad_(False)

    def forward(self, x):
        x = (x - self.mean) / self.std
        outs = []
        for s in self.slices:
            x = s(x); outs.append(x)
        return outs


class PerceptualPyramidLoss(nn.Module):
    """Multi-scale VGG-19 perceptual loss (FOMM); falls back to multi-scale pixel-L1 if VGG is unavailable."""
    def __init__(self, scales=(1, 0.5, 0.25, 0.125), slice_weights=(1., 1., 1., 1., 1.)):
        super().__init__()
        self.scales = list(scales); self.slice_weights = slice_weights
        self.pyramid = _ImagePyramide(self.scales, 3)
        try:
            self.vgg = _Vgg19(); self.use_vgg = True; self.note = 'vgg19-perceptual'
        except Exception as e:
            self.vgg = None; self.use_vgg = False; self.note = 'MS-L1-fallback:' + str(e)[:100]

    def forward(self, pred, target):
        pred = (pred.clamp(-1, 1) + 1) * 0.5
        target = (target.clamp(-1, 1) + 1) * 0.5
        pp = self.pyramid(pred); pt = self.pyramid(target)
        total = pred.sum() * 0.0
        for s in self.scales:
            a = pp['prediction_' + str(s)]; b = pt['prediction_' + str(s)]
            if self.use_vgg:
                xv = self.vgg(a); yv = self.vgg(b)
                for i, wgt in enumerate(self.slice_weights):
                    total = total + wgt * (xv[i] - yv[i].detach()).abs().mean()
            else:
                total = total + (a - b.detach()).abs().mean()
        return total


class LDR(nn.Module):
    """Encode each frame to a structured latent, roll it forward by kinematic integration, decode by warping the cond frame."""

    def __init__(self, n_kp=16, num_pred=29, width=256, accel_scale=0.5, warp_flow_res=64, kappa_init=0.15):
        super().__init__()
        self.n_kp = n_kp; self.num_pred = num_pred; self.accel_scale = accel_scale
        self.cdim = 3
        # kappa (softplus of log_kappa): uncertainty gate for the measured second-order init
        self.log_kappa = nn.Parameter(torch.log(torch.expm1(torch.tensor(max(float(kappa_init), 1e-3)))))
        self.enc = _KeypointEnc(n_kp)
        d = n_kp * self.cdim
        self.g = nn.Sequential(nn.Linear(2 * d, width), nn.SiLU(),
                               nn.Linear(width, width), nn.SiLU(),
                               nn.Linear(width, d))
        nn.init.zeros_(self.g[-1].weight); nn.init.zeros_(self.g[-1].bias)  # zero-init: rollout starts as pure inertia
        self.warp = _MeasuredWarpRenderer(num_kp=n_kp, flow_res=warp_flow_res)

    def _residual(self, s, v):
        return self.accel_scale * torch.tanh(self.g(torch.cat([s, v], dim=1)))

    def rollout(self, c_cond, n=None):
        n = self.num_pred if n is None else n
        B = c_cond.shape[0]
        assert c_cond.shape[1] >= 3, 'kinematic initialization needs 3 conditioning latents'
        s = c_cond[:, -1].reshape(B, -1)
        v = (c_cond[:, -1] - c_cond[:, -2]).reshape(B, -1)
        a0_raw = (c_cond[:, -1] - 2 * c_cond[:, -2] + c_cond[:, -3]).reshape(B, -1)
        kappa = F.softplus(self.log_kappa)
        a0 = (a0_raw * a0_raw) / (a0_raw * a0_raw + kappa * kappa) * a0_raw
        out = []
        for _ in range(n):
            v = v + a0 + self._residual(s, v)
            s = s + v
            out.append(s)
        return torch.stack(out, 1).view(B, n, self.n_kp, self.cdim)

    def _enc_seq(self, frames):
        B, L = frames.shape[:2]
        return self.enc(frames.reshape(B * L, *frames.shape[2:])).view(B, L, self.n_kp, self.cdim)

    def _decode_seq(self, cond_img, coords_seq, coords_cond):
        B, T = coords_seq.shape[:2]; H, W = cond_img.shape[-2:]
        src01 = ((cond_img.clamp(-1, 1) + 1) * 0.5).unsqueeze(1).expand(B, T, 3, H, W).reshape(B * T, 3, H, W)
        ct = coords_seq.reshape(B * T, self.n_kp, self.cdim)
        cc = coords_cond.unsqueeze(1).expand(B, T, self.n_kp, self.cdim).reshape(B * T, self.n_kp, self.cdim)
        out01 = self.warp(src01, ct, cc)
        return (out01 * 2 - 1).view(B, T, 3, H, W)

    def forward(self, frames, nc, full=False, horizon=None, cond_img=None):
        coords = self._enc_seq(frames)
        coords_cond = coords[:, nc - 1]
        if not full:
            return self._decode_seq(cond_img, self.rollout(coords[:, :nc]), coords_cond)
        dec_ae = self._decode_seq(cond_img, coords, coords_cond)
        roll = self.rollout(coords[:, :nc], horizon)
        dec_roll = self._decode_seq(cond_img, roll, coords_cond)
        return dec_roll, dec_ae, roll, coords


def build_ldr(n_kp=16, num_pred=29, width=256, accel_scale=0.5, warp_flow_res=64, kappa_init=0.15):
    return LDR(n_kp=n_kp, num_pred=num_pred, width=width, accel_scale=accel_scale,
               warp_flow_res=warp_flow_res, kappa_init=kappa_init)