flip-student-2step / encoder /code /k3_encoder_distill.py
zziang's picture
Upload encoder/code/k3_encoder_distill.py with huggingface_hub
0fd7dec verified
Raw History Blame Contribute Delete
40.9 kB
"""K3 - distil the front end: photo -> (feature1, feature2), plus the matte.
The DiT is done and the decoder is done. What is still missing is the half that
turns a photo into the two conditioning tensors the DiT was trained on:
feature1 [1,4101,1280] DINOv3 ViT-H/16+ output, layer-normed over 1280
feature2 [1,4096, 128] Flux2-VAE encoder mean, BatchNorm-standardised
and the alpha matte that decides what the photo even is. All three teachers are
too big for a browser (DINO 840 M, VAE 30 M of pure convolution, BiRefNet 220 M
with deformable convs), so each gets a student:
matte small U-Net ~2 M image -> alpha (any H,W)
dino ConvNeXt-style token encoder ~19 M 1024^2 -> 4101x1280
vae small strided conv encoder ~5 M 1024^2 -> 4096x128
Why a conv student for DINO instead of a small ViT: the contract is 4101 tokens,
and a ViT at that length materialises a 4101x4101 score matrix per head unless
the attention is fused. A ConvNeXt block has no such matrix, exports to plain
Conv/LayerNorm/MatMul, and the ORT WebGPU conv path already measured 711 GFLOP/s
(PUBLISH.md section 2). The five prefix tokens (cls + 4 registers) are produced
from global average pooling, which is what those tokens mostly carry anyway.
Stages (FLIP_STAGES, default all):
setup clone upstream, download the four teacher checkpoints
prep run the teachers once, cache per-image targets + matte masks
train train the three students (resumable, checkpoints in OUT)
export ONNX + torch-vs-ORT parity, writes encoder_meta.json
e2e teacher DiT 2-step + decoder on student conditions vs teacher ones
Everything is sized for a single Colab/Kaggle T4. Defaults: 200 images, 40
epochs, batch 4 for the conv students, batch 1 for matte.
Env:
FLIP_WORK /content or /kaggle/working (auto)
FLIP_OUT output dir (default WORK/k3)
FLIP_STAGES comma list, default "setup,prep,train,export,e2e"
FLIP_N_IMG images to trace/train on (default 200)
FLIP_EPOCHS epochs per student (default 40)
FLIP_BATCH batch size for dino/vae (default 4)
FLIP_IMG_HF HF image dataset (default DownFlow/fuliji)
FLIP_IMAGE_DIR extra local images
FLIP_DINO_ARCH conv (default) or vit
FLIP_DINO_DIM student width (default 512)
FLIP_DINO_DEPTH blocks (default 8)
FLIP_FP16 export fp16 instead of fp32 (default 0)
FLIP_UPLOAD 1 = push encoder/ to idefinity/flip-web
HF_TOKEN upload token (or Kaggle secret / Colab userdata)
Run:
python3 k3_encoder_distill.py # all stages
FLIP_STAGES=prep python3 k3_encoder_distill.py
"""
import argparse
import json
import math
import os
import shutil
import subprocess
import sys
import time
import traceback
import urllib.parse
import urllib.request
from pathlib import Path
WORK = Path(os.environ.get("FLIP_WORK")
or ("/content" if Path("/content").exists() else "/kaggle/working"))
REPO = WORK / "TripoSplat"
CKPT = REPO / "ckpts"
OUT = Path(os.environ.get("FLIP_OUT", str(WORK / "k3")))
CACHE = OUT / "cache"
WEIGHTS = OUT / "weights"
ONNX = OUT / "onnx"
SHA = "d8db9e0"
UPSTREAM = "https://github.com/VAST-AI-Research/TripoSplat.git"
TEACHER_REPO = "VAST-AI/TripoSplat"
PUBLISH_REPO = "idefinity/flip-student-2step"
N_IMG = int(os.environ.get("FLIP_N_IMG", "200"))
EPOCHS = int(os.environ.get("FLIP_EPOCHS", "40"))
BATCH = int(os.environ.get("FLIP_BATCH", "4"))
LR = float(os.environ.get("FLIP_LR", "3e-4"))
FP16 = os.environ.get("FLIP_FP16", "0") == "1"
UPLOAD = os.environ.get("FLIP_UPLOAD", "0") == "1"
IMG_HF = os.environ.get("FLIP_IMG_HF", "DownFlow/fuliji")
IMG_DIR = os.environ.get("FLIP_IMAGE_DIR", "")
STAGES = [s.strip() for s in os.environ.get(
"FLIP_STAGES", "setup,prep,train,export,e2e").split(",") if s.strip()]
CANVAS = 1024
DINO_OUT_TOKENS = 4101
DINO_DIM_OUT = 1280
VAE_TOKENS = 4096
VAE_DIM = 128
MATTE_SIZE = CANVAS
LOG = []
def p(*a):
s = " ".join(str(x) for x in a)
LOG.append(s)
print(s, flush=True)
def note(msg):
p(msg)
def run(cmd, timeout=1800, env=None, check=False):
p("$", " ".join(str(c) for c in cmd)[:160])
r = subprocess.run([str(c) for c in cmd], capture_output=True, text=True,
timeout=timeout, env=env)
if r.returncode != 0:
p(" ", (r.stderr or r.stdout or "")[-500:].replace("\n", " "))
if check:
raise RuntimeError(f"command failed: {cmd}")
return r
def ensure_dirs():
for d in (OUT, CACHE, WEIGHTS, ONNX):
d.mkdir(parents=True, exist_ok=True)
def hf_token():
tok = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
if tok:
return tok
try:
from kaggle_secrets import UserSecretsClient # noqa: PLC0415
return UserSecretsClient().get_secret("HF_TOKEN")
except Exception: # noqa: BLE001
return None
try:
from google.colab import userdata # noqa: PLC0415
return userdata.get("HF_TOKEN")
except Exception: # noqa: BLE001
return None
# ---------------------------------------------------------------------------
# setup
# ---------------------------------------------------------------------------
def deps():
pkgs = ["huggingface_hub", "safetensors", "torchvision", "tqdm",
"onnx", "onnxruntime", "Pillow"]
p("installing", " ".join(pkgs))
run([sys.executable, "-m", "pip", "install", "-q", "--no-cache-dir", *pkgs],
timeout=1800)
def setup():
deps()
if not (REPO / ".git").exists():
run(["git", "clone", "-q", UPSTREAM, str(REPO)], timeout=900, check=True)
run(["git", "-C", str(REPO), "checkout", "-q", SHA], timeout=300)
from huggingface_hub import snapshot_download # noqa: PLC0415
if not (CKPT / ".complete").exists():
p("downloading teacher checkpoints (~3.5 GB)")
snapshot_download(repo_id=TEACHER_REPO, local_dir=str(CKPT),
token=hf_token())
(CKPT / ".complete").touch()
total = sum(f.stat().st_size for f in CKPT.rglob("*") if f.is_file())
p("ckpts", round(total / 1e9, 2), "GB")
# ---------------------------------------------------------------------------
# inputs
# ---------------------------------------------------------------------------
IMG_EXT = (".png", ".jpg", ".jpeg", ".webp")
def fetch_hf(n):
if not IMG_HF:
return []
dst = WORK / "inputs"
dst.mkdir(parents=True, exist_ok=True)
column = os.environ.get("FLIP_IMG_HF_COLUMN", "image").strip()
split = os.environ.get("FLIP_IMG_HF_SPLIT", "train").strip()
def get(url, timeout=90):
req = urllib.request.Request(url, headers={"User-Agent": "flip-k3"})
with urllib.request.urlopen(req, timeout=timeout) as fh:
return fh.read()
cfg = None
try:
q = urllib.parse.urlencode({"dataset": IMG_HF})
splits = json.loads(get(
f"https://datasets-server.huggingface.co/splits?{q}"))["splits"]
if splits:
cfg, split = splits[0].get("config"), splits[0].get("split", split)
except Exception as exc: # noqa: BLE001
p(" splits lookup failed", type(exc).__name__)
got, offset = [], 0
while len(got) < n:
q = {"dataset": IMG_HF, "split": split, "offset": offset,
"length": min(100, n - len(got))}
if cfg:
q["config"] = cfg
try:
js = json.loads(get("https://datasets-server.huggingface.co/rows?"
+ urllib.parse.urlencode(q)))
except Exception as exc: # noqa: BLE001
p(" rows failed", type(exc).__name__)
break
rows = js.get("rows") or []
if not rows:
break
for r in rows:
cell = (r.get("row") or {}).get(column)
src = cell.get("src") if isinstance(cell, dict) else None
if not src:
continue
path = dst / f"hf_{len(got):04d}.jpg"
if path.exists() and path.stat().st_size > 1024:
got.append(path)
continue
try:
blob = get(src, timeout=60)
if blob[:2] != b"\xff\xd8" and blob[:8] != b"\x89PNG\r\n\x1a\n":
continue
tmp = path.with_suffix(".jpg.part")
tmp.write_bytes(blob)
os.replace(tmp, path)
got.append(path)
except Exception: # noqa: BLE001
continue
if len(got) >= n:
break
offset += len(rows)
if offset >= (js.get("num_rows_total") or 0):
break
p("fetched", len(got), "images from", IMG_HF)
return got
def pick_images():
imgs = []
if IMG_DIR:
root = Path(IMG_DIR)
if root.is_dir():
imgs += sorted(q for q in root.rglob("*")
if q.suffix.lower() in IMG_EXT)
elif root.is_file():
imgs.append(root)
p("local images", len(imgs))
if len(imgs) < N_IMG:
imgs += fetch_hf(N_IMG - len(imgs))
if len(imgs) < N_IMG:
ex = REPO / "static" / "example_inputs"
if ex.is_dir():
imgs += sorted(q for q in ex.rglob("*")
if q.suffix.lower() in IMG_EXT)
if not imgs:
raise RuntimeError("no input images: set FLIP_IMG_HF or FLIP_IMAGE_DIR")
p("inputs", len(imgs), "->", imgs[0], "...", imgs[-1])
return imgs[:N_IMG]
# ---------------------------------------------------------------------------
# teacher + preprocessing
# ---------------------------------------------------------------------------
def load_teacher():
import torch # noqa: F401
sys.path.insert(0, str(REPO))
import triposplat as T # noqa: PLC0415
dev = "cuda" if __import__("torch").cuda.is_available() else "cpu"
dino = T.load_dinov3(str(CKPT / "clip_vision/dino_v3_vit_h.safetensors"),
device=dev, dtype=__import__("torch").bfloat16)
vae = T.load_vae_encoder(str(CKPT / "vae/flux2-vae.safetensors"),
device=dev, dtype=__import__("torch").bfloat16)
rmbg = T.load_rmbg(str(CKPT / "background_removal/birefnet.safetensors"),
device=dev, dtype=__import__("torch").float16)
return T, dino, vae, rmbg, dev
def resize_min_side(image, target=CANVAS):
w, h = image.size
s = target / min(w, h)
w2 = max(32, int(round(w * s / 32)) * 32)
h2 = max(32, int(round(h * s / 32)) * 32)
return image.resize((w2, h2), __import__("PIL.Image", fromlist=["Image"]).LANCZOS)
def erode_alpha(alpha, radius=1):
"""MinFilter(2r+1) on the alpha channel, the same shrink upstream applies."""
from PIL import Image, ImageFilter # noqa: PLC0415
im = Image.fromarray(alpha, mode="L")
return im.filter(ImageFilter.MinFilter(2 * radius + 1))
def square_crop_box(alpha, expand=1.2):
import numpy as np # noqa: PLC0415
a = np.asarray(alpha)
ys, xs = np.nonzero(a)
if len(xs) == 0:
h, w = a.shape
side = min(h, w)
return (w - side) // 2, (h - side) // 2, (w + side) // 2, (h + side) // 2
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
half = max(x1 - x0, y1 - y0) / 2 * expand
return (int(round(cx - half)), int(round(cy - half)),
int(round(cx + half)), int(round(cy + half)))
def make_sample(T, dino, vae, rmbg, path, idx):
import numpy as np # noqa: PLC0415
import torch # noqa: PLC0415
import torch.nn.functional as F # noqa: PLC0415
from PIL import Image # noqa: PLC0415
from torchvision import transforms # noqa: PLC0415
stem = f"{idx:04d}"
files = {k: CACHE / f"{k}_{stem}.npy" for k in ("f1", "f2", "canvas")}
matte = {k: CACHE / f"{k}_{stem}.png" for k in ("matte_rgb", "matte_alpha")}
if all(f.exists() for f in files.values()) and all(f.exists() for f in matte.values()):
return True
image = Image.open(path).convert("RGB")
rgb = resize_min_side(image)
with torch.no_grad():
rgba = rmbg.remove_background(rgb)
alpha_raw = np.array(rgba.getchannel(3), dtype=np.uint8)
alpha_er = np.array(erode_alpha(alpha_raw, 1), dtype=np.uint8)
box = square_crop_box(alpha_er)
cropped = rgba.crop(box).resize((CANVAS, CANVAS), Image.LANCZOS)
canvas = Image.new("RGB", (CANVAS, CANVAS), (0, 0, 0))
canvas.paste(cropped, mask=cropped.split()[3])
dino_dtype = next(dino.parameters()).dtype
vae_dtype = next(vae.parameters()).dtype
dino_device = next(dino.parameters()).device
vae_device = next(vae.parameters()).device
x = transforms.ToTensor()(canvas).unsqueeze(0)
with torch.no_grad():
f1 = dino(pixel_values=T._DINOV3_NORMALIZE(x).to(dino_device, dino_dtype))
f1 = F.layer_norm(f1.float(), f1.shape[-1:])
f2 = vae.encode(x.to(vae_device, vae_dtype) * 2 - 1, deterministic=True)
np.save(files["f1"], f1[0].half().cpu().numpy())
np.save(files["f2"], f2[0].half().cpu().numpy())
np.save(files["canvas"], (np.asarray(canvas, dtype=np.uint8).transpose(2, 0, 1)))
Image.fromarray(np.asarray(rgb, dtype=np.uint8)).save(matte["matte_rgb"])
Image.fromarray(alpha_raw).save(matte["matte_alpha"])
return True
def prep():
imgs = pick_images()
T, dino, vae, rmbg, dev = load_teacher()
p("teacher on", dev)
t0 = time.time()
ok = 0
for i, path in enumerate(imgs):
try:
if make_sample(T, dino, vae, rmbg, path, i):
ok += 1
except Exception: # noqa: BLE001
p(" skip", path.name, traceback.format_exc().splitlines()[-1][:160])
if (i + 1) % 10 == 0:
dt = time.time() - t0
p(f" prep {i + 1}/{len(imgs)} {dt / (i + 1):.2f} s/img")
p("prep done", ok, "of", len(imgs))
# ---------------------------------------------------------------------------
# students
# ---------------------------------------------------------------------------
def build_dino():
import torch
import torch.nn as nn
class LN2d(nn.Module):
def __init__(self, dim, eps=1e-6):
super().__init__()
self.ln = nn.LayerNorm(dim, eps=eps)
def forward(self, x):
return self.ln(x.permute(0, 2, 3, 1).contiguous()).permute(0, 3, 1, 2)
class ConvBlock(nn.Module):
"""ConvNeXt block: depthwise 7x7 then an inverted bottleneck."""
def __init__(self, dim, mlp_ratio=4):
super().__init__()
hidden = int(dim * mlp_ratio)
self.norm = LN2d(dim)
self.dw = nn.Conv2d(dim, dim, 7, padding=3, groups=dim)
self.pw1 = nn.Conv2d(dim, hidden, 1)
self.act = nn.GELU()
self.pw2 = nn.Conv2d(hidden, dim, 1)
self.gamma = nn.Parameter(torch.ones(dim) * 1e-6)
def forward(self, x):
h = self.norm(x)
h = self.dw(h)
h = self.pw1(h)
h = self.act(h)
h = self.pw2(h)
return x + self.gamma.view(1, -1, 1, 1) * h
class DinoConvStudent(nn.Module):
def __init__(self, dim=512, depth=8, patch=16, out_dim=DINO_DIM_OUT):
super().__init__()
self.stem = nn.Conv2d(3, dim, patch, patch)
self.stem_norm = LN2d(dim)
self.blocks = nn.Sequential(*[ConvBlock(dim) for _ in range(depth)])
self.norm = nn.LayerNorm(dim)
self.prefix = nn.Linear(dim, 5 * dim)
self.proj = nn.Linear(dim, out_dim)
self.out_norm = nn.LayerNorm(out_dim, elementwise_affine=False)
def forward(self, x):
h = self.stem_norm(self.stem(x))
h = self.blocks(h)
b, c, hh, ww = h.shape
tok = h.flatten(2).transpose(1, 2)
tok = self.norm(tok)
g = tok.mean(dim=1)
pre = self.prefix(g).reshape(b, 5, c)
tok = torch.cat([pre, tok], dim=1)
return self.out_norm(self.proj(tok))
class VitBlock(nn.Module):
def __init__(self, dim, heads=8, mlp_ratio=4):
super().__init__()
self.n1 = nn.LayerNorm(dim)
self.qkv = nn.Linear(dim, 3 * dim)
self.proj = nn.Linear(dim, dim)
self.n2 = nn.LayerNorm(dim)
hidden = dim * mlp_ratio
self.fc1 = nn.Linear(dim, hidden)
self.fc2 = nn.Linear(hidden, dim)
self.heads = heads
def forward(self, x):
b, n, c = x.shape
qkv = self.qkv(self.n1(x)).reshape(b, n, 3, self.heads, c // self.heads)
q, k, v = qkv.permute(2, 0, 3, 1, 4)
att = (q @ k.transpose(-2, -1)) * (c // self.heads) ** -0.5
att = att.softmax(dim=-1)
o = (att @ v).transpose(1, 2).reshape(b, n, c)
x = x + self.proj(o)
x = x + self.fc2(torch.nn.functional.gelu(self.fc1(self.n2(x))))
return x
class DinoVitStudent(nn.Module):
def __init__(self, dim=384, depth=8, heads=6, patch=16,
out_dim=DINO_DIM_OUT, tokens=4096):
super().__init__()
self.stem = nn.Conv2d(3, dim, patch, patch)
self.cls = nn.Parameter(torch.zeros(1, 1, dim))
self.reg = nn.Parameter(torch.zeros(1, 4, dim))
self.pos = nn.Parameter(torch.zeros(1, tokens, dim))
self.blocks = nn.ModuleList([VitBlock(dim, heads) for _ in range(depth)])
self.norm = nn.LayerNorm(dim)
self.proj = nn.Linear(dim, out_dim)
self.out_norm = nn.LayerNorm(out_dim, elementwise_affine=False)
def forward(self, x):
h = self.stem(x).flatten(2).transpose(1, 2) + self.pos
h = torch.cat([self.cls.expand(h.shape[0], -1, -1),
self.reg.expand(h.shape[0], -1, -1), h], dim=1)
for blk in self.blocks:
h = blk(h)
return self.out_norm(self.proj(self.norm(h)))
arch = os.environ.get("FLIP_DINO_ARCH", "conv")
dim = int(os.environ.get("FLIP_DINO_DIM", "512"))
depth = int(os.environ.get("FLIP_DINO_DEPTH", "8"))
if arch == "vit":
return DinoVitStudent(dim=min(dim, 512), depth=depth)
return DinoConvStudent(dim=dim, depth=depth)
def build_vae():
import torch.nn as nn
class Res(nn.Module):
def __init__(self, cin, cout):
super().__init__()
self.n1 = nn.GroupNorm(8, cin)
self.c1 = nn.Conv2d(cin, cout, 3, 1, 1)
self.n2 = nn.GroupNorm(8, cout)
self.c2 = nn.Conv2d(cout, cout, 3, 1, 1)
self.act = nn.SiLU()
self.skip = nn.Conv2d(cin, cout, 1) if cin != cout else nn.Identity()
def forward(self, x):
h = self.c1(self.act(self.n1(x)))
h = self.c2(self.act(self.n2(h)))
return h + self.skip(x)
class VaeStudent(nn.Module):
def __init__(self, ch=(64, 128, 256, 256, 256), out_dim=VAE_DIM):
super().__init__()
self.inp = nn.Conv2d(3, ch[0], 3, 1, 1)
self.enc = nn.ModuleList()
self.down = nn.ModuleList()
prev = ch[0]
for c in ch[1:]:
self.enc.append(Res(prev, c))
self.down.append(nn.Conv2d(c, c, 3, 2, 1))
prev = c
self.mid = nn.Sequential(Res(prev, prev), Res(prev, prev))
self.out = nn.Conv2d(prev, out_dim, 1)
def forward(self, x):
h = self.inp(x)
for res, down in zip(self.enc, self.down):
h = down(res(h))
h = self.mid(h)
h = self.out(h)
return h.flatten(2).transpose(1, 2)
return VaeStudent()
def build_matte():
import torch
import torch.nn as nn
import torch.nn.functional as F
class Block(nn.Module):
def __init__(self, cin, cout):
super().__init__()
self.c1 = nn.Conv2d(cin, cout, 3, 1, 1)
self.c2 = nn.Conv2d(cout, cout, 3, 1, 1)
self.n1 = nn.GroupNorm(8, cout)
self.n2 = nn.GroupNorm(8, cout)
self.act = nn.SiLU()
def forward(self, x):
x = self.act(self.n1(self.c1(x)))
return self.act(self.n2(self.c2(x)))
class MatteStudent(nn.Module):
def __init__(self, base=24):
super().__init__()
c = [base, base * 2, base * 4, base * 8, base * 8]
self.e0 = Block(3, c[0])
self.e1 = Block(c[0], c[1])
self.e2 = Block(c[1], c[2])
self.e3 = Block(c[2], c[3])
self.e4 = Block(c[3], c[4])
self.pool = nn.MaxPool2d(2)
self.d3 = Block(c[4] + c[3], c[3])
self.d2 = Block(c[3] + c[2], c[2])
self.d1 = Block(c[2] + c[1], c[1])
self.d0 = Block(c[1] + c[0], c[0])
self.head = nn.Conv2d(c[0], 1, 1)
def up(self, x):
return F.interpolate(x, scale_factor=2, mode="bilinear",
align_corners=False)
def forward(self, x):
e0 = self.e0(x)
e1 = self.e1(self.pool(e0))
e2 = self.e2(self.pool(e1))
e3 = self.e3(self.pool(e2))
e4 = self.e4(self.pool(e3))
d3 = self.d3(torch.cat([self.up(e4), e3], 1))
d2 = self.d2(torch.cat([self.up(d3), e2], 1))
d1 = self.d1(torch.cat([self.up(d2), e1], 1))
d0 = self.d0(torch.cat([self.up(d1), e0], 1))
return torch.sigmoid(self.head(d0))
return MatteStudent()
def params(model):
return sum(q.numel() for q in model.parameters()) / 1e6
# ---------------------------------------------------------------------------
# datasets / training
# ---------------------------------------------------------------------------
def cache_items():
items = sorted(CACHE.glob("f1_*.npy"))
return [q.name[3:-4] for q in items]
def load_canvas(stem):
import numpy as np # noqa: PLC0415
a = np.load(CACHE / f"canvas_{stem}.npy")
return a
def train_dino(device):
import numpy as np # noqa: PLC0415
import torch # noqa: PLC0415
import torch.nn.functional as F # noqa: PLC0415
stems = cache_items()
if not stems:
raise RuntimeError("no cached targets; run prep first")
model = build_dino().to(device)
p(f"dino student {params(model):.1f} M params")
opt = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.05)
steps = max(1, (len(stems) // BATCH) * EPOCHS)
sched = torch.optim.lr_scheduler.OneCycleLR(
opt, max_lr=LR, total_steps=steps, pct_start=0.1)
scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
ckpt = WEIGHTS / "dino.pt"
start = 0
if ckpt.exists():
st = torch.load(ckpt, map_location=device)
model.load_state_dict(st["model"])
opt.load_state_dict(st["opt"])
start = st["epoch"]
for _ in range(start * max(1, len(stems) // BATCH)):
if sched.last_epoch < steps - 1:
sched.step()
p("resume dino at epoch", start)
for ep in range(start, EPOCHS):
t0 = time.time()
order = np.random.RandomState(ep).permutation(len(stems))
acc = n = 0
for it in range(0, len(order) - BATCH + 1, BATCH):
idx = order[it:it + BATCH]
x = torch.stack([torch.from_numpy(load_canvas(stems[j])).float() / 255.0
for j in idx]).to(device)
tgt = torch.stack([
torch.from_numpy(np.load(CACHE / f"f1_{stems[j]}.npy")).float()
for j in idx]).to(device)
with torch.autocast("cuda", torch.float16, enabled=device == "cuda"):
pred = model(x)
cos = F.cosine_similarity(pred.float(), tgt, dim=-1).mean()
loss = (1 - cos) + 0.1 * F.mse_loss(pred.float(), tgt)
opt.zero_grad(set_to_none=True)
scaler.scale(loss).backward()
scaler.unscale_(opt)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt)
scaler.update()
if sched.last_epoch < steps - 1:
sched.step()
acc += float(cos.detach())
n += 1
if n % 20 == 0:
p(f" dino ep{ep} {n} cos {acc / n:.4f} "
f"lr {sched.get_last_lr()[0]:.2e}")
torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
"scaler": scaler.state_dict(),
"epoch": ep + 1, "cos": acc / max(n, 1)},
ckpt)
p(f"dino epoch {ep + 1}/{EPOCHS} cos {acc / max(n, 1):.4f} "
f"({time.time() - t0:.0f}s)")
return model
def train_vae(device):
import numpy as np # noqa: PLC0415
import torch # noqa: PLC0415
import torch.nn.functional as F # noqa: PLC0415
stems = cache_items()
model = build_vae().to(device)
p(f"vae student {params(model):.1f} M params")
opt = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.05)
steps = max(1, (len(stems) // BATCH) * EPOCHS)
sched = torch.optim.lr_scheduler.OneCycleLR(
opt, max_lr=LR, total_steps=steps, pct_start=0.1)
scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
ckpt = WEIGHTS / "vae.pt"
start = 0
if ckpt.exists():
st = torch.load(ckpt, map_location=device)
model.load_state_dict(st["model"])
opt.load_state_dict(st["opt"])
start = st["epoch"]
for _ in range(start * max(1, len(stems) // BATCH)):
if sched.last_epoch < steps - 1:
sched.step()
p("resume vae at epoch", start)
for ep in range(start, EPOCHS):
t0 = time.time()
order = np.random.RandomState(1000 + ep).permutation(len(stems))
acc = n = 0
for it in range(0, len(order) - BATCH + 1, BATCH):
idx = order[it:it + BATCH]
x = torch.stack([torch.from_numpy(load_canvas(stems[j])).float() / 255.0
for j in idx]).to(device)
x = x * 2 - 1
tgt = torch.stack([
torch.from_numpy(np.load(CACHE / f"f2_{stems[j]}.npy")).float()
for j in idx]).to(device)
with torch.autocast("cuda", torch.float16, enabled=device == "cuda"):
pred = model(x)
cos = F.cosine_similarity(pred.float(), tgt, dim=-1).mean()
loss = (1 - cos) + 0.5 * F.mse_loss(pred.float(), tgt)
opt.zero_grad(set_to_none=True)
scaler.scale(loss).backward()
scaler.unscale_(opt)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt)
scaler.update()
if sched.last_epoch < steps - 1:
sched.step()
acc += float(cos.detach())
n += 1
if n % 20 == 0:
p(f" vae ep{ep} {n} cos {acc / n:.4f} "
f"lr {sched.get_last_lr()[0]:.2e}")
torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
"scaler": scaler.state_dict(),
"epoch": ep + 1, "cos": acc / max(n, 1)}, ckpt)
p(f"vae epoch {ep + 1}/{EPOCHS} cos {acc / max(n, 1):.4f} "
f"({time.time() - t0:.0f}s)")
return model
def train_matte(device):
import numpy as np # noqa: PLC0415
import torch # noqa: PLC0415
import torch.nn.functional as F # noqa: PLC0415
from PIL import Image # noqa: PLC0415
stems = cache_items()
model = build_matte().to(device)
p(f"matte student {params(model):.1f} M params")
opt = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.01)
steps = max(1, len(stems) * EPOCHS)
sched = torch.optim.lr_scheduler.OneCycleLR(
opt, max_lr=LR, total_steps=steps, pct_start=0.1)
scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
ckpt = WEIGHTS / "matte.pt"
start = 0
if ckpt.exists():
st = torch.load(ckpt, map_location=device)
model.load_state_dict(st["model"])
opt.load_state_dict(st["opt"])
start = st["epoch"]
for _ in range(start * len(stems)):
if sched.last_epoch < steps - 1:
sched.step()
p("resume matte at epoch", start)
for ep in range(start, EPOCHS):
t0 = time.time()
order = np.random.RandomState(2000 + ep).permutation(len(stems))
acc = n = 0
for j in order:
rgb = Image.open(CACHE / f"matte_rgb_{stems[j]}.png").convert("RGB")
alpha = Image.open(CACHE / f"matte_alpha_{stems[j]}.png").convert("L")
x = torch.from_numpy(np.asarray(rgb).astype(np.float32) / 255.0)
y = torch.from_numpy(np.asarray(alpha).astype(np.float32) / 255.0)
x = x.permute(2, 0, 1).unsqueeze(0).to(device)
y = y.unsqueeze(0).unsqueeze(0).to(device)
with torch.autocast("cuda", torch.float16, enabled=device == "cuda"):
pred = model(x).float()
bce = F.binary_cross_entropy(pred, y)
inter = (pred * y).sum()
dice = 1 - (2 * inter + 1) / (pred.sum() + y.sum() + 1)
loss = bce + dice
opt.zero_grad(set_to_none=True)
scaler.scale(loss).backward()
scaler.unscale_(opt)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt)
scaler.update()
if sched.last_epoch < steps - 1:
sched.step()
acc += 1 - float(bce.detach())
n += 1
if n % 40 == 0:
p(f" matte ep{ep} {n}/{len(stems)} bce {float(bce):.4f} "
f"dice {float(dice):.4f}")
torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
"scaler": scaler.state_dict(),
"epoch": ep + 1, "bce": 1 - acc / max(n, 1)}, ckpt)
p(f"matte epoch {ep + 1}/{EPOCHS} "
f"acc {acc / max(n, 1):.4f} ({time.time() - t0:.0f}s)")
return model
def train():
import torch # noqa: PLC0415
device = "cuda" if torch.cuda.is_available() else "cpu"
p("train on", device)
train_dino(device)
train_vae(device)
train_matte(device)
# ---------------------------------------------------------------------------
# export
# ---------------------------------------------------------------------------
def load_weights(model, name, device):
import torch # noqa: PLC0415
ckpt = WEIGHTS / f"{name}.pt"
if not ckpt.exists():
raise RuntimeError(f"missing {ckpt}")
st = torch.load(ckpt, map_location=device)
model.load_state_dict(st["model"])
return model.to(device).eval()
def export_model(model, example, path, inputs, outputs, dynamic=None, fp16=False):
import torch # noqa: PLC0415
model = model.eval()
if fp16:
model = model.half()
example = example.half()
torch.onnx.export(
model, example, str(path),
input_names=inputs, output_names=outputs,
opset_version=17, do_constant_folding=True,
dynamic_axes=dynamic, dynamo=False,
)
return path
def parity(path, inputs, outputs, torch_out, tol=0.999):
import numpy as np # noqa: PLC0415
import onnxruntime as ort # noqa: PLC0415
providers = ["CPUExecutionProvider"]
try:
if ort.get_device() == "GPU":
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
except Exception: # noqa: BLE001
pass
sess = ort.InferenceSession(str(path), providers=providers)
feed = {k: v.detach().cpu().numpy() for k, v in inputs.items()}
got = sess.run(None, feed)
worst = 1.0
for a, b in zip(got, torch_out):
a = a.astype(np.float32).reshape(-1)
b = b.detach().cpu().float().numpy().reshape(-1)
denom = np.linalg.norm(a) * np.linalg.norm(b) + 1e-12
cos = float(np.dot(a, b) / denom)
worst = min(worst, cos)
return worst, [tuple(g.shape) for g in got]
def export():
import torch # noqa: PLC0415
device = "cuda" if torch.cuda.is_available() else "cpu"
meta = {"files": {}, "shapes": {}, "params": {}}
dino = load_weights(build_dino(), "dino", device)
ex = torch.rand(1, 3, CANVAS, CANVAS, device=device)
with torch.no_grad():
ref = dino(ex)
path = ONNX / "dino_student.onnx"
export_model(dino.float(), ex, path, ["image"], ["feature1"], fp16=False)
cos, shapes = parity(path, {"image": ex}, ["feature1"], [ref])
p(f"dino onnx {path.stat().st_size / 1e6:.1f} MB parity cos {cos:.6f}")
meta["files"]["dino"] = path.name
meta["shapes"]["dino"] = {"input": [1, 3, CANVAS, CANVAS],
"output": list(shapes[0])}
meta["params"]["dino"] = round(params(dino), 2)
vae = load_weights(build_vae(), "vae", device)
ex = torch.rand(1, 3, CANVAS, CANVAS, device=device) * 2 - 1
with torch.no_grad():
ref = vae(ex)
path = ONNX / "vae_student.onnx"
export_model(vae.float(), ex, path, ["image"], ["feature2"], fp16=False)
cos, shapes = parity(path, {"image": ex}, ["feature2"], [ref])
p(f"vae onnx {path.stat().st_size / 1e6:.1f} MB parity cos {cos:.6f}")
meta["files"]["vae"] = path.name
meta["shapes"]["vae"] = {"input": [1, 3, CANVAS, CANVAS],
"output": list(shapes[0])}
meta["params"]["vae"] = round(params(vae), 2)
matte = load_weights(build_matte(), "matte", device)
ex = torch.rand(1, 3, 512, 768, device=device)
with torch.no_grad():
ref = matte(ex)
path = ONNX / "matte_student.onnx"
export_model(matte.float(), ex, path, ["image"], ["alpha"],
dynamic={"image": {2: "h", 3: "w"},
"alpha": {2: "h", 3: "w"}})
cos, shapes = parity(path, {"image": ex}, ["alpha"], [ref])
p(f"matte onnx {path.stat().st_size / 1e6:.1f} MB parity cos {cos:.6f}")
meta["files"]["matte"] = path.name
meta["shapes"]["matte"] = {"input": [1, 3, "h", "w"], "output": [1, 1, "h", "w"]}
meta["params"]["matte"] = round(params(matte), 2)
meta.update({
"preprocess": {
"resize": "shortest side 1024, dims rounded to /32",
"matte_input": "rgb / 255",
"dino_input": "canvas rgb / 255",
"vae_input": "canvas rgb / 255 * 2 - 1",
"canonical": CANVAS,
},
"tokens": {"dino": DINO_OUT_TOKENS, "vae": VAE_TOKENS},
})
(OUT / "encoder_meta.json").write_text(json.dumps(meta, indent=2))
p("wrote", OUT / "encoder_meta.json")
return meta
def upload():
if not UPLOAD:
return
from huggingface_hub import HfApi # noqa: PLC0415
tok = hf_token()
if not tok:
raise RuntimeError("FLIP_UPLOAD=1 but no HF token")
api = HfApi(token=tok)
api.create_repo(PUBLISH_REPO, private=False, exist_ok=True)
for f in sorted(ONNX.glob("*.onnx")) + [OUT / "encoder_meta.json"]:
api.upload_file(path_or_fileobj=str(f), path_in_repo=f.name,
repo_id=PUBLISH_REPO)
p("uploaded", f.name)
# ---------------------------------------------------------------------------
# end to end
# ---------------------------------------------------------------------------
def build_teacher_pipeline(T, device):
files = {
"ckpt": CKPT / "diffusion_models/triposplat_fp16.safetensors",
"dec": CKPT / "vae/triposplat_vae_decoder_fp16.safetensors",
}
return T.TripoSplatPipeline(
ckpt_path=str(files["ckpt"]), decoder_path=str(files["dec"]),
dinov3_path=str(CKPT / "clip_vision/dino_v3_vit_h.safetensors"),
flux2_vae_encoder_path=str(CKPT / "vae/flux2-vae.safetensors"),
rmbg_path=str(CKPT / "background_removal/birefnet.safetensors"),
device=device)
def ply_stats(gauss):
import numpy as np # noqa: PLC0415
op = gauss.get_opacity.detach().float().cpu().numpy().reshape(-1)
sc = gauss.get_scaling.detach().float().cpu().numpy().reshape(-1)
xyz = gauss.get_xyz.detach().float().cpu().numpy().reshape(-1, 3)
return {"opacity": float(op.mean()), "scale": float(sc.mean()),
"x": [float(xyz[:, 0].min()), float(xyz[:, 0].max())]}
def e2e():
"""Teacher DiT + decoder on student conditions vs teacher conditions.
This is the acceptance test that matters: a feature cosine of 0.98 means
nothing if the contact sheet moves. We run the teacher's own sampler for
two steps (the student's grid) and compare the decoded Gaussian statistics.
"""
import numpy as np # noqa: PLC0415
import torch # noqa: PLC0415
from PIL import Image # noqa: PLC0415
import torch.nn.functional as F # noqa: PLC0415
from torchvision import transforms # noqa: PLC0415
device = "cuda" if torch.cuda.is_available() else "cpu"
sys.path.insert(0, str(REPO))
import triposplat as T # noqa: PLC0415
pipe = build_teacher_pipeline(T, device)
dino, vae = pipe.dinov3, pipe.vae_encoder
dino_s = load_weights(build_dino(), "dino", device)
vae_s = load_weights(build_vae(), "vae", device)
stems = cache_items()[:4]
rows = []
for stem in stems:
canvas = Image.fromarray(
np.load(CACHE / f"canvas_{stem}.npy").transpose(1, 2, 0))
x = transforms.ToTensor()(canvas).unsqueeze(0).to(device)
dino_dtype = next(dino.parameters()).dtype
vae_dtype = next(vae.parameters()).dtype
dino_device = next(dino.parameters()).device
vae_device = next(vae.parameters()).device
with torch.no_grad():
tf1 = F.layer_norm(dino(
pixel_values=T._DINOV3_NORMALIZE(x).to(dino_device, dino_dtype)
).float(), (1280,))
tf2 = vae.encode(x.to(vae_device, vae_dtype) * 2 - 1,
deterministic=True)
sf1 = dino_s(x)
sf2 = vae_s(x * 2 - 1)
cos1 = float(F.cosine_similarity(sf1, tf1, dim=-1).mean())
cos2 = float(F.cosine_similarity(sf2, tf2, dim=-1).mean())
def pad2(f):
z = torch.zeros(f.shape[0], 5, f.shape[2], dtype=f.dtype,
device=f.device)
return torch.cat([z, f], dim=1)
tf2p, sf2p = pad2(tf2), pad2(sf2)
out = {}
lats = {}
for tag, f1, f2 in (("teacher", tf1, tf2p),
("dino", sf1, tf2p),
("full", sf1, sf2p)):
cond = {"feature1": f1, "feature2": f2}
gen = torch.Generator(device=device).manual_seed(42)
lat = pipe.sample_latent(cond, steps=2, guidance_scale=1.0,
shift=1.0, generator=gen)
lats[tag] = lat["latent"].detach().float()
g = pipe.decode_latent(lat["latent"], num_gaussians=65536)
out[tag] = ply_stats(g)
lat_cos = float(F.cosine_similarity(
lats["full"], lats["teacher"], dim=-1).mean())
lat_cos_d = float(F.cosine_similarity(
lats["dino"], lats["teacher"], dim=-1).mean())
rows.append({"stem": stem, "cos1": cos1, "cos2": cos2,
"lat_cos_full": lat_cos, "lat_cos_dino": lat_cos_d, **out})
p(f"e2e {stem} cos1 {cos1:.4f} cos2 {cos2:.4f} "
f"lat {lat_cos:.4f}/{lat_cos_d:.4f} "
f"opacity teacher/dino/full "
f"{out['teacher']['opacity']:.4f}/"
f"{out['dino']['opacity']:.4f}/{out['full']['opacity']:.4f}")
(OUT / "e2e.json").write_text(json.dumps(rows, indent=2))
p("wrote", OUT / "e2e.json")
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--stages", default=",".join(STAGES))
args = ap.parse_args()
ensure_dirs()
stages = [s.strip() for s in args.stages.split(",") if s.strip()]
p("stages", stages)
try:
if "setup" in stages:
setup()
if "prep" in stages:
prep()
if "train" in stages:
train()
if "export" in stages:
export()
if "upload" in stages:
upload()
if "e2e" in stages:
e2e()
except Exception: # noqa: BLE001
p("FAILED")
p(traceback.format_exc()[-4000:])
raise
finally:
(OUT / "log.txt").write_text("\n".join(LOG))
p("log ->", OUT / "log.txt")
if __name__ == "__main__":
main()