zziang commited on
Commit
0fd7dec
·
verified ·
1 Parent(s): 800b9cf

Upload encoder/code/k3_encoder_distill.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. encoder/code/k3_encoder_distill.py +1063 -0
encoder/code/k3_encoder_distill.py ADDED
@@ -0,0 +1,1063 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """K3 - distil the front end: photo -> (feature1, feature2), plus the matte.
2
+
3
+ The DiT is done and the decoder is done. What is still missing is the half that
4
+ turns a photo into the two conditioning tensors the DiT was trained on:
5
+
6
+ feature1 [1,4101,1280] DINOv3 ViT-H/16+ output, layer-normed over 1280
7
+ feature2 [1,4096, 128] Flux2-VAE encoder mean, BatchNorm-standardised
8
+
9
+ and the alpha matte that decides what the photo even is. All three teachers are
10
+ too big for a browser (DINO 840 M, VAE 30 M of pure convolution, BiRefNet 220 M
11
+ with deformable convs), so each gets a student:
12
+
13
+ matte small U-Net ~2 M image -> alpha (any H,W)
14
+ dino ConvNeXt-style token encoder ~19 M 1024^2 -> 4101x1280
15
+ vae small strided conv encoder ~5 M 1024^2 -> 4096x128
16
+
17
+ Why a conv student for DINO instead of a small ViT: the contract is 4101 tokens,
18
+ and a ViT at that length materialises a 4101x4101 score matrix per head unless
19
+ the attention is fused. A ConvNeXt block has no such matrix, exports to plain
20
+ Conv/LayerNorm/MatMul, and the ORT WebGPU conv path already measured 711 GFLOP/s
21
+ (PUBLISH.md section 2). The five prefix tokens (cls + 4 registers) are produced
22
+ from global average pooling, which is what those tokens mostly carry anyway.
23
+
24
+ Stages (FLIP_STAGES, default all):
25
+
26
+ setup clone upstream, download the four teacher checkpoints
27
+ prep run the teachers once, cache per-image targets + matte masks
28
+ train train the three students (resumable, checkpoints in OUT)
29
+ export ONNX + torch-vs-ORT parity, writes encoder_meta.json
30
+ e2e teacher DiT 2-step + decoder on student conditions vs teacher ones
31
+
32
+ Everything is sized for a single Colab/Kaggle T4. Defaults: 200 images, 40
33
+ epochs, batch 4 for the conv students, batch 1 for matte.
34
+
35
+ Env:
36
+ FLIP_WORK /content or /kaggle/working (auto)
37
+ FLIP_OUT output dir (default WORK/k3)
38
+ FLIP_STAGES comma list, default "setup,prep,train,export,e2e"
39
+ FLIP_N_IMG images to trace/train on (default 200)
40
+ FLIP_EPOCHS epochs per student (default 40)
41
+ FLIP_BATCH batch size for dino/vae (default 4)
42
+ FLIP_IMG_HF HF image dataset (default DownFlow/fuliji)
43
+ FLIP_IMAGE_DIR extra local images
44
+ FLIP_DINO_ARCH conv (default) or vit
45
+ FLIP_DINO_DIM student width (default 512)
46
+ FLIP_DINO_DEPTH blocks (default 8)
47
+ FLIP_FP16 export fp16 instead of fp32 (default 0)
48
+ FLIP_UPLOAD 1 = push encoder/ to idefinity/flip-web
49
+ HF_TOKEN upload token (or Kaggle secret / Colab userdata)
50
+
51
+ Run:
52
+ python3 k3_encoder_distill.py # all stages
53
+ FLIP_STAGES=prep python3 k3_encoder_distill.py
54
+ """
55
+
56
+ import argparse
57
+ import json
58
+ import math
59
+ import os
60
+ import shutil
61
+ import subprocess
62
+ import sys
63
+ import time
64
+ import traceback
65
+ import urllib.parse
66
+ import urllib.request
67
+ from pathlib import Path
68
+
69
+ WORK = Path(os.environ.get("FLIP_WORK")
70
+ or ("/content" if Path("/content").exists() else "/kaggle/working"))
71
+ REPO = WORK / "TripoSplat"
72
+ CKPT = REPO / "ckpts"
73
+ OUT = Path(os.environ.get("FLIP_OUT", str(WORK / "k3")))
74
+ CACHE = OUT / "cache"
75
+ WEIGHTS = OUT / "weights"
76
+ ONNX = OUT / "onnx"
77
+
78
+ SHA = "d8db9e0"
79
+ UPSTREAM = "https://github.com/VAST-AI-Research/TripoSplat.git"
80
+ TEACHER_REPO = "VAST-AI/TripoSplat"
81
+ PUBLISH_REPO = "idefinity/flip-student-2step"
82
+
83
+ N_IMG = int(os.environ.get("FLIP_N_IMG", "200"))
84
+ EPOCHS = int(os.environ.get("FLIP_EPOCHS", "40"))
85
+ BATCH = int(os.environ.get("FLIP_BATCH", "4"))
86
+ LR = float(os.environ.get("FLIP_LR", "3e-4"))
87
+ FP16 = os.environ.get("FLIP_FP16", "0") == "1"
88
+ UPLOAD = os.environ.get("FLIP_UPLOAD", "0") == "1"
89
+ IMG_HF = os.environ.get("FLIP_IMG_HF", "DownFlow/fuliji")
90
+ IMG_DIR = os.environ.get("FLIP_IMAGE_DIR", "")
91
+ STAGES = [s.strip() for s in os.environ.get(
92
+ "FLIP_STAGES", "setup,prep,train,export,e2e").split(",") if s.strip()]
93
+
94
+ CANVAS = 1024
95
+ DINO_OUT_TOKENS = 4101
96
+ DINO_DIM_OUT = 1280
97
+ VAE_TOKENS = 4096
98
+ VAE_DIM = 128
99
+ MATTE_SIZE = CANVAS
100
+
101
+ LOG = []
102
+
103
+
104
+ def p(*a):
105
+ s = " ".join(str(x) for x in a)
106
+ LOG.append(s)
107
+ print(s, flush=True)
108
+
109
+
110
+ def note(msg):
111
+ p(msg)
112
+
113
+
114
+ def run(cmd, timeout=1800, env=None, check=False):
115
+ p("$", " ".join(str(c) for c in cmd)[:160])
116
+ r = subprocess.run([str(c) for c in cmd], capture_output=True, text=True,
117
+ timeout=timeout, env=env)
118
+ if r.returncode != 0:
119
+ p(" ", (r.stderr or r.stdout or "")[-500:].replace("\n", " "))
120
+ if check:
121
+ raise RuntimeError(f"command failed: {cmd}")
122
+ return r
123
+
124
+
125
+ def ensure_dirs():
126
+ for d in (OUT, CACHE, WEIGHTS, ONNX):
127
+ d.mkdir(parents=True, exist_ok=True)
128
+
129
+
130
+ def hf_token():
131
+ tok = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
132
+ if tok:
133
+ return tok
134
+ try:
135
+ from kaggle_secrets import UserSecretsClient # noqa: PLC0415
136
+ return UserSecretsClient().get_secret("HF_TOKEN")
137
+ except Exception: # noqa: BLE001
138
+ return None
139
+ try:
140
+ from google.colab import userdata # noqa: PLC0415
141
+ return userdata.get("HF_TOKEN")
142
+ except Exception: # noqa: BLE001
143
+ return None
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # setup
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def deps():
151
+ pkgs = ["huggingface_hub", "safetensors", "torchvision", "tqdm",
152
+ "onnx", "onnxruntime", "Pillow"]
153
+ p("installing", " ".join(pkgs))
154
+ run([sys.executable, "-m", "pip", "install", "-q", "--no-cache-dir", *pkgs],
155
+ timeout=1800)
156
+
157
+
158
+ def setup():
159
+ deps()
160
+ if not (REPO / ".git").exists():
161
+ run(["git", "clone", "-q", UPSTREAM, str(REPO)], timeout=900, check=True)
162
+ run(["git", "-C", str(REPO), "checkout", "-q", SHA], timeout=300)
163
+ from huggingface_hub import snapshot_download # noqa: PLC0415
164
+ if not (CKPT / ".complete").exists():
165
+ p("downloading teacher checkpoints (~3.5 GB)")
166
+ snapshot_download(repo_id=TEACHER_REPO, local_dir=str(CKPT),
167
+ token=hf_token())
168
+ (CKPT / ".complete").touch()
169
+ total = sum(f.stat().st_size for f in CKPT.rglob("*") if f.is_file())
170
+ p("ckpts", round(total / 1e9, 2), "GB")
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # inputs
175
+ # ---------------------------------------------------------------------------
176
+
177
+ IMG_EXT = (".png", ".jpg", ".jpeg", ".webp")
178
+
179
+
180
+ def fetch_hf(n):
181
+ if not IMG_HF:
182
+ return []
183
+ dst = WORK / "inputs"
184
+ dst.mkdir(parents=True, exist_ok=True)
185
+ column = os.environ.get("FLIP_IMG_HF_COLUMN", "image").strip()
186
+ split = os.environ.get("FLIP_IMG_HF_SPLIT", "train").strip()
187
+
188
+ def get(url, timeout=90):
189
+ req = urllib.request.Request(url, headers={"User-Agent": "flip-k3"})
190
+ with urllib.request.urlopen(req, timeout=timeout) as fh:
191
+ return fh.read()
192
+
193
+ cfg = None
194
+ try:
195
+ q = urllib.parse.urlencode({"dataset": IMG_HF})
196
+ splits = json.loads(get(
197
+ f"https://datasets-server.huggingface.co/splits?{q}"))["splits"]
198
+ if splits:
199
+ cfg, split = splits[0].get("config"), splits[0].get("split", split)
200
+ except Exception as exc: # noqa: BLE001
201
+ p(" splits lookup failed", type(exc).__name__)
202
+
203
+ got, offset = [], 0
204
+ while len(got) < n:
205
+ q = {"dataset": IMG_HF, "split": split, "offset": offset,
206
+ "length": min(100, n - len(got))}
207
+ if cfg:
208
+ q["config"] = cfg
209
+ try:
210
+ js = json.loads(get("https://datasets-server.huggingface.co/rows?"
211
+ + urllib.parse.urlencode(q)))
212
+ except Exception as exc: # noqa: BLE001
213
+ p(" rows failed", type(exc).__name__)
214
+ break
215
+ rows = js.get("rows") or []
216
+ if not rows:
217
+ break
218
+ for r in rows:
219
+ cell = (r.get("row") or {}).get(column)
220
+ src = cell.get("src") if isinstance(cell, dict) else None
221
+ if not src:
222
+ continue
223
+ path = dst / f"hf_{len(got):04d}.jpg"
224
+ if path.exists() and path.stat().st_size > 1024:
225
+ got.append(path)
226
+ continue
227
+ try:
228
+ blob = get(src, timeout=60)
229
+ if blob[:2] != b"\xff\xd8" and blob[:8] != b"\x89PNG\r\n\x1a\n":
230
+ continue
231
+ tmp = path.with_suffix(".jpg.part")
232
+ tmp.write_bytes(blob)
233
+ os.replace(tmp, path)
234
+ got.append(path)
235
+ except Exception: # noqa: BLE001
236
+ continue
237
+ if len(got) >= n:
238
+ break
239
+ offset += len(rows)
240
+ if offset >= (js.get("num_rows_total") or 0):
241
+ break
242
+ p("fetched", len(got), "images from", IMG_HF)
243
+ return got
244
+
245
+
246
+ def pick_images():
247
+ imgs = []
248
+ if IMG_DIR:
249
+ root = Path(IMG_DIR)
250
+ if root.is_dir():
251
+ imgs += sorted(q for q in root.rglob("*")
252
+ if q.suffix.lower() in IMG_EXT)
253
+ elif root.is_file():
254
+ imgs.append(root)
255
+ p("local images", len(imgs))
256
+ if len(imgs) < N_IMG:
257
+ imgs += fetch_hf(N_IMG - len(imgs))
258
+ if len(imgs) < N_IMG:
259
+ ex = REPO / "static" / "example_inputs"
260
+ if ex.is_dir():
261
+ imgs += sorted(q for q in ex.rglob("*")
262
+ if q.suffix.lower() in IMG_EXT)
263
+ if not imgs:
264
+ raise RuntimeError("no input images: set FLIP_IMG_HF or FLIP_IMAGE_DIR")
265
+ p("inputs", len(imgs), "->", imgs[0], "...", imgs[-1])
266
+ return imgs[:N_IMG]
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # teacher + preprocessing
271
+ # ---------------------------------------------------------------------------
272
+
273
+ def load_teacher():
274
+ import torch # noqa: F401
275
+ sys.path.insert(0, str(REPO))
276
+ import triposplat as T # noqa: PLC0415
277
+ dev = "cuda" if __import__("torch").cuda.is_available() else "cpu"
278
+ dino = T.load_dinov3(str(CKPT / "clip_vision/dino_v3_vit_h.safetensors"),
279
+ device=dev, dtype=__import__("torch").bfloat16)
280
+ vae = T.load_vae_encoder(str(CKPT / "vae/flux2-vae.safetensors"),
281
+ device=dev, dtype=__import__("torch").bfloat16)
282
+ rmbg = T.load_rmbg(str(CKPT / "background_removal/birefnet.safetensors"),
283
+ device=dev, dtype=__import__("torch").float16)
284
+ return T, dino, vae, rmbg, dev
285
+
286
+
287
+ def resize_min_side(image, target=CANVAS):
288
+ w, h = image.size
289
+ s = target / min(w, h)
290
+ w2 = max(32, int(round(w * s / 32)) * 32)
291
+ h2 = max(32, int(round(h * s / 32)) * 32)
292
+ return image.resize((w2, h2), __import__("PIL.Image", fromlist=["Image"]).LANCZOS)
293
+
294
+
295
+ def erode_alpha(alpha, radius=1):
296
+ """MinFilter(2r+1) on the alpha channel, the same shrink upstream applies."""
297
+ from PIL import Image, ImageFilter # noqa: PLC0415
298
+ im = Image.fromarray(alpha, mode="L")
299
+ return im.filter(ImageFilter.MinFilter(2 * radius + 1))
300
+
301
+
302
+ def square_crop_box(alpha, expand=1.2):
303
+ import numpy as np # noqa: PLC0415
304
+ a = np.asarray(alpha)
305
+ ys, xs = np.nonzero(a)
306
+ if len(xs) == 0:
307
+ h, w = a.shape
308
+ side = min(h, w)
309
+ return (w - side) // 2, (h - side) // 2, (w + side) // 2, (h + side) // 2
310
+ x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
311
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
312
+ half = max(x1 - x0, y1 - y0) / 2 * expand
313
+ return (int(round(cx - half)), int(round(cy - half)),
314
+ int(round(cx + half)), int(round(cy + half)))
315
+
316
+
317
+ def make_sample(T, dino, vae, rmbg, path, idx):
318
+ import numpy as np # noqa: PLC0415
319
+ import torch # noqa: PLC0415
320
+ import torch.nn.functional as F # noqa: PLC0415
321
+ from PIL import Image # noqa: PLC0415
322
+ from torchvision import transforms # noqa: PLC0415
323
+
324
+ stem = f"{idx:04d}"
325
+ files = {k: CACHE / f"{k}_{stem}.npy" for k in ("f1", "f2", "canvas")}
326
+ matte = {k: CACHE / f"{k}_{stem}.png" for k in ("matte_rgb", "matte_alpha")}
327
+ if all(f.exists() for f in files.values()) and all(f.exists() for f in matte.values()):
328
+ return True
329
+
330
+ image = Image.open(path).convert("RGB")
331
+ rgb = resize_min_side(image)
332
+ with torch.no_grad():
333
+ rgba = rmbg.remove_background(rgb)
334
+ alpha_raw = np.array(rgba.getchannel(3), dtype=np.uint8)
335
+ alpha_er = np.array(erode_alpha(alpha_raw, 1), dtype=np.uint8)
336
+
337
+ box = square_crop_box(alpha_er)
338
+ cropped = rgba.crop(box).resize((CANVAS, CANVAS), Image.LANCZOS)
339
+ canvas = Image.new("RGB", (CANVAS, CANVAS), (0, 0, 0))
340
+ canvas.paste(cropped, mask=cropped.split()[3])
341
+
342
+ dino_dtype = next(dino.parameters()).dtype
343
+ vae_dtype = next(vae.parameters()).dtype
344
+ dino_device = next(dino.parameters()).device
345
+ vae_device = next(vae.parameters()).device
346
+ x = transforms.ToTensor()(canvas).unsqueeze(0)
347
+ with torch.no_grad():
348
+ f1 = dino(pixel_values=T._DINOV3_NORMALIZE(x).to(dino_device, dino_dtype))
349
+ f1 = F.layer_norm(f1.float(), f1.shape[-1:])
350
+ f2 = vae.encode(x.to(vae_device, vae_dtype) * 2 - 1, deterministic=True)
351
+
352
+ np.save(files["f1"], f1[0].half().cpu().numpy())
353
+ np.save(files["f2"], f2[0].half().cpu().numpy())
354
+ np.save(files["canvas"], (np.asarray(canvas, dtype=np.uint8).transpose(2, 0, 1)))
355
+ Image.fromarray(np.asarray(rgb, dtype=np.uint8)).save(matte["matte_rgb"])
356
+ Image.fromarray(alpha_raw).save(matte["matte_alpha"])
357
+ return True
358
+
359
+
360
+ def prep():
361
+ imgs = pick_images()
362
+ T, dino, vae, rmbg, dev = load_teacher()
363
+ p("teacher on", dev)
364
+ t0 = time.time()
365
+ ok = 0
366
+ for i, path in enumerate(imgs):
367
+ try:
368
+ if make_sample(T, dino, vae, rmbg, path, i):
369
+ ok += 1
370
+ except Exception: # noqa: BLE001
371
+ p(" skip", path.name, traceback.format_exc().splitlines()[-1][:160])
372
+ if (i + 1) % 10 == 0:
373
+ dt = time.time() - t0
374
+ p(f" prep {i + 1}/{len(imgs)} {dt / (i + 1):.2f} s/img")
375
+ p("prep done", ok, "of", len(imgs))
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # students
380
+ # ---------------------------------------------------------------------------
381
+
382
+ def build_dino():
383
+ import torch
384
+ import torch.nn as nn
385
+
386
+ class LN2d(nn.Module):
387
+ def __init__(self, dim, eps=1e-6):
388
+ super().__init__()
389
+ self.ln = nn.LayerNorm(dim, eps=eps)
390
+
391
+ def forward(self, x):
392
+ return self.ln(x.permute(0, 2, 3, 1).contiguous()).permute(0, 3, 1, 2)
393
+
394
+ class ConvBlock(nn.Module):
395
+ """ConvNeXt block: depthwise 7x7 then an inverted bottleneck."""
396
+
397
+ def __init__(self, dim, mlp_ratio=4):
398
+ super().__init__()
399
+ hidden = int(dim * mlp_ratio)
400
+ self.norm = LN2d(dim)
401
+ self.dw = nn.Conv2d(dim, dim, 7, padding=3, groups=dim)
402
+ self.pw1 = nn.Conv2d(dim, hidden, 1)
403
+ self.act = nn.GELU()
404
+ self.pw2 = nn.Conv2d(hidden, dim, 1)
405
+ self.gamma = nn.Parameter(torch.ones(dim) * 1e-6)
406
+
407
+ def forward(self, x):
408
+ h = self.norm(x)
409
+ h = self.dw(h)
410
+ h = self.pw1(h)
411
+ h = self.act(h)
412
+ h = self.pw2(h)
413
+ return x + self.gamma.view(1, -1, 1, 1) * h
414
+
415
+ class DinoConvStudent(nn.Module):
416
+ def __init__(self, dim=512, depth=8, patch=16, out_dim=DINO_DIM_OUT):
417
+ super().__init__()
418
+ self.stem = nn.Conv2d(3, dim, patch, patch)
419
+ self.stem_norm = LN2d(dim)
420
+ self.blocks = nn.Sequential(*[ConvBlock(dim) for _ in range(depth)])
421
+ self.norm = nn.LayerNorm(dim)
422
+ self.prefix = nn.Linear(dim, 5 * dim)
423
+ self.proj = nn.Linear(dim, out_dim)
424
+ self.out_norm = nn.LayerNorm(out_dim, elementwise_affine=False)
425
+
426
+ def forward(self, x):
427
+ h = self.stem_norm(self.stem(x))
428
+ h = self.blocks(h)
429
+ b, c, hh, ww = h.shape
430
+ tok = h.flatten(2).transpose(1, 2)
431
+ tok = self.norm(tok)
432
+ g = tok.mean(dim=1)
433
+ pre = self.prefix(g).reshape(b, 5, c)
434
+ tok = torch.cat([pre, tok], dim=1)
435
+ return self.out_norm(self.proj(tok))
436
+
437
+ class VitBlock(nn.Module):
438
+ def __init__(self, dim, heads=8, mlp_ratio=4):
439
+ super().__init__()
440
+ self.n1 = nn.LayerNorm(dim)
441
+ self.qkv = nn.Linear(dim, 3 * dim)
442
+ self.proj = nn.Linear(dim, dim)
443
+ self.n2 = nn.LayerNorm(dim)
444
+ hidden = dim * mlp_ratio
445
+ self.fc1 = nn.Linear(dim, hidden)
446
+ self.fc2 = nn.Linear(hidden, dim)
447
+ self.heads = heads
448
+
449
+ def forward(self, x):
450
+ b, n, c = x.shape
451
+ qkv = self.qkv(self.n1(x)).reshape(b, n, 3, self.heads, c // self.heads)
452
+ q, k, v = qkv.permute(2, 0, 3, 1, 4)
453
+ att = (q @ k.transpose(-2, -1)) * (c // self.heads) ** -0.5
454
+ att = att.softmax(dim=-1)
455
+ o = (att @ v).transpose(1, 2).reshape(b, n, c)
456
+ x = x + self.proj(o)
457
+ x = x + self.fc2(torch.nn.functional.gelu(self.fc1(self.n2(x))))
458
+ return x
459
+
460
+ class DinoVitStudent(nn.Module):
461
+ def __init__(self, dim=384, depth=8, heads=6, patch=16,
462
+ out_dim=DINO_DIM_OUT, tokens=4096):
463
+ super().__init__()
464
+ self.stem = nn.Conv2d(3, dim, patch, patch)
465
+ self.cls = nn.Parameter(torch.zeros(1, 1, dim))
466
+ self.reg = nn.Parameter(torch.zeros(1, 4, dim))
467
+ self.pos = nn.Parameter(torch.zeros(1, tokens, dim))
468
+ self.blocks = nn.ModuleList([VitBlock(dim, heads) for _ in range(depth)])
469
+ self.norm = nn.LayerNorm(dim)
470
+ self.proj = nn.Linear(dim, out_dim)
471
+ self.out_norm = nn.LayerNorm(out_dim, elementwise_affine=False)
472
+
473
+ def forward(self, x):
474
+ h = self.stem(x).flatten(2).transpose(1, 2) + self.pos
475
+ h = torch.cat([self.cls.expand(h.shape[0], -1, -1),
476
+ self.reg.expand(h.shape[0], -1, -1), h], dim=1)
477
+ for blk in self.blocks:
478
+ h = blk(h)
479
+ return self.out_norm(self.proj(self.norm(h)))
480
+
481
+ arch = os.environ.get("FLIP_DINO_ARCH", "conv")
482
+ dim = int(os.environ.get("FLIP_DINO_DIM", "512"))
483
+ depth = int(os.environ.get("FLIP_DINO_DEPTH", "8"))
484
+ if arch == "vit":
485
+ return DinoVitStudent(dim=min(dim, 512), depth=depth)
486
+ return DinoConvStudent(dim=dim, depth=depth)
487
+
488
+
489
+ def build_vae():
490
+ import torch.nn as nn
491
+
492
+ class Res(nn.Module):
493
+ def __init__(self, cin, cout):
494
+ super().__init__()
495
+ self.n1 = nn.GroupNorm(8, cin)
496
+ self.c1 = nn.Conv2d(cin, cout, 3, 1, 1)
497
+ self.n2 = nn.GroupNorm(8, cout)
498
+ self.c2 = nn.Conv2d(cout, cout, 3, 1, 1)
499
+ self.act = nn.SiLU()
500
+ self.skip = nn.Conv2d(cin, cout, 1) if cin != cout else nn.Identity()
501
+
502
+ def forward(self, x):
503
+ h = self.c1(self.act(self.n1(x)))
504
+ h = self.c2(self.act(self.n2(h)))
505
+ return h + self.skip(x)
506
+
507
+ class VaeStudent(nn.Module):
508
+ def __init__(self, ch=(64, 128, 256, 256, 256), out_dim=VAE_DIM):
509
+ super().__init__()
510
+ self.inp = nn.Conv2d(3, ch[0], 3, 1, 1)
511
+ self.enc = nn.ModuleList()
512
+ self.down = nn.ModuleList()
513
+ prev = ch[0]
514
+ for c in ch[1:]:
515
+ self.enc.append(Res(prev, c))
516
+ self.down.append(nn.Conv2d(c, c, 3, 2, 1))
517
+ prev = c
518
+ self.mid = nn.Sequential(Res(prev, prev), Res(prev, prev))
519
+ self.out = nn.Conv2d(prev, out_dim, 1)
520
+
521
+ def forward(self, x):
522
+ h = self.inp(x)
523
+ for res, down in zip(self.enc, self.down):
524
+ h = down(res(h))
525
+ h = self.mid(h)
526
+ h = self.out(h)
527
+ return h.flatten(2).transpose(1, 2)
528
+
529
+ return VaeStudent()
530
+
531
+
532
+ def build_matte():
533
+ import torch
534
+ import torch.nn as nn
535
+ import torch.nn.functional as F
536
+
537
+ class Block(nn.Module):
538
+ def __init__(self, cin, cout):
539
+ super().__init__()
540
+ self.c1 = nn.Conv2d(cin, cout, 3, 1, 1)
541
+ self.c2 = nn.Conv2d(cout, cout, 3, 1, 1)
542
+ self.n1 = nn.GroupNorm(8, cout)
543
+ self.n2 = nn.GroupNorm(8, cout)
544
+ self.act = nn.SiLU()
545
+
546
+ def forward(self, x):
547
+ x = self.act(self.n1(self.c1(x)))
548
+ return self.act(self.n2(self.c2(x)))
549
+
550
+ class MatteStudent(nn.Module):
551
+ def __init__(self, base=24):
552
+ super().__init__()
553
+ c = [base, base * 2, base * 4, base * 8, base * 8]
554
+ self.e0 = Block(3, c[0])
555
+ self.e1 = Block(c[0], c[1])
556
+ self.e2 = Block(c[1], c[2])
557
+ self.e3 = Block(c[2], c[3])
558
+ self.e4 = Block(c[3], c[4])
559
+ self.pool = nn.MaxPool2d(2)
560
+ self.d3 = Block(c[4] + c[3], c[3])
561
+ self.d2 = Block(c[3] + c[2], c[2])
562
+ self.d1 = Block(c[2] + c[1], c[1])
563
+ self.d0 = Block(c[1] + c[0], c[0])
564
+ self.head = nn.Conv2d(c[0], 1, 1)
565
+
566
+ def up(self, x):
567
+ return F.interpolate(x, scale_factor=2, mode="bilinear",
568
+ align_corners=False)
569
+
570
+ def forward(self, x):
571
+ e0 = self.e0(x)
572
+ e1 = self.e1(self.pool(e0))
573
+ e2 = self.e2(self.pool(e1))
574
+ e3 = self.e3(self.pool(e2))
575
+ e4 = self.e4(self.pool(e3))
576
+ d3 = self.d3(torch.cat([self.up(e4), e3], 1))
577
+ d2 = self.d2(torch.cat([self.up(d3), e2], 1))
578
+ d1 = self.d1(torch.cat([self.up(d2), e1], 1))
579
+ d0 = self.d0(torch.cat([self.up(d1), e0], 1))
580
+ return torch.sigmoid(self.head(d0))
581
+
582
+ return MatteStudent()
583
+
584
+
585
+ def params(model):
586
+ return sum(q.numel() for q in model.parameters()) / 1e6
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # datasets / training
591
+ # ---------------------------------------------------------------------------
592
+
593
+ def cache_items():
594
+ items = sorted(CACHE.glob("f1_*.npy"))
595
+ return [q.name[3:-4] for q in items]
596
+
597
+
598
+ def load_canvas(stem):
599
+ import numpy as np # noqa: PLC0415
600
+ a = np.load(CACHE / f"canvas_{stem}.npy")
601
+ return a
602
+
603
+
604
+ def train_dino(device):
605
+ import numpy as np # noqa: PLC0415
606
+ import torch # noqa: PLC0415
607
+ import torch.nn.functional as F # noqa: PLC0415
608
+
609
+ stems = cache_items()
610
+ if not stems:
611
+ raise RuntimeError("no cached targets; run prep first")
612
+ model = build_dino().to(device)
613
+ p(f"dino student {params(model):.1f} M params")
614
+ opt = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.05)
615
+ steps = max(1, (len(stems) // BATCH) * EPOCHS)
616
+ sched = torch.optim.lr_scheduler.OneCycleLR(
617
+ opt, max_lr=LR, total_steps=steps, pct_start=0.1)
618
+ scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
619
+ ckpt = WEIGHTS / "dino.pt"
620
+ start = 0
621
+ if ckpt.exists():
622
+ st = torch.load(ckpt, map_location=device)
623
+ model.load_state_dict(st["model"])
624
+ opt.load_state_dict(st["opt"])
625
+ start = st["epoch"]
626
+ for _ in range(start * max(1, len(stems) // BATCH)):
627
+ if sched.last_epoch < steps - 1:
628
+ sched.step()
629
+ p("resume dino at epoch", start)
630
+
631
+ for ep in range(start, EPOCHS):
632
+ t0 = time.time()
633
+ order = np.random.RandomState(ep).permutation(len(stems))
634
+ acc = n = 0
635
+ for it in range(0, len(order) - BATCH + 1, BATCH):
636
+ idx = order[it:it + BATCH]
637
+ x = torch.stack([torch.from_numpy(load_canvas(stems[j])).float() / 255.0
638
+ for j in idx]).to(device)
639
+ tgt = torch.stack([
640
+ torch.from_numpy(np.load(CACHE / f"f1_{stems[j]}.npy")).float()
641
+ for j in idx]).to(device)
642
+ with torch.autocast("cuda", torch.float16, enabled=device == "cuda"):
643
+ pred = model(x)
644
+ cos = F.cosine_similarity(pred.float(), tgt, dim=-1).mean()
645
+ loss = (1 - cos) + 0.1 * F.mse_loss(pred.float(), tgt)
646
+ opt.zero_grad(set_to_none=True)
647
+ scaler.scale(loss).backward()
648
+ scaler.unscale_(opt)
649
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
650
+ scaler.step(opt)
651
+ scaler.update()
652
+ if sched.last_epoch < steps - 1:
653
+ sched.step()
654
+ acc += float(cos.detach())
655
+ n += 1
656
+ if n % 20 == 0:
657
+ p(f" dino ep{ep} {n} cos {acc / n:.4f} "
658
+ f"lr {sched.get_last_lr()[0]:.2e}")
659
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
660
+ "scaler": scaler.state_dict(),
661
+ "epoch": ep + 1, "cos": acc / max(n, 1)},
662
+ ckpt)
663
+ p(f"dino epoch {ep + 1}/{EPOCHS} cos {acc / max(n, 1):.4f} "
664
+ f"({time.time() - t0:.0f}s)")
665
+ return model
666
+
667
+
668
+ def train_vae(device):
669
+ import numpy as np # noqa: PLC0415
670
+ import torch # noqa: PLC0415
671
+ import torch.nn.functional as F # noqa: PLC0415
672
+
673
+ stems = cache_items()
674
+ model = build_vae().to(device)
675
+ p(f"vae student {params(model):.1f} M params")
676
+ opt = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.05)
677
+ steps = max(1, (len(stems) // BATCH) * EPOCHS)
678
+ sched = torch.optim.lr_scheduler.OneCycleLR(
679
+ opt, max_lr=LR, total_steps=steps, pct_start=0.1)
680
+ scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
681
+ ckpt = WEIGHTS / "vae.pt"
682
+ start = 0
683
+ if ckpt.exists():
684
+ st = torch.load(ckpt, map_location=device)
685
+ model.load_state_dict(st["model"])
686
+ opt.load_state_dict(st["opt"])
687
+ start = st["epoch"]
688
+ for _ in range(start * max(1, len(stems) // BATCH)):
689
+ if sched.last_epoch < steps - 1:
690
+ sched.step()
691
+ p("resume vae at epoch", start)
692
+
693
+ for ep in range(start, EPOCHS):
694
+ t0 = time.time()
695
+ order = np.random.RandomState(1000 + ep).permutation(len(stems))
696
+ acc = n = 0
697
+ for it in range(0, len(order) - BATCH + 1, BATCH):
698
+ idx = order[it:it + BATCH]
699
+ x = torch.stack([torch.from_numpy(load_canvas(stems[j])).float() / 255.0
700
+ for j in idx]).to(device)
701
+ x = x * 2 - 1
702
+ tgt = torch.stack([
703
+ torch.from_numpy(np.load(CACHE / f"f2_{stems[j]}.npy")).float()
704
+ for j in idx]).to(device)
705
+ with torch.autocast("cuda", torch.float16, enabled=device == "cuda"):
706
+ pred = model(x)
707
+ cos = F.cosine_similarity(pred.float(), tgt, dim=-1).mean()
708
+ loss = (1 - cos) + 0.5 * F.mse_loss(pred.float(), tgt)
709
+ opt.zero_grad(set_to_none=True)
710
+ scaler.scale(loss).backward()
711
+ scaler.unscale_(opt)
712
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
713
+ scaler.step(opt)
714
+ scaler.update()
715
+ if sched.last_epoch < steps - 1:
716
+ sched.step()
717
+ acc += float(cos.detach())
718
+ n += 1
719
+ if n % 20 == 0:
720
+ p(f" vae ep{ep} {n} cos {acc / n:.4f} "
721
+ f"lr {sched.get_last_lr()[0]:.2e}")
722
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
723
+ "scaler": scaler.state_dict(),
724
+ "epoch": ep + 1, "cos": acc / max(n, 1)}, ckpt)
725
+ p(f"vae epoch {ep + 1}/{EPOCHS} cos {acc / max(n, 1):.4f} "
726
+ f"({time.time() - t0:.0f}s)")
727
+ return model
728
+
729
+
730
+ def train_matte(device):
731
+ import numpy as np # noqa: PLC0415
732
+ import torch # noqa: PLC0415
733
+ import torch.nn.functional as F # noqa: PLC0415
734
+ from PIL import Image # noqa: PLC0415
735
+
736
+ stems = cache_items()
737
+ model = build_matte().to(device)
738
+ p(f"matte student {params(model):.1f} M params")
739
+ opt = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.01)
740
+ steps = max(1, len(stems) * EPOCHS)
741
+ sched = torch.optim.lr_scheduler.OneCycleLR(
742
+ opt, max_lr=LR, total_steps=steps, pct_start=0.1)
743
+ scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
744
+ ckpt = WEIGHTS / "matte.pt"
745
+ start = 0
746
+ if ckpt.exists():
747
+ st = torch.load(ckpt, map_location=device)
748
+ model.load_state_dict(st["model"])
749
+ opt.load_state_dict(st["opt"])
750
+ start = st["epoch"]
751
+ for _ in range(start * len(stems)):
752
+ if sched.last_epoch < steps - 1:
753
+ sched.step()
754
+ p("resume matte at epoch", start)
755
+
756
+ for ep in range(start, EPOCHS):
757
+ t0 = time.time()
758
+ order = np.random.RandomState(2000 + ep).permutation(len(stems))
759
+ acc = n = 0
760
+ for j in order:
761
+ rgb = Image.open(CACHE / f"matte_rgb_{stems[j]}.png").convert("RGB")
762
+ alpha = Image.open(CACHE / f"matte_alpha_{stems[j]}.png").convert("L")
763
+ x = torch.from_numpy(np.asarray(rgb).astype(np.float32) / 255.0)
764
+ y = torch.from_numpy(np.asarray(alpha).astype(np.float32) / 255.0)
765
+ x = x.permute(2, 0, 1).unsqueeze(0).to(device)
766
+ y = y.unsqueeze(0).unsqueeze(0).to(device)
767
+ with torch.autocast("cuda", torch.float16, enabled=device == "cuda"):
768
+ pred = model(x).float()
769
+ bce = F.binary_cross_entropy(pred, y)
770
+ inter = (pred * y).sum()
771
+ dice = 1 - (2 * inter + 1) / (pred.sum() + y.sum() + 1)
772
+ loss = bce + dice
773
+ opt.zero_grad(set_to_none=True)
774
+ scaler.scale(loss).backward()
775
+ scaler.unscale_(opt)
776
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
777
+ scaler.step(opt)
778
+ scaler.update()
779
+ if sched.last_epoch < steps - 1:
780
+ sched.step()
781
+ acc += 1 - float(bce.detach())
782
+ n += 1
783
+ if n % 40 == 0:
784
+ p(f" matte ep{ep} {n}/{len(stems)} bce {float(bce):.4f} "
785
+ f"dice {float(dice):.4f}")
786
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
787
+ "scaler": scaler.state_dict(),
788
+ "epoch": ep + 1, "bce": 1 - acc / max(n, 1)}, ckpt)
789
+ p(f"matte epoch {ep + 1}/{EPOCHS} "
790
+ f"acc {acc / max(n, 1):.4f} ({time.time() - t0:.0f}s)")
791
+ return model
792
+
793
+
794
+ def train():
795
+ import torch # noqa: PLC0415
796
+ device = "cuda" if torch.cuda.is_available() else "cpu"
797
+ p("train on", device)
798
+ train_dino(device)
799
+ train_vae(device)
800
+ train_matte(device)
801
+
802
+
803
+ # ---------------------------------------------------------------------------
804
+ # export
805
+ # ---------------------------------------------------------------------------
806
+
807
+ def load_weights(model, name, device):
808
+ import torch # noqa: PLC0415
809
+ ckpt = WEIGHTS / f"{name}.pt"
810
+ if not ckpt.exists():
811
+ raise RuntimeError(f"missing {ckpt}")
812
+ st = torch.load(ckpt, map_location=device)
813
+ model.load_state_dict(st["model"])
814
+ return model.to(device).eval()
815
+
816
+
817
+ def export_model(model, example, path, inputs, outputs, dynamic=None, fp16=False):
818
+ import torch # noqa: PLC0415
819
+ model = model.eval()
820
+ if fp16:
821
+ model = model.half()
822
+ example = example.half()
823
+ torch.onnx.export(
824
+ model, example, str(path),
825
+ input_names=inputs, output_names=outputs,
826
+ opset_version=17, do_constant_folding=True,
827
+ dynamic_axes=dynamic, dynamo=False,
828
+ )
829
+ return path
830
+
831
+
832
+ def parity(path, inputs, outputs, torch_out, tol=0.999):
833
+ import numpy as np # noqa: PLC0415
834
+ import onnxruntime as ort # noqa: PLC0415
835
+ providers = ["CPUExecutionProvider"]
836
+ try:
837
+ if ort.get_device() == "GPU":
838
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
839
+ except Exception: # noqa: BLE001
840
+ pass
841
+ sess = ort.InferenceSession(str(path), providers=providers)
842
+ feed = {k: v.detach().cpu().numpy() for k, v in inputs.items()}
843
+ got = sess.run(None, feed)
844
+ worst = 1.0
845
+ for a, b in zip(got, torch_out):
846
+ a = a.astype(np.float32).reshape(-1)
847
+ b = b.detach().cpu().float().numpy().reshape(-1)
848
+ denom = np.linalg.norm(a) * np.linalg.norm(b) + 1e-12
849
+ cos = float(np.dot(a, b) / denom)
850
+ worst = min(worst, cos)
851
+ return worst, [tuple(g.shape) for g in got]
852
+
853
+
854
+ def export():
855
+ import torch # noqa: PLC0415
856
+ device = "cuda" if torch.cuda.is_available() else "cpu"
857
+ meta = {"files": {}, "shapes": {}, "params": {}}
858
+
859
+ dino = load_weights(build_dino(), "dino", device)
860
+ ex = torch.rand(1, 3, CANVAS, CANVAS, device=device)
861
+ with torch.no_grad():
862
+ ref = dino(ex)
863
+ path = ONNX / "dino_student.onnx"
864
+ export_model(dino.float(), ex, path, ["image"], ["feature1"], fp16=False)
865
+ cos, shapes = parity(path, {"image": ex}, ["feature1"], [ref])
866
+ p(f"dino onnx {path.stat().st_size / 1e6:.1f} MB parity cos {cos:.6f}")
867
+ meta["files"]["dino"] = path.name
868
+ meta["shapes"]["dino"] = {"input": [1, 3, CANVAS, CANVAS],
869
+ "output": list(shapes[0])}
870
+ meta["params"]["dino"] = round(params(dino), 2)
871
+
872
+ vae = load_weights(build_vae(), "vae", device)
873
+ ex = torch.rand(1, 3, CANVAS, CANVAS, device=device) * 2 - 1
874
+ with torch.no_grad():
875
+ ref = vae(ex)
876
+ path = ONNX / "vae_student.onnx"
877
+ export_model(vae.float(), ex, path, ["image"], ["feature2"], fp16=False)
878
+ cos, shapes = parity(path, {"image": ex}, ["feature2"], [ref])
879
+ p(f"vae onnx {path.stat().st_size / 1e6:.1f} MB parity cos {cos:.6f}")
880
+ meta["files"]["vae"] = path.name
881
+ meta["shapes"]["vae"] = {"input": [1, 3, CANVAS, CANVAS],
882
+ "output": list(shapes[0])}
883
+ meta["params"]["vae"] = round(params(vae), 2)
884
+
885
+ matte = load_weights(build_matte(), "matte", device)
886
+ ex = torch.rand(1, 3, 512, 768, device=device)
887
+ with torch.no_grad():
888
+ ref = matte(ex)
889
+ path = ONNX / "matte_student.onnx"
890
+ export_model(matte.float(), ex, path, ["image"], ["alpha"],
891
+ dynamic={"image": {2: "h", 3: "w"},
892
+ "alpha": {2: "h", 3: "w"}})
893
+ cos, shapes = parity(path, {"image": ex}, ["alpha"], [ref])
894
+ p(f"matte onnx {path.stat().st_size / 1e6:.1f} MB parity cos {cos:.6f}")
895
+ meta["files"]["matte"] = path.name
896
+ meta["shapes"]["matte"] = {"input": [1, 3, "h", "w"], "output": [1, 1, "h", "w"]}
897
+ meta["params"]["matte"] = round(params(matte), 2)
898
+
899
+ meta.update({
900
+ "preprocess": {
901
+ "resize": "shortest side 1024, dims rounded to /32",
902
+ "matte_input": "rgb / 255",
903
+ "dino_input": "canvas rgb / 255",
904
+ "vae_input": "canvas rgb / 255 * 2 - 1",
905
+ "canonical": CANVAS,
906
+ },
907
+ "tokens": {"dino": DINO_OUT_TOKENS, "vae": VAE_TOKENS},
908
+ })
909
+ (OUT / "encoder_meta.json").write_text(json.dumps(meta, indent=2))
910
+ p("wrote", OUT / "encoder_meta.json")
911
+ return meta
912
+
913
+
914
+ def upload():
915
+ if not UPLOAD:
916
+ return
917
+ from huggingface_hub import HfApi # noqa: PLC0415
918
+ tok = hf_token()
919
+ if not tok:
920
+ raise RuntimeError("FLIP_UPLOAD=1 but no HF token")
921
+ api = HfApi(token=tok)
922
+ api.create_repo(PUBLISH_REPO, private=False, exist_ok=True)
923
+ for f in sorted(ONNX.glob("*.onnx")) + [OUT / "encoder_meta.json"]:
924
+ api.upload_file(path_or_fileobj=str(f), path_in_repo=f.name,
925
+ repo_id=PUBLISH_REPO)
926
+ p("uploaded", f.name)
927
+
928
+
929
+ # ---------------------------------------------------------------------------
930
+ # end to end
931
+ # ---------------------------------------------------------------------------
932
+
933
+ def build_teacher_pipeline(T, device):
934
+ files = {
935
+ "ckpt": CKPT / "diffusion_models/triposplat_fp16.safetensors",
936
+ "dec": CKPT / "vae/triposplat_vae_decoder_fp16.safetensors",
937
+ }
938
+ return T.TripoSplatPipeline(
939
+ ckpt_path=str(files["ckpt"]), decoder_path=str(files["dec"]),
940
+ dinov3_path=str(CKPT / "clip_vision/dino_v3_vit_h.safetensors"),
941
+ flux2_vae_encoder_path=str(CKPT / "vae/flux2-vae.safetensors"),
942
+ rmbg_path=str(CKPT / "background_removal/birefnet.safetensors"),
943
+ device=device)
944
+
945
+
946
+ def ply_stats(gauss):
947
+ import numpy as np # noqa: PLC0415
948
+ op = gauss.get_opacity.detach().float().cpu().numpy().reshape(-1)
949
+ sc = gauss.get_scaling.detach().float().cpu().numpy().reshape(-1)
950
+ xyz = gauss.get_xyz.detach().float().cpu().numpy().reshape(-1, 3)
951
+ return {"opacity": float(op.mean()), "scale": float(sc.mean()),
952
+ "x": [float(xyz[:, 0].min()), float(xyz[:, 0].max())]}
953
+
954
+
955
+ def e2e():
956
+ """Teacher DiT + decoder on student conditions vs teacher conditions.
957
+
958
+ This is the acceptance test that matters: a feature cosine of 0.98 means
959
+ nothing if the contact sheet moves. We run the teacher's own sampler for
960
+ two steps (the student's grid) and compare the decoded Gaussian statistics.
961
+ """
962
+ import numpy as np # noqa: PLC0415
963
+ import torch # noqa: PLC0415
964
+ from PIL import Image # noqa: PLC0415
965
+ import torch.nn.functional as F # noqa: PLC0415
966
+ from torchvision import transforms # noqa: PLC0415
967
+
968
+ device = "cuda" if torch.cuda.is_available() else "cpu"
969
+ sys.path.insert(0, str(REPO))
970
+ import triposplat as T # noqa: PLC0415
971
+ pipe = build_teacher_pipeline(T, device)
972
+ dino, vae = pipe.dinov3, pipe.vae_encoder
973
+ dino_s = load_weights(build_dino(), "dino", device)
974
+ vae_s = load_weights(build_vae(), "vae", device)
975
+
976
+ stems = cache_items()[:4]
977
+ rows = []
978
+ for stem in stems:
979
+ canvas = Image.fromarray(
980
+ np.load(CACHE / f"canvas_{stem}.npy").transpose(1, 2, 0))
981
+ x = transforms.ToTensor()(canvas).unsqueeze(0).to(device)
982
+ dino_dtype = next(dino.parameters()).dtype
983
+ vae_dtype = next(vae.parameters()).dtype
984
+ dino_device = next(dino.parameters()).device
985
+ vae_device = next(vae.parameters()).device
986
+ with torch.no_grad():
987
+ tf1 = F.layer_norm(dino(
988
+ pixel_values=T._DINOV3_NORMALIZE(x).to(dino_device, dino_dtype)
989
+ ).float(), (1280,))
990
+ tf2 = vae.encode(x.to(vae_device, vae_dtype) * 2 - 1,
991
+ deterministic=True)
992
+ sf1 = dino_s(x)
993
+ sf2 = vae_s(x * 2 - 1)
994
+ cos1 = float(F.cosine_similarity(sf1, tf1, dim=-1).mean())
995
+ cos2 = float(F.cosine_similarity(sf2, tf2, dim=-1).mean())
996
+
997
+ def pad2(f):
998
+ z = torch.zeros(f.shape[0], 5, f.shape[2], dtype=f.dtype,
999
+ device=f.device)
1000
+ return torch.cat([z, f], dim=1)
1001
+
1002
+ tf2p, sf2p = pad2(tf2), pad2(sf2)
1003
+ out = {}
1004
+ lats = {}
1005
+ for tag, f1, f2 in (("teacher", tf1, tf2p),
1006
+ ("dino", sf1, tf2p),
1007
+ ("full", sf1, sf2p)):
1008
+ cond = {"feature1": f1, "feature2": f2}
1009
+ gen = torch.Generator(device=device).manual_seed(42)
1010
+ lat = pipe.sample_latent(cond, steps=2, guidance_scale=1.0,
1011
+ shift=1.0, generator=gen)
1012
+ lats[tag] = lat["latent"].detach().float()
1013
+ g = pipe.decode_latent(lat["latent"], num_gaussians=65536)
1014
+ out[tag] = ply_stats(g)
1015
+ lat_cos = float(F.cosine_similarity(
1016
+ lats["full"], lats["teacher"], dim=-1).mean())
1017
+ lat_cos_d = float(F.cosine_similarity(
1018
+ lats["dino"], lats["teacher"], dim=-1).mean())
1019
+ rows.append({"stem": stem, "cos1": cos1, "cos2": cos2,
1020
+ "lat_cos_full": lat_cos, "lat_cos_dino": lat_cos_d, **out})
1021
+ p(f"e2e {stem} cos1 {cos1:.4f} cos2 {cos2:.4f} "
1022
+ f"lat {lat_cos:.4f}/{lat_cos_d:.4f} "
1023
+ f"opacity teacher/dino/full "
1024
+ f"{out['teacher']['opacity']:.4f}/"
1025
+ f"{out['dino']['opacity']:.4f}/{out['full']['opacity']:.4f}")
1026
+
1027
+ (OUT / "e2e.json").write_text(json.dumps(rows, indent=2))
1028
+ p("wrote", OUT / "e2e.json")
1029
+
1030
+
1031
+ # ---------------------------------------------------------------------------
1032
+
1033
+ def main():
1034
+ ap = argparse.ArgumentParser(description=__doc__)
1035
+ ap.add_argument("--stages", default=",".join(STAGES))
1036
+ args = ap.parse_args()
1037
+ ensure_dirs()
1038
+ stages = [s.strip() for s in args.stages.split(",") if s.strip()]
1039
+ p("stages", stages)
1040
+ try:
1041
+ if "setup" in stages:
1042
+ setup()
1043
+ if "prep" in stages:
1044
+ prep()
1045
+ if "train" in stages:
1046
+ train()
1047
+ if "export" in stages:
1048
+ export()
1049
+ if "upload" in stages:
1050
+ upload()
1051
+ if "e2e" in stages:
1052
+ e2e()
1053
+ except Exception: # noqa: BLE001
1054
+ p("FAILED")
1055
+ p(traceback.format_exc()[-4000:])
1056
+ raise
1057
+ finally:
1058
+ (OUT / "log.txt").write_text("\n".join(LOG))
1059
+ p("log ->", OUT / "log.txt")
1060
+
1061
+
1062
+ if __name__ == "__main__":
1063
+ main()