LiquidInbetween 1.0: on-device photo in-betweener (Core ML FP16 + PyTorch weights)
Browse files- .gitattributes +1 -0
- LICENSE +28 -0
- LiquidInbetweenNet.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
- LiquidInbetweenNet.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
- LiquidInbetweenNet.mlpackage/Manifest.json +18 -0
- README.md +123 -0
- __pycache__/liquid_model.cpython-314.pyc +0 -0
- liquid_inbetween.safetensors +3 -0
- liquid_model.py +119 -0
- review.mp4 +3 -0
- training/SOURCES.tsv +10 -0
- training/check_coreml.py +25 -0
- training/eval_motion.py +59 -0
- training/export_coreml.py +77 -0
- training/prepare_frames.py +25 -0
- training/train.py +165 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* 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
|
|
|
|
|
|
| 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 |
+
review.mp4 filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Haplo LLC
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
The network architecture and starting weights are RIFE (ECCV 2022),
|
| 26 |
+
https://github.com/hzwer/ECCV2022-RIFE, released under the MIT License:
|
| 27 |
+
|
| 28 |
+
Copyright (c) 2021 hzwer
|
LiquidInbetweenNet.mlpackage/Data/com.apple.CoreML/model.mlmodel
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3e5b28063b63c4a923d7f0aba90a154a32e5876dd6e61018d3ab745e555b07f6
|
| 3 |
+
size 191434
|
LiquidInbetweenNet.mlpackage/Data/com.apple.CoreML/weights/weight.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0a9e61af9ce4ffba98235445b31d9ba552bfa20ae217c281c0982112ead31720
|
| 3 |
+
size 8782644
|
LiquidInbetweenNet.mlpackage/Manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fileFormatVersion": "1.0.0",
|
| 3 |
+
"itemInfoEntries": {
|
| 4 |
+
"637DC258-E1B3-4357-87ED-601746EF7735": {
|
| 5 |
+
"author": "com.apple.CoreML",
|
| 6 |
+
"description": "CoreML Model Weights",
|
| 7 |
+
"name": "weights",
|
| 8 |
+
"path": "com.apple.CoreML/weights"
|
| 9 |
+
},
|
| 10 |
+
"E7A6499F-D326-4DB5-87E9-7100DEA396D2": {
|
| 11 |
+
"author": "com.apple.CoreML",
|
| 12 |
+
"description": "CoreML Model Specification",
|
| 13 |
+
"name": "model.mlmodel",
|
| 14 |
+
"path": "com.apple.CoreML/model.mlmodel"
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
"rootModelIdentifier": "E7A6499F-D326-4DB5-87E9-7100DEA396D2"
|
| 18 |
+
}
|
README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
library_name: coreml
|
| 4 |
+
tags:
|
| 5 |
+
- frame-interpolation
|
| 6 |
+
- video-frame-interpolation
|
| 7 |
+
- coreml
|
| 8 |
+
- ios
|
| 9 |
+
- on-device
|
| 10 |
+
- rife
|
| 11 |
+
pipeline_tag: image-to-image
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# LiquidInbetween
|
| 15 |
+
|
| 16 |
+
A small learned in-betweener for photos that runs on the phone. Give it two
|
| 17 |
+
photos and a moment between them, and it returns the frame at that moment.
|
| 18 |
+
People are part-way into their next pose and things are part-way to where
|
| 19 |
+
they went. Parts that appear or disappear are handled by the network, so
|
| 20 |
+
nothing is smeared or double-exposed.
|
| 21 |
+
|
| 22 |
+
  
|
| 23 |
+
|
| 24 |
+
## Use it in an app (Core ML)
|
| 25 |
+
|
| 26 |
+
Add `LiquidInbetweenNet.mlpackage` to your Xcode target. The inputs are
|
| 27 |
+
`first` and `second` (512 × 512 images) and `t` (a 1 × 1 × 1 × 1 float); the
|
| 28 |
+
output `frame` is the 512 × 512 frame at that moment.
|
| 29 |
+
|
| 30 |
+
```swift
|
| 31 |
+
import CoreML
|
| 32 |
+
import Vision
|
| 33 |
+
|
| 34 |
+
let model = try LiquidInbetweenNet(configuration: MLModelConfiguration()).model
|
| 35 |
+
let fill: [MLFeatureValue.ImageOption: Any] = [.cropAndScale: VNImageCropAndScaleOption.scaleFill.rawValue]
|
| 36 |
+
let first = try MLFeatureValue(cgImage: photoA, pixelsWide: 512, pixelsHigh: 512,
|
| 37 |
+
pixelFormatType: kCVPixelFormatType_32BGRA, options: fill)
|
| 38 |
+
let second = try MLFeatureValue(cgImage: photoB, pixelsWide: 512, pixelsHigh: 512,
|
| 39 |
+
pixelFormatType: kCVPixelFormatType_32BGRA, options: fill)
|
| 40 |
+
let t = try MLMultiArray(shape: [1, 1, 1, 1], dataType: .float32)
|
| 41 |
+
t[0] = 0.5
|
| 42 |
+
let output = try model.prediction(from: MLDictionaryFeatureProvider(dictionary: [
|
| 43 |
+
"first": first, "second": second, "t": MLFeatureValue(multiArray: t),
|
| 44 |
+
]))
|
| 45 |
+
let frame = output.featureValue(for: "frame")?.imageBufferValue // 512 × 512
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Both photos are stretched to fill the square, so the motion between them
|
| 49 |
+
stays consistent; stretch each frame back to your photos' shape to show it.
|
| 50 |
+
|
| 51 |
+
## Use it in Python
|
| 52 |
+
|
| 53 |
+
```python
|
| 54 |
+
import torch
|
| 55 |
+
from safetensors.torch import load_file
|
| 56 |
+
from liquid_model import IFNet
|
| 57 |
+
|
| 58 |
+
net = IFNet(timestep=True).eval()
|
| 59 |
+
net.load_state_dict(load_file("liquid_inbetween.safetensors"))
|
| 60 |
+
# first, second: 1 × 3 × H × W tensors in 0…1, sides divisible by 32.
|
| 61 |
+
with torch.no_grad():
|
| 62 |
+
frame = net(first, second, t=torch.tensor([[[[0.5]]]]))
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
## The model
|
| 66 |
+
|
| 67 |
+
- **Architecture.** The model is RIFE's IFNet (Huang et al., *Real-Time
|
| 68 |
+
Intermediate Flow Estimation for Video Frame Interpolation*, ECCV 2022,
|
| 69 |
+
MIT). It predicts flows from the in-between moment back to both photos,
|
| 70 |
+
plus an occlusion mask that decides which photo each pixel trusts. It has
|
| 71 |
+
about 2.3 M parameters.
|
| 72 |
+
- **What's new: any moment in one pass.** Every block also sees the timestep
|
| 73 |
+
t, and the mirrored pass sees 1 − t. RIFE alone only makes the exact
|
| 74 |
+
middle and needs recursion for other moments. The new input channel starts
|
| 75 |
+
at zero, so training begins exactly at RIFE's behaviour.
|
| 76 |
+
- **Fine-tuned for photos, not video.** Each training pair is two frames
|
| 77 |
+
0.13–0.8 s apart (photos taken moments apart), much larger gaps than
|
| 78 |
+
consecutive video frames. The target is a real frame somewhere between
|
| 79 |
+
them, at its own t. The footage is public-domain and CC0 Wikimedia
|
| 80 |
+
Commons video of people moving: dance, children playing, NASA astronaut
|
| 81 |
+
reels and military PT. See `training/SOURCES.tsv`.
|
| 82 |
+
- **Core ML.** The weights are FP16, about 8.6 MB, with image inputs and
|
| 83 |
+
output. The conversion was measured at 42.9 dB PSNR against the PyTorch
|
| 84 |
+
model. 8-bit weights halved the size but cost too much accuracy, so they
|
| 85 |
+
are not used.
|
| 86 |
+
|
| 87 |
+
Training, evaluation and export scripts are in `training/`.
|
| 88 |
+
|
| 89 |
+
## Files
|
| 90 |
+
|
| 91 |
+
- `LiquidInbetweenNet.mlpackage`: Core ML, FP16. Inputs are `first` and
|
| 92 |
+
`second` (512 × 512 RGB images) and `t` (1×1×1×1 float); the output is
|
| 93 |
+
`frame`.
|
| 94 |
+
- `liquid_inbetween.safetensors`: PyTorch weights for `IFNet(timestep=True)`
|
| 95 |
+
in `liquid_model.py`.
|
| 96 |
+
- `review.mp4`: the model's frames beside a plain cross-fade, on held-out
|
| 97 |
+
footage.
|
| 98 |
+
|
| 99 |
+
## Results on held-out footage
|
| 100 |
+
|
| 101 |
+
The evaluation set is a different video from any used in training: a
|
| 102 |
+
public-domain folk dance, with gaps of 0.4–0.8 s. Scores are PSNR (dB) at
|
| 103 |
+
t = ¼, ½ and ¾.
|
| 104 |
+
|
| 105 |
+
| | Whole frame | Moving pixels only |
|
| 106 |
+
|---|---|---|
|
| 107 |
+
| Plain cross-fade | 36.97 | 22.74 |
|
| 108 |
+
| RIFE (recursive for ¼, ¾) | 36.50 | 22.60 |
|
| 109 |
+
| **LiquidInbetween** (one pass) | **36.77** | **22.92** |
|
| 110 |
+
|
| 111 |
+
PSNR rewards blurry cross-fades, so it undersells sharp in-betweens: compare
|
| 112 |
+
`review.mp4`.
|
| 113 |
+
|
| 114 |
+
## Limits
|
| 115 |
+
|
| 116 |
+
This is an in-betweener, not a generative video model. It moves what is in
|
| 117 |
+
the two photos and does not invent content that is in neither. Two
|
| 118 |
+
unrelated photos become a flowing dissolve, not a semantic morph.
|
| 119 |
+
|
| 120 |
+
## License
|
| 121 |
+
|
| 122 |
+
MIT, as is RIFE's code and weights it starts from (© 2021 hzwer). Made by
|
| 123 |
+
[Haplo LLC](https://haploapp.com).
|
__pycache__/liquid_model.cpython-314.pyc
ADDED
|
Binary file (10.5 kB). View file
|
|
|
liquid_inbetween.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:24544362f618979594c82aa641e599f94ac9fc10d556f81c1c7aea4451670a14
|
| 3 |
+
size 9126276
|
liquid_model.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LiquidInbetween: a small learned in-betweener for photos.
|
| 2 |
+
|
| 3 |
+
The architecture is RIFE's IFNet (Huang et al., ECCV 2022, MIT licence,
|
| 4 |
+
github.com/hzwer/ECCV2022-RIFE), extended with a timestep input so one pass
|
| 5 |
+
gives the frame at any t in (0, 1), not only the middle. The extra channel
|
| 6 |
+
starts at zero, so training begins exactly at RIFE's behaviour at t = 0.5.
|
| 7 |
+
"""
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def conv(cin, cout, k=3, s=1, p=1):
|
| 14 |
+
return nn.Sequential(nn.Conv2d(cin, cout, k, s, p, bias=True), nn.PReLU(cout))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def base_grid(n, h, w, device, dtype):
|
| 18 |
+
gx = torch.linspace(-1.0, 1.0, w, device=device, dtype=dtype).view(1, 1, 1, w).expand(n, -1, h, -1)
|
| 19 |
+
gy = torch.linspace(-1.0, 1.0, h, device=device, dtype=dtype).view(1, 1, h, 1).expand(n, -1, -1, w)
|
| 20 |
+
return torch.cat([gx, gy], 1)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def warp(img, flow):
|
| 24 |
+
"""Backward warp: each output pixel reads `img` at its position + `flow` (pixels)."""
|
| 25 |
+
n, _, h, w = flow.shape
|
| 26 |
+
f = torch.cat([flow[:, 0:1] / ((w - 1.0) / 2.0), flow[:, 1:2] / ((h - 1.0) / 2.0)], 1)
|
| 27 |
+
# Clamping the grid gives border padding on every backend (MPS and Core ML too).
|
| 28 |
+
g = (base_grid(n, h, w, flow.device, flow.dtype) + f).clamp(-1.0, 1.0).permute(0, 2, 3, 1)
|
| 29 |
+
return F.grid_sample(img, g, mode="bilinear", padding_mode="zeros", align_corners=True)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class IFBlock(nn.Module):
|
| 33 |
+
def __init__(self, in_planes, c=90):
|
| 34 |
+
super().__init__()
|
| 35 |
+
self.conv0 = nn.Sequential(conv(in_planes, c // 2, 3, 2, 1), conv(c // 2, c, 3, 2, 1))
|
| 36 |
+
self.convblock0 = nn.Sequential(conv(c, c), conv(c, c))
|
| 37 |
+
self.convblock1 = nn.Sequential(conv(c, c), conv(c, c))
|
| 38 |
+
self.convblock2 = nn.Sequential(conv(c, c), conv(c, c))
|
| 39 |
+
self.convblock3 = nn.Sequential(conv(c, c), conv(c, c))
|
| 40 |
+
self.conv1 = nn.Sequential(nn.ConvTranspose2d(c, c // 2, 4, 2, 1), nn.PReLU(c // 2), nn.ConvTranspose2d(c // 2, 4, 4, 2, 1))
|
| 41 |
+
self.conv2 = nn.Sequential(nn.ConvTranspose2d(c, c // 2, 4, 2, 1), nn.PReLU(c // 2), nn.ConvTranspose2d(c // 2, 1, 4, 2, 1))
|
| 42 |
+
|
| 43 |
+
def forward(self, x, flow, scale):
|
| 44 |
+
x = F.interpolate(x, scale_factor=1.0 / scale, mode="bilinear", align_corners=False)
|
| 45 |
+
flow = F.interpolate(flow, scale_factor=1.0 / scale, mode="bilinear", align_corners=False) / scale
|
| 46 |
+
feat = self.conv0(torch.cat((x, flow), 1))
|
| 47 |
+
feat = self.convblock0(feat) + feat
|
| 48 |
+
feat = self.convblock1(feat) + feat
|
| 49 |
+
feat = self.convblock2(feat) + feat
|
| 50 |
+
feat = self.convblock3(feat) + feat
|
| 51 |
+
flow = F.interpolate(self.conv1(feat), scale_factor=scale, mode="bilinear", align_corners=False) * scale
|
| 52 |
+
mask = F.interpolate(self.conv2(feat), scale_factor=scale, mode="bilinear", align_corners=False)
|
| 53 |
+
return flow, mask
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class IFNet(nn.Module):
|
| 57 |
+
"""RIFE's IFNet. With `timestep`, every block also sees t (and the
|
| 58 |
+
mirrored pass 1 − t), so the frame at any t comes out in one pass."""
|
| 59 |
+
|
| 60 |
+
def __init__(self, timestep=False, c=90):
|
| 61 |
+
super().__init__()
|
| 62 |
+
self.timestep = timestep
|
| 63 |
+
extra = 1 if timestep else 0
|
| 64 |
+
self.block0 = IFBlock(7 + 4 + extra, c)
|
| 65 |
+
self.block1 = IFBlock(7 + 4 + extra, c)
|
| 66 |
+
self.block2 = IFBlock(7 + 4 + extra, c)
|
| 67 |
+
|
| 68 |
+
def forward(self, img0, img1, t=None, all_scales=False):
|
| 69 |
+
n, _, h, w = img0.shape
|
| 70 |
+
flow = torch.zeros(n, 4, h, w, device=img0.device, dtype=img0.dtype)
|
| 71 |
+
mask = torch.zeros(n, 1, h, w, device=img0.device, dtype=img0.dtype)
|
| 72 |
+
if self.timestep:
|
| 73 |
+
tt = (t if t is not None else torch.full((n, 1, 1, 1), 0.5, device=img0.device, dtype=img0.dtype)).expand(n, 1, h, w)
|
| 74 |
+
w0, w1 = img0, img1
|
| 75 |
+
outputs = []
|
| 76 |
+
for block, scale in ((self.block0, 4), (self.block1, 2), (self.block2, 1)):
|
| 77 |
+
a = [w0, w1, mask] + ([tt] if self.timestep else [])
|
| 78 |
+
b = [w1, w0, -mask] + ([1 - tt] if self.timestep else [])
|
| 79 |
+
f0, m0 = block(torch.cat(a, 1), flow, scale)
|
| 80 |
+
f1, m1 = block(torch.cat(b, 1), torch.cat((flow[:, 2:4], flow[:, :2]), 1), scale)
|
| 81 |
+
flow = flow + (f0 + torch.cat((f1[:, 2:4], f1[:, :2]), 1)) / 2
|
| 82 |
+
mask = mask + (m0 - m1) / 2
|
| 83 |
+
w0 = warp(img0, flow[:, :2])
|
| 84 |
+
w1 = warp(img1, flow[:, 2:4])
|
| 85 |
+
if all_scales:
|
| 86 |
+
m = torch.sigmoid(mask)
|
| 87 |
+
outputs.append(w0 * m + w1 * (1 - m))
|
| 88 |
+
m = torch.sigmoid(mask)
|
| 89 |
+
out = w0 * m + w1 * (1 - m)
|
| 90 |
+
return outputs if all_scales else out
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def load_teacher(path):
|
| 94 |
+
"""RIFE's ECCV 2022 weights (safetensors) into IFNet()."""
|
| 95 |
+
from safetensors.torch import load_file
|
| 96 |
+
net = IFNet(timestep=False)
|
| 97 |
+
state = load_file(path)
|
| 98 |
+
state = {k.replace("module.", ""): v for k, v in state.items() if not k.startswith("block_tea")}
|
| 99 |
+
missing, unexpected = net.load_state_dict(state, strict=False)
|
| 100 |
+
assert not missing, missing
|
| 101 |
+
return net
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def student_from_teacher(teacher):
|
| 105 |
+
"""IFNet(timestep=True) starting exactly as the teacher: the new t channel's
|
| 106 |
+
weights are zero."""
|
| 107 |
+
student = IFNet(timestep=True)
|
| 108 |
+
sd = student.state_dict()
|
| 109 |
+
for k, v in teacher.state_dict().items():
|
| 110 |
+
if k.endswith("conv0.0.0.weight"):
|
| 111 |
+
new = torch.zeros_like(sd[k])
|
| 112 |
+
# Inputs: warped0 (3), warped1 (3), mask (1) | t (1) | flow (4)
|
| 113 |
+
new[:, :7] = v[:, :7]
|
| 114 |
+
new[:, 8:] = v[:, 7:]
|
| 115 |
+
sd[k] = new
|
| 116 |
+
else:
|
| 117 |
+
sd[k] = v
|
| 118 |
+
student.load_state_dict(sd)
|
| 119 |
+
return student
|
review.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d4fa2ba32374cf6df884bba73b830cfa6de043d628ff68535b4ff8976f7fc766
|
| 3 |
+
size 7395081
|
training/SOURCES.tsv
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
v00.webm File:The Bakweri Mola dance.webm CC0 https://commons.wikimedia.org/wiki/File:The_Bakweri_Mola_dance.webm
|
| 2 |
+
v01.webm File:NASA Astronaut Candidate Ben Bailey Resource Reel (jsc2025m000154).webm Public domain https://commons.wikimedia.org/wiki/File:NASA_Astronaut_Candidate_Ben_Bailey_Resource_Reel_(jsc2025m000154).webm
|
| 3 |
+
v02.webm File:NASA Astronaut Candidate Anna Menon Resource Reel (jsc2025m000153).webm Public domain https://commons.wikimedia.org/wiki/File:NASA_Astronaut_Candidate_Anna_Menon_Resource_Reel_(jsc2025m000153).webm
|
| 4 |
+
v03.webm File:NASA Astronaut Candidate Imelda Muller Resource Reel (jsc2025m000157).webm Public domain https://commons.wikimedia.org/wiki/File:NASA_Astronaut_Candidate_Imelda_Muller_Resource_Reel_(jsc2025m000157).webm
|
| 5 |
+
v04.webm File:Dali-Girls-playing-20241229.webm CC0 https://commons.wikimedia.org/wiki/File:Dali-Girls-playing-20241229.webm
|
| 6 |
+
v05.webm File:B-Roll- SETAF-AF leads physical training during sergeants time (994732).webm Public_domain https://commons.wikimedia.org/wiki/File:B-Roll-_SETAF-AF_leads_physical_training_during_sergeants_time_(994732).webm
|
| 7 |
+
v06.webm File:Valiant Mark 25- MRF-SEA, SAF conduct physical training (956293).webm Public_domain https://commons.wikimedia.org/wiki/File:Valiant_Mark_25-_MRF-SEA,_SAF_conduct_physical_training_(956293).webm
|
| 8 |
+
|
| 9 |
+
# Held out for evaluation (never trained on)
|
| 10 |
+
v00 (eval) File:Folk dance of Austria Tyrol.webm Public domain https://commons.wikimedia.org/wiki/File:Folk_dance_of_Austria_Tyrol.webm
|
training/check_coreml.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compare a Core ML export with the PyTorch model on a real pair."""
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import coremltools as ct
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from PIL import Image
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 9 |
+
from liquid_model import IFNet, load_teacher, student_from_teacher
|
| 10 |
+
|
| 11 |
+
weights, package, a_path, b_path, out_png = sys.argv[1:6]
|
| 12 |
+
net = student_from_teacher(load_teacher(Path(weights))) if weights.endswith(".safetensors") else IFNet(timestep=True)
|
| 13 |
+
if not weights.endswith(".safetensors"):
|
| 14 |
+
net.load_state_dict(torch.load(weights, map_location="cpu"))
|
| 15 |
+
net.eval()
|
| 16 |
+
a = Image.open(a_path).convert("RGB").resize((512, 512), Image.LANCZOS)
|
| 17 |
+
b = Image.open(b_path).convert("RGB").resize((512, 512), Image.LANCZOS)
|
| 18 |
+
model = ct.models.MLModel(package)
|
| 19 |
+
coreml = np.asarray(model.predict({"first": a, "second": b, "t": np.full((1, 1, 1, 1), 0.5, np.float32)})["frame"].convert("RGB")).astype(np.float32)
|
| 20 |
+
ta, tb = (torch.from_numpy(np.asarray(x)).permute(2, 0, 1)[None].float() / 255 for x in (a, b))
|
| 21 |
+
with torch.no_grad():
|
| 22 |
+
ref = (net(ta, tb, torch.full((1, 1, 1, 1), 0.5)).clamp(0, 1)[0].permute(1, 2, 0).numpy() * 255)
|
| 23 |
+
mse = ((coreml - ref) ** 2).mean()
|
| 24 |
+
print(f"Core ML vs PyTorch PSNR: {10 * np.log10(255 ** 2 / max(mse, 1e-9)):.2f} dB")
|
| 25 |
+
Image.fromarray(np.concatenate([ref, coreml], 1).astype(np.uint8)).save(out_png)
|
training/eval_motion.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Score in-betweens where things move: PSNR over pixels whose two endpoint
|
| 2 |
+
frames differ (dilated), on held-out triplets, for a blend, RIFE and ours."""
|
| 3 |
+
import json, random, sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 9 |
+
from liquid_model import IFNet, load_teacher
|
| 10 |
+
|
| 11 |
+
eval_dir, teacher_path, ours_path = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3])
|
| 12 |
+
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
|
| 13 |
+
index = json.loads((eval_dir / "index.json").read_text())
|
| 14 |
+
videos = [(np.load(eval_dir / f"{v['name']}.npy", mmap_mode="r"), set(v["cuts"])) for v in index]
|
| 15 |
+
teacher = load_teacher(teacher_path).to(device).eval()
|
| 16 |
+
ours = IFNet(timestep=True).to(device).eval()
|
| 17 |
+
ours.load_state_dict(torch.load(ours_path, map_location=device))
|
| 18 |
+
|
| 19 |
+
random.seed(99)
|
| 20 |
+
samples = []
|
| 21 |
+
while len(samples) < 90:
|
| 22 |
+
frames, cuts = random.choice(videos)
|
| 23 |
+
gap = random.choice([6, 8, 10, 12])
|
| 24 |
+
i = random.randrange(0, len(frames) - gap)
|
| 25 |
+
if any(i < c <= i + gap for c in cuts):
|
| 26 |
+
continue
|
| 27 |
+
j = random.choice([gap // 4, gap // 2, 3 * gap // 4])
|
| 28 |
+
samples.append((frames[i], frames[i + j], frames[i + gap], j / gap))
|
| 29 |
+
|
| 30 |
+
def tensor(f):
|
| 31 |
+
return torch.from_numpy(np.ascontiguousarray(f)).permute(2, 0, 1)[None].float().div(255).to(device)
|
| 32 |
+
|
| 33 |
+
def rife_at(t, x0, x1, lo=0.0, hi=1.0, depth=0):
|
| 34 |
+
mid = teacher(x0, x1)
|
| 35 |
+
c = (lo + hi) / 2
|
| 36 |
+
if abs(c - t) < 1e-6 or depth > 4:
|
| 37 |
+
return mid
|
| 38 |
+
return rife_at(t, x0, mid, lo, c, depth + 1) if t < c else rife_at(t, mid, x1, c, hi, depth + 1)
|
| 39 |
+
|
| 40 |
+
scores = {"blend": [], "rife": [], "ours": []}
|
| 41 |
+
with torch.no_grad():
|
| 42 |
+
for a, m, b, t in samples:
|
| 43 |
+
A, M, B = tensor(a), tensor(m), tensor(b)
|
| 44 |
+
h, w = A.shape[-2:]
|
| 45 |
+
H, W = (h + 31) // 32 * 32, (w + 31) // 32 * 32
|
| 46 |
+
pad = lambda x: F.pad(x, (0, W - w, 0, H - h), mode="replicate")
|
| 47 |
+
moving = ((A - B).abs().max(1, keepdim=True)[0] > 0.08).float()
|
| 48 |
+
moving = (F.max_pool2d(moving, 9, 1, 4) > 0).float()
|
| 49 |
+
if moving.mean() < 0.01:
|
| 50 |
+
continue
|
| 51 |
+
outs = {
|
| 52 |
+
"blend": A * (1 - t) + B * t,
|
| 53 |
+
"rife": rife_at(t, pad(A), pad(B))[..., :h, :w],
|
| 54 |
+
"ours": ours(pad(A), pad(B), torch.tensor([[[[t]]]], device=device))[..., :h, :w],
|
| 55 |
+
}
|
| 56 |
+
for k, o in outs.items():
|
| 57 |
+
mse = (((o.clamp(0, 1) - M) ** 2) * moving).sum() / (moving.sum() * 3)
|
| 58 |
+
scores[k].append(-10 * torch.log10(mse + 1e-10).item())
|
| 59 |
+
print({k: round(float(np.mean(v)), 2) for k, v in scores.items()}, "on", len(scores["ours"]), "moving samples")
|
training/export_coreml.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export LiquidInbetween to Core ML.
|
| 2 |
+
|
| 3 |
+
Images in and out (RGB, 512 × 512): pass two pixel buffers and a moment
|
| 4 |
+
t ∈ (0, 1), get the frame at that moment. Weights are FP16 (about 8.6 MB).
|
| 5 |
+
|
| 6 |
+
python export_coreml.py <weights.pt | teacher.safetensors> <out.mlpackage> [--int8]
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import coremltools as ct
|
| 12 |
+
import coremltools.optimize.coreml as cto
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 17 |
+
from liquid_model import IFNet, load_teacher, student_from_teacher
|
| 18 |
+
|
| 19 |
+
SIZE = 512
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Wrapper(torch.nn.Module):
|
| 23 |
+
"""Pixel values 0...255 in and out, as Core ML image types expect."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, net):
|
| 26 |
+
super().__init__()
|
| 27 |
+
self.net = net
|
| 28 |
+
|
| 29 |
+
def forward(self, first, second, t):
|
| 30 |
+
out = self.net(first / 255.0, second / 255.0, t)
|
| 31 |
+
return (out.clamp(0, 1) * 255.0)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
weights, out = Path(sys.argv[1]), Path(sys.argv[2])
|
| 35 |
+
if weights.suffix == ".safetensors":
|
| 36 |
+
net = student_from_teacher(load_teacher(weights))
|
| 37 |
+
else:
|
| 38 |
+
net = IFNet(timestep=True)
|
| 39 |
+
net.load_state_dict(torch.load(weights, map_location="cpu"))
|
| 40 |
+
model = Wrapper(net.eval()).eval()
|
| 41 |
+
|
| 42 |
+
example = (torch.rand(1, 3, SIZE, SIZE) * 255, torch.rand(1, 3, SIZE, SIZE) * 255, torch.full((1, 1, 1, 1), 0.5))
|
| 43 |
+
with torch.no_grad():
|
| 44 |
+
traced = torch.jit.trace(model, example)
|
| 45 |
+
|
| 46 |
+
mlmodel = ct.convert(
|
| 47 |
+
traced,
|
| 48 |
+
inputs=[
|
| 49 |
+
ct.ImageType(name="first", shape=(1, 3, SIZE, SIZE), color_layout=ct.colorlayout.RGB),
|
| 50 |
+
ct.ImageType(name="second", shape=(1, 3, SIZE, SIZE), color_layout=ct.colorlayout.RGB),
|
| 51 |
+
ct.TensorType(name="t", shape=(1, 1, 1, 1), dtype=np.float32),
|
| 52 |
+
],
|
| 53 |
+
outputs=[ct.ImageType(name="frame", color_layout=ct.colorlayout.RGB)],
|
| 54 |
+
convert_to="mlprogram",
|
| 55 |
+
compute_precision=ct.precision.FLOAT16,
|
| 56 |
+
minimum_deployment_target=ct.target.iOS17,
|
| 57 |
+
)
|
| 58 |
+
if "--int8" in sys.argv:
|
| 59 |
+
# Experimental: 8-bit weights halve the size, but measured 24.7 dB against
|
| 60 |
+
# PyTorch (FP16 alone: 42.9 dB), visibly worse, so FP16 is the default.
|
| 61 |
+
# The transposed convolutions are the flow and mask heads: a tiny weight
|
| 62 |
+
# error there becomes pixels of motion error, so they stay FP16.
|
| 63 |
+
config = cto.OptimizationConfig(
|
| 64 |
+
global_config=cto.OpLinearQuantizerConfig(mode="linear_symmetric", dtype="int8", granularity="per_channel"),
|
| 65 |
+
op_type_configs={"conv_transpose": None},
|
| 66 |
+
)
|
| 67 |
+
mlmodel = cto.linear_quantize_weights(mlmodel, config)
|
| 68 |
+
mlmodel.short_description = "LiquidInbetween: the frame at moment t between two photos."
|
| 69 |
+
mlmodel.author = "Haplo LLC (jc_builds). Based on RIFE (Huang et al., ECCV 2022, MIT)."
|
| 70 |
+
mlmodel.license = "MIT"
|
| 71 |
+
mlmodel.version = "1.0"
|
| 72 |
+
mlmodel.input_description["first"] = "The first photo, 512 × 512 RGB."
|
| 73 |
+
mlmodel.input_description["second"] = "The second photo, 512 × 512 RGB."
|
| 74 |
+
mlmodel.input_description["t"] = "The moment between them, 0...1."
|
| 75 |
+
mlmodel.output_description["frame"] = "The frame at moment t."
|
| 76 |
+
mlmodel.save(str(out))
|
| 77 |
+
print("saved", out)
|
training/prepare_frames.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decode training videos to 15 fps frames (fit in 448 px) as uint8 arrays,
|
| 2 |
+
and mark scene cuts so no training triplet spans one."""
|
| 3 |
+
import json, subprocess, sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
src, out = Path(sys.argv[1]), Path(sys.argv[2])
|
| 8 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 9 |
+
index = []
|
| 10 |
+
for video in sorted(src.glob("v*.webm")):
|
| 11 |
+
probe = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
| 12 |
+
"stream=width,height", "-of", "json", str(video)], capture_output=True, text=True).stdout)
|
| 13 |
+
w, h = probe["streams"][0]["width"], probe["streams"][0]["height"]
|
| 14 |
+
s = 448 / max(w, h)
|
| 15 |
+
W, H = int(round(w * s / 8) * 8), int(round(h * s / 8) * 8)
|
| 16 |
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(video), "-vf", f"fps=15,scale={W}:{H}:flags=area",
|
| 17 |
+
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True).stdout
|
| 18 |
+
frames = np.frombuffer(raw, np.uint8).reshape(-1, H, W, 3)
|
| 19 |
+
small = frames[:, ::8, ::8].astype(np.float32).mean(-1)
|
| 20 |
+
diff = np.abs(np.diff(small, axis=0)).mean((1, 2))
|
| 21 |
+
cuts = np.where(diff > max(18.0, np.median(diff) * 6))[0] + 1 # first frame of each new shot
|
| 22 |
+
np.save(out / f"{video.stem}.npy", frames)
|
| 23 |
+
index.append({"name": video.stem, "frames": int(len(frames)), "width": W, "height": H, "cuts": cuts.tolist()})
|
| 24 |
+
print(video.stem, frames.shape, "cuts:", len(cuts))
|
| 25 |
+
(out / "index.json").write_text(json.dumps(index, indent=1))
|
training/train.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fine-tune LiquidInbetween from RIFE's weights.
|
| 2 |
+
|
| 3 |
+
Each sample is two frames 2–12 steps apart at 15 fps (0.13–0.8 s: photos
|
| 4 |
+
taken moments apart), and a real frame somewhere between them as the target
|
| 5 |
+
at its own t. The held-out set is a different video entirely.
|
| 6 |
+
|
| 7 |
+
python train.py <frames dir> <eval frames dir> <teacher.safetensors> <out dir> [iterations]
|
| 8 |
+
"""
|
| 9 |
+
import json, math, random, sys, time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 17 |
+
from liquid_model import load_teacher, student_from_teacher
|
| 18 |
+
|
| 19 |
+
frames_dir, eval_dir, teacher_path, out_dir = map(Path, sys.argv[1:5])
|
| 20 |
+
iterations = int(sys.argv[5]) if len(sys.argv) > 5 else 6000
|
| 21 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
device = torch.device("mps")
|
| 23 |
+
random.seed(7)
|
| 24 |
+
np.random.seed(7)
|
| 25 |
+
torch.manual_seed(7)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_set(d):
|
| 29 |
+
index = json.loads((d / "index.json").read_text())
|
| 30 |
+
return [(np.load(d / f"{v['name']}.npy", mmap_mode="r"), set(v["cuts"])) for v in index]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
train_set, eval_set = load_set(frames_dir), load_set(eval_dir)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def triplet(videos, gap_range, size=None):
|
| 37 |
+
"""Two frames `gap` apart with no cut between them, and one in between."""
|
| 38 |
+
while True:
|
| 39 |
+
frames, cuts = random.choices(videos, weights=[len(v[0]) for v in videos])[0]
|
| 40 |
+
gap = random.randint(*gap_range)
|
| 41 |
+
if len(frames) <= gap:
|
| 42 |
+
continue
|
| 43 |
+
i = random.randrange(0, len(frames) - gap)
|
| 44 |
+
if any(i < c <= i + gap for c in cuts):
|
| 45 |
+
continue
|
| 46 |
+
j = random.randint(1, gap - 1)
|
| 47 |
+
a, m, b = frames[i], frames[i + j], frames[i + gap]
|
| 48 |
+
t = j / gap
|
| 49 |
+
if size:
|
| 50 |
+
h, w = a.shape[:2]
|
| 51 |
+
y, x = random.randrange(0, h - size + 1), random.randrange(0, w - size + 1)
|
| 52 |
+
a, m, b = (f[y:y + size, x:x + size] for f in (a, m, b))
|
| 53 |
+
return a, m, b, t
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def to_tensor(arrays):
|
| 57 |
+
return torch.from_numpy(np.stack(arrays)).permute(0, 3, 1, 2).float().div(255)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def batch(n, size=224):
|
| 61 |
+
items = []
|
| 62 |
+
for _ in range(n):
|
| 63 |
+
a, m, b, t = triplet(train_set, (2, 12), size)
|
| 64 |
+
if random.random() < 0.5:
|
| 65 |
+
a, m, b = a[:, ::-1], m[:, ::-1], b[:, ::-1]
|
| 66 |
+
if random.random() < 0.5:
|
| 67 |
+
a, b, t = b, a, 1 - t
|
| 68 |
+
items.append((np.ascontiguousarray(a), np.ascontiguousarray(m), np.ascontiguousarray(b), t))
|
| 69 |
+
a, m, b = (to_tensor([x[k] for x in items]).to(device) for k in range(3))
|
| 70 |
+
t = torch.tensor([x[3] for x in items], dtype=torch.float32, device=device).view(-1, 1, 1, 1)
|
| 71 |
+
return a, m, b, t
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def charbonnier(x, y):
|
| 75 |
+
return torch.sqrt((x - y) ** 2 + 1e-6).mean()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def laplacian_loss(x, y, levels=4):
|
| 79 |
+
loss = 0
|
| 80 |
+
for _ in range(levels):
|
| 81 |
+
xd, yd = F.avg_pool2d(x, 2), F.avg_pool2d(y, 2)
|
| 82 |
+
loss = loss + (x - F.interpolate(xd, size=x.shape[-2:], mode="bilinear", align_corners=False)
|
| 83 |
+
- (y - F.interpolate(yd, size=y.shape[-2:], mode="bilinear", align_corners=False))).abs().mean()
|
| 84 |
+
x, y = xd, yd
|
| 85 |
+
return loss
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
teacher = load_teacher(teacher_path).to(device).eval()
|
| 89 |
+
student = student_from_teacher(load_teacher(teacher_path)).to(device)
|
| 90 |
+
for p in teacher.parameters():
|
| 91 |
+
p.requires_grad_(False)
|
| 92 |
+
|
| 93 |
+
# Fixed held-out triplets: a different video, larger gaps, three moments.
|
| 94 |
+
random.seed(123)
|
| 95 |
+
held = []
|
| 96 |
+
while len(held) < 120:
|
| 97 |
+
a, m, b, t = triplet(eval_set, (6, 10))
|
| 98 |
+
if t in (0.25, 0.5, 0.75) or len(held) % 3 == 0:
|
| 99 |
+
held.append((a, m, b, t))
|
| 100 |
+
random.seed(7)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def psnr(x, y):
|
| 104 |
+
return (-10 * torch.log10(((x - y) ** 2).mean(dim=(1, 2, 3)) + 1e-10)).mean().item()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@torch.no_grad()
|
| 108 |
+
def evaluate(net, timestep):
|
| 109 |
+
scores = {"blend": [], "model": []}
|
| 110 |
+
for a, m, b, t in held:
|
| 111 |
+
a_, m_, b_ = (to_tensor([np.ascontiguousarray(f)]).to(device) for f in (a, m, b))
|
| 112 |
+
h, w = a_.shape[-2:]
|
| 113 |
+
H, W = (h + 31) // 32 * 32, (w + 31) // 32 * 32
|
| 114 |
+
pad = lambda x: F.pad(x, (0, W - w, 0, H - h), mode="replicate")
|
| 115 |
+
if timestep:
|
| 116 |
+
out = net(pad(a_), pad(b_), torch.tensor([[[[t]]]], device=device))
|
| 117 |
+
else:
|
| 118 |
+
# RIFE only makes the middle; reach other moments by halving recursively.
|
| 119 |
+
def at(lo, hi, x0, x1, depth=0):
|
| 120 |
+
mid = net(x0, x1)
|
| 121 |
+
c = (lo + hi) / 2
|
| 122 |
+
if abs(c - t) < 1e-6 or depth > 4:
|
| 123 |
+
return mid
|
| 124 |
+
return at(lo, c, x0, mid, depth + 1) if t < c else at(c, hi, mid, x1, depth + 1)
|
| 125 |
+
out = at(0.0, 1.0, pad(a_), pad(b_))
|
| 126 |
+
out = out[..., :h, :w].clamp(0, 1)
|
| 127 |
+
scores["model"].append(psnr(out, m_))
|
| 128 |
+
scores["blend"].append(psnr(a_ * (1 - t) + b_ * t, m_))
|
| 129 |
+
return {k: float(np.mean(v)) for k, v in scores.items()}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
log = []
|
| 133 |
+
baseline = evaluate(teacher, timestep=False)
|
| 134 |
+
start = evaluate(student, timestep=True)
|
| 135 |
+
print(f"held-out PSNR: blend {baseline['blend']:.2f}, RIFE {baseline['model']:.2f}, ours at start {start['model']:.2f}", flush=True)
|
| 136 |
+
log.append({"iteration": 0, "blend": baseline["blend"], "rife": baseline["model"], "ours": start["model"]})
|
| 137 |
+
|
| 138 |
+
optimizer = torch.optim.AdamW(student.parameters(), lr=1e-4, weight_decay=1e-4)
|
| 139 |
+
best = start["model"]
|
| 140 |
+
began = time.time()
|
| 141 |
+
for it in range(1, iterations + 1):
|
| 142 |
+
lr = 1e-5 + 0.5 * (1e-4 - 1e-5) * (1 + math.cos(math.pi * it / iterations))
|
| 143 |
+
for g in optimizer.param_groups:
|
| 144 |
+
g["lr"] = lr
|
| 145 |
+
a, m, b, t = batch(8)
|
| 146 |
+
outs = student(a, b, t, all_scales=True)
|
| 147 |
+
loss = charbonnier(outs[-1], m) + 0.5 * laplacian_loss(outs[-1], m) + 0.25 * charbonnier(outs[-2], m)
|
| 148 |
+
optimizer.zero_grad(set_to_none=True)
|
| 149 |
+
loss.backward()
|
| 150 |
+
torch.nn.utils.clip_grad_norm_(student.parameters(), 1.0)
|
| 151 |
+
optimizer.step()
|
| 152 |
+
if it % 100 == 0:
|
| 153 |
+
print(f"it {it} loss {loss.item():.4f} lr {lr:.2e} {(time.time() - began) / it:.2f}s/it", flush=True)
|
| 154 |
+
if it % 500 == 0 or it == iterations:
|
| 155 |
+
student.eval()
|
| 156 |
+
score = evaluate(student, timestep=True)["model"]
|
| 157 |
+
student.train()
|
| 158 |
+
log.append({"iteration": it, "ours": score})
|
| 159 |
+
print(f"== it {it}: held-out PSNR ours {score:.2f} (RIFE {baseline['model']:.2f}, blend {baseline['blend']:.2f})", flush=True)
|
| 160 |
+
if score > best:
|
| 161 |
+
best = score
|
| 162 |
+
torch.save(student.state_dict(), out_dir / "liquid_inbetween.pt")
|
| 163 |
+
(out_dir / "log.json").write_text(json.dumps(log, indent=1))
|
| 164 |
+
torch.save(student.state_dict(), out_dir / "liquid_inbetween_last.pt")
|
| 165 |
+
print("done; best held-out PSNR", best, flush=True)
|