File size: 10,849 Bytes
b958326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9aabf6a
 
 
b958326
ed3b86e
 
 
 
 
 
b958326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35ddc05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b958326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed3b86e
 
 
 
b958326
 
b4c820e
 
b958326
 
 
 
 
 
35ddc05
 
b958326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35ddc05
 
 
 
 
 
 
 
 
 
b958326
 
 
 
35ddc05
b958326
 
35ddc05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b958326
 
 
35ddc05
 
b958326
 
 
 
 
 
 
 
35ddc05
 
b958326
 
 
35ddc05
b958326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""K3 sheet - four renders per photo, so quality is looked at instead of argued about.

The acceptance test we never ran: put the teacher's 20-step result next to what our
chain produces for the same photo, and look. Numbers like "lat cos 0.84" are a proxy
for this image, and they were made on cosplay studio photos, not on the photos people
actually drop in.

Four PLYs per input, all through the teacher's own decoder so the only differences are
the ones we care about:

    t20      teacher conditions, 20 steps, its own sampler (guidance 3, shift 3)  <- truth
    t2       teacher conditions,  2 steps, guidance 3                          <- cost of fewer steps
    s2       student conditions,  2 steps, guidance 3                          <- cost of our encoder
    s2g      student conditions,  2 steps, guidance 1, shift 1                 <- what the browser runs

Also written per image: the canvas the student encoder saw (1024^2, black), the alpha
the student matte produced, and the teacher's own prepared image. When a sheet cell is
fog, one of those three says which half broke.

Run it from the k3 driver (stage "sheet"), or directly:
    FLIP_WORK=/kaggle/working python3 k3_sheet.py /kaggle/working/eval
