jc-builds commited on
Commit
ebec118
·
verified ·
1 Parent(s): 0245cad

LiquidInbetween 1.0: on-device photo in-betweener (Core ML FP16 + PyTorch weights)

Browse files
.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
+ ![iOS 17+](https://img.shields.io/badge/iOS-17%2B-blue) ![Core ML](https://img.shields.io/badge/Core%20ML-8.6%20MB-green) ![MIT](https://img.shields.io/badge/license-MIT-lightgrey)
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)