"""

import importlib.util
import json
import os
import pathlib
import sys
import tarfile
import time

WORK = pathlib.Path(os.environ.get("FLIP_WORK", "/kaggle/working"))
K3 = WORK / "k3_encoder_distill.py"
IMGDIR = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else str(WORK / "eval"))
OUT = WORK / "k3" / "sheet"
OUT.mkdir(parents=True, exist_ok=True)
# 高斯数决定 decoder 取多少个八叉树点(每点 32 个):65536 -> 2048 点,
# 262144 -> 全部 8192 点。浏览器按 65536 做预算,教师要证明"原厂水平"就得给满。
N_GAUSS = int(os.environ.get("FLIP_SHEET_GAUSS", "65536"))

# 输入分辨率:upstream 的 preprocess_image 用模块级 _CANVAS_SIZE,改它会同时改
# 抠图/裁切/最终画布。DINO patch 数按 size/16 变:1024 -> 4101 token,
# 512 -> 1029 token。flow model 的 cond 是 concat + self-attn,长度不写死;
# 输出高斯数不受影响。只有显式设了 FLIP_SHEET_IN 才去动 upstream 的全局。
IN_SIZE = int(os.environ.get("FLIP_SHEET_IN", "1024"))

os.environ.setdefault("FLIP_WORK", str(WORK))


def p(*a):
    print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True)


def load_k3():
    spec = importlib.util.spec_from_file_location("k3", str(K3))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def pad2(f):
    import torch  # noqa: PLC0415
    z = torch.zeros(f.shape[0], 5, f.shape[2], dtype=f.dtype, device=f.device)
    return torch.cat([z, f], dim=1)


def parse_variants():
    """tag:cond:steps:guidance:shift, comma separated.

    cond is "t" (teacher conditions) or "s" (student conditions). The student was
    distilled with the guidance folded into the target, so its contract grid is
    g1/shift1; running it at the teacher's g3 is informative but off-contract.
    """
    specs = os.environ.get(
        "FLIP_SHEET_VARIANTS",
        "t20:t:20:3:3,t2:t:2:3:3,s2:s:2:3:3,s2g:s:2:1:1").split(",")
    out = []
    for spec in specs:
        tag, which, steps, gs, shift = [s.strip() for s in spec.split(":")]
        out.append((tag, which, int(steps), float(gs), float(shift)))
    return out


def stats(g):
    return {
        "n": int(g.get_xyz.shape[0]),
        "opacity": round(float(g.get_opacity.detach().float().mean()), 4),
        "scale": round(float(g.get_scaling.detach().float().mean()), 5),
        "x": [round(float(g.get_xyz[:, 0].min()), 3),
              round(float(g.get_xyz[:, 0].max()), 3)],
    }


def main():
    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

    dev = "cuda" if torch.cuda.is_available() else "cpu"
    p("device", dev, "torch", torch.__version__)
    k3 = load_k3()
    sys.path.insert(0, str(k3.REPO))
    import triposplat as T  # noqa: PLC0415
    if IN_SIZE != 1024:
        T._CANVAS_SIZE = IN_SIZE
        p("input canvas patched ->", IN_SIZE, "(teacher DINO tokens ->",
          (IN_SIZE // 16) ** 2 + 5, ")")

    imgs = sorted(q for q in IMGDIR.rglob("*")
                  if q.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp")
                  and not q.name.startswith("."))
    limit = int(os.environ.get("FLIP_SHEET_N", "0"))
    if limit:
        imgs = imgs[:limit]
    if not imgs:
        raise SystemExit(f"no images under {IMGDIR}")
    p("images", len(imgs), [q.name for q in imgs])
    variants = parse_variants()
    p("variants", [f"{t}·{c}{s}st·g{g}·sh{sh}" for t, c, s, g, sh in variants])

    t0 = time.time()
    pipe = k3.build_teacher_pipeline(T, dev)
    p("teacher pipeline", round(time.time() - t0, 1), "s")
    dino_s = k3.load_weights(k3.build_dino(), "dino", dev)
    vae_s = k3.load_weights(k3.build_vae(), "vae", dev)
    matte_s = k3.load_weights(k3.build_matte(), "matte", dev)
    p("student encoders loaded")

    report = []
    for i, path in enumerate(imgs):
        stem = path.stem
        row = {"image": path.name, "stem": stem, "runs": {}}
        try:
            t_img = time.time()
            image = Image.open(path).convert("RGB")
            row["size"] = list(image.size)

            # --- teacher path: its own preprocess, its own encoder
            prepared = pipe.preprocess_image(image)
            prepared.save(OUT / f"{stem}_prepared.png")
            row["prepared"] = list(prepared.size)
            gen = torch.Generator(device=dev).manual_seed(42)
            cond_t = pipe.encode_image(prepared, generator=gen)

            # --- student path: mirror of site/src/pipeline.js loadCondition
            rgb = k3.resize_min_side(image)
            w, h = rgb.size
            xin = transforms.ToTensor()(rgb).unsqueeze(0).to(dev)
            with torch.no_grad():
                a = matte_s(xin)[0, 0].float().cpu().numpy()
            alpha = (np.clip(a, 0, 1) * 255).astype(np.uint8)
            Image.fromarray(alpha, "L").save(OUT / f"{stem}_mask.png")
            er = np.array(k3.erode_alpha(alpha, 1), dtype=np.uint8)
            box = k3.square_crop_box(er)
            rgba = rgb.convert("RGBA")
            rgba.putalpha(Image.fromarray(alpha, "L"))
            cropped = rgba.crop(box).resize((k3.CANVAS, k3.CANVAS), Image.LANCZOS)
            canvas = Image.new("RGB", (k3.CANVAS, k3.CANVAS), (0, 0, 0))
            canvas.paste(cropped, mask=cropped.split()[3])
            canvas.save(OUT / f"{stem}_canvas.png")
            fx = alpha.astype(np.float32) / 255.0
            row["mask"] = {
                "resized": [w, h],
                "box": [int(v) for v in box],
                "fill": round(float(fx.mean()), 4),
                "coverage": round(float((fx > 0.5).mean()), 4),
            }

            x = transforms.ToTensor()(canvas).unsqueeze(0).to(dev)
            dino_dev = next(pipe.dinov3.parameters()).device
            dino_dt = next(pipe.dinov3.parameters()).dtype
            vae_dev = next(pipe.vae_encoder.parameters()).device
            vae_dt = next(pipe.vae_encoder.parameters()).dtype

            def teacher_feats(t):
                with torch.no_grad():
                    t1 = F.layer_norm(pipe.dinov3(
                        pixel_values=T._DINOV3_NORMALIZE(t).to(dino_dev, dino_dt)
                    ).float(), (1280,))
                    t2 = pipe.vae_encoder.encode(
                        (t * 2 - 1).to(vae_dev, vae_dt), deterministic=True)
                return t1, t2

            with torch.no_grad():
                sf1 = dino_s(x)
                sf2 = vae_s(x * 2 - 1)
                # teacher features on the SAME canvas: isolates encoder error from crop
                tf1, tf2 = teacher_feats(x)
            row["cos1"] = round(float(F.cosine_similarity(sf1, tf1, dim=-1).mean()), 4)
            row["cos2"] = round(float(F.cosine_similarity(sf2, tf2, dim=-1).mean()), 4)

            # and once more on the teacher's own prepared image: encoder alone, no
            # matte and no crop of ours in the way. The gap between the two pairs is
            # what our matte costs, which cos1 above can't separate.
            pc = prepared.convert("RGB")
            if pc.size != (k3.CANVAS, k3.CANVAS):
                pc = pc.resize((k3.CANVAS, k3.CANVAS), Image.LANCZOS)
            xp = transforms.ToTensor()(pc).unsqueeze(0).to(dev)
            with torch.no_grad():
                pf1 = dino_s(xp)
                pf2 = vae_s(xp * 2 - 1)
                qf1, qf2 = teacher_feats(xp)
            row["cos_enc1"] = round(float(F.cosine_similarity(pf1, qf1, dim=-1).mean()), 4)
            row["cos_enc2"] = round(float(F.cosine_similarity(pf2, qf2, dim=-1).mean()), 4)
            p(stem, "cos", row["cos1"], row["cos2"], "| encoder only",
              row["cos_enc1"], row["cos_enc2"], "| mask",
              row["mask"]["coverage"], "box", row["mask"]["box"])

            cond_s = {"feature1": sf1, "feature2": pad2(sf2)}
            for tag, which, steps, gs, shift in variants:
                cond = cond_t if which == "t" else cond_s
                t1 = time.time()
                gen = torch.Generator(device=dev).manual_seed(42)
                lat = pipe.sample_latent(cond, steps=steps, guidance_scale=gs,
                                         shift=shift, generator=gen)
                g = pipe.decode_latent(lat["latent"], num_gaussians=N_GAUSS)
                ply = OUT / f"{stem}_{tag}.ply"
                g.save_ply(str(ply))
                row["runs"][tag] = {
                    **stats(g), "ply": ply.name, "steps": steps,
                    "g": gs, "shift": shift, "cond": which,
                    "mb": round(ply.stat().st_size / 1e6, 2),
                    "secs": round(time.time() - t1, 1),
                }
                p(f"  {tag:6s} s{steps} g{gs} sh{shift} {row['runs'][tag]['secs']:6.1f}s "
                  f"op {row['runs'][tag]['opacity']} sc {row['runs'][tag]['scale']}")
            row["secs"] = round(time.time() - t_img, 1)
        except Exception as exc:  # noqa: BLE001
            import traceback  # noqa: PLC0415
            row["error"] = traceback.format_exc()[-2000:]
            p(stem, "FAILED", type(exc).__name__, str(exc)[:200])
        report.append(row)
        (OUT / "report.json").write_text(json.dumps(report, indent=2))

    tgz = OUT / "sheet.tgz"
    with tarfile.open(tgz, "w:gz") as tf:
        for f in sorted(OUT.iterdir()):
            if f.name != tgz.name:
                tf.add(f, arcname=f.name)
    p("wrote", tgz, round(tgz.stat().st_size / 1e6, 1), "MB")


if __name__ == "__main__":
    main()