File size: 12,850 Bytes
ed66dc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""Host-side steps of the Idol pipeline that are not tensor programs, as the phone runs them (the Kotlin code in the
sample mirrors these functions line by line). Each follows idol-sdk @4702a7f; the comments name the source.

- preprocess: idol/preprocess.py preprocess_image, with the alpha predictor passed in (BiRefNet or the matting graph)
- shared randomness: CONTRACT.md "Shared randomness"
- systematic_rows / octree_descend: idol/models/decoder.py sample + systematic_sample, with the per-row pool draws
- to_gaussians / write_ply: GaussianFixedlenDecoder.to_representation, idol/gaussian.py Gaussian + save_ply
"""
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image, ImageFilter

CROP_MARGIN = {'synthetic': 1.2, 'real': 1.7}
IMAGE_SIZE = 1024
MAX_LEVEL = 8
PER_ANCHOR = 32
PHASE_THRESHOLD = 0.5


# ---------------------------------------------------------------- preprocessing

def has_real_alpha(image):
    return image.mode == 'RGBA' and np.array(image.getchannel(3), dtype=np.int32).min() < 255


def preprocess(image, alpha_fn, image_size=IMAGE_SIZE, crop_margin=1.2):
    """-> (composite RGB image the encoders see, uint8 alpha at the resized size before erosion, crop box).
    alpha_fn(rgb PIL image) -> uint8 [H, W] alpha."""
    width, height = image.size
    scale = image_size / min(width, height)
    image = image.resize((max(1, int(round(width * scale))), max(1, int(round(height * scale)))), Image.LANCZOS)
    if not has_real_alpha(image):
        rgb = image.convert('RGB')
        image = rgb.copy()
        image.putalpha(Image.fromarray(alpha_fn(rgb), mode='L'))
    alpha_raw = np.array(image.getchannel(3))
    image.putalpha(image.getchannel(3).filter(ImageFilter.MinFilter(3)))
    alpha = np.array(image.getchannel(3))
    ys, xs = np.nonzero(alpha)
    if len(xs) == 0:
        raise ValueError('no object found in the image (the alpha channel is empty)')
    left, top, right, bottom = xs.min(), ys.min(), xs.max(), ys.max()
    center_x, center_y = (left + right) / 2, (top + bottom) / 2
    half = max(right - left, bottom - top) / 2 * crop_margin
    box = [int(center_x - half), int(center_y - half), int(center_x + half), int(center_y + half)]
    image = image.crop(box)
    image = image.resize((image_size, image_size), Image.LANCZOS)
    composite = Image.new('RGB', (image_size, image_size), (0, 0, 0))
    composite.paste(image, mask=image.split()[3])
    return composite, alpha_raw, box


def birefnet_alpha_fn(birefnet):
    return lambda rgb: np.array(birefnet.remove_background(rgb).getchannel(3))


BIREFNET_SIZE = 1024
BIREFNET_MEAN = (0.485, 0.456, 0.406)
BIREFNET_STD = (0.229, 0.224, 0.225)


def birefnet_alpha(rgb, run_bir):
    """The author's BiRefNet.remove_background (idol/models/birefnet.py) around the graph: RGB / 255, bilinear
    (align_corners) to 1024 x 1024, ImageNet mean / std; the graph returns the sigmoid map [1, 1, 1024, 1024];
    bilinear (align_corners) back to the image size; x 255 truncated to uint8."""
    w, h = rgb.size
    t = torch.from_numpy(np.array(rgb.convert('RGB'), dtype=np.float32) / 255.0).permute(2, 0, 1)[None]
    t = F.interpolate(t, size=(BIREFNET_SIZE, BIREFNET_SIZE), mode='bilinear', align_corners=True)
    t = (t - torch.tensor(BIREFNET_MEAN).view(1, 3, 1, 1)) / torch.tensor(BIREFNET_STD).view(1, 3, 1, 1)
    a = torch.from_numpy(np.asarray(run_bir(t.numpy()), np.float32).reshape(1, 1, BIREFNET_SIZE, BIREFNET_SIZE))
    a = F.interpolate(a, size=(h, w), mode='bilinear', align_corners=True)[0, 0]
    return (a.clamp(0, 1) * 255).to(torch.uint8).numpy()


# uint8 DIS alpha values below this floor are set to 0 before the author's preprocessing (0 = off). DIS leaves faint
# responses BiRefNet does not (1-5 of 255 at the image corners, along thin wisps); the author's rule crops to the
# nonzero alpha, so they stretch the crop. The value is calibrated against BiRefNet's crop on held-out images
# (scripts/calibrate_matting_floor.py, contract/matting_floor.json).
DIS_ALPHA_FLOOR = 8   # calibrated 2026-11-09 on 31 held-out gallery images: crop box error mean 0.14 %, max 0.59 % of the side


def dis_alpha(rgb, run_dis, floor=None):
    """IS-Net DIS general-use as its authors run it (xuebinqin/DIS inference): bilinear resize to 1024², x/255 - 0.5,
    the sigmoid map back to the image size (bilinear), min-max normalised, x255 truncated to uint8.
    run_dis(x [1, 3, 1024, 1024] float32 numpy) -> [1, 1, 1024, 1024] sigmoid alpha."""
    w, h = rgb.size
    t = torch.from_numpy(np.array(rgb, dtype=np.float32)).permute(2, 0, 1)[None]
    t = F.interpolate(t, size=(1024, 1024), mode='bilinear', align_corners=False)
    x = (t / 255.0 - 0.5).numpy().astype(np.float32)
    a = torch.from_numpy(np.asarray(run_dis(x), dtype=np.float32).reshape(1, 1, 1024, 1024))
    a = F.interpolate(a, size=(h, w), mode='bilinear', align_corners=False)[0, 0]
    mi, ma = a.min(), a.max()
    a = (a - mi) / (ma - mi).clamp_min(1e-12)
    a = (a.clamp(0, 1) * 255).to(torch.uint8).numpy()
    floor = DIS_ALPHA_FLOOR if floor is None else floor
    if floor > 0:
        a = np.where(a >= floor, a, 0).astype(np.uint8)
    return a


def pixels_of(composite):
    """The encoders' input: [1, 3, 1024, 1024] float32 in [0, 1]."""
    return (np.asarray(composite, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0).astype(np.float32)


# ---------------------------------------------------------------- shared randomness

def make_noise(seed, q_tokens=8192, in_ch=16, cam_ch=5):
    g = torch.Generator().manual_seed(seed)
    vae = torch.randn(1, 32, 128, 128, generator=g)
    latent = torch.randn(1, q_tokens, in_ch, generator=g)
    camera = torch.randn(1, 1, cam_ch, generator=g)
    g1 = torch.Generator().manual_seed(seed + 1)
    pool = torch.rand(MAX_LEVEL, q_tokens, generator=g1)
    leaf = torch.rand(1, q_tokens, 3, generator=g1)
    return {k: v.numpy() for k, v in
            dict(vae_noise=vae, latent_noise=latent, camera_noise=camera, octree_pool=pool, leaf_pool=leaf).items()}


def time_grid(steps=5, rescale_t=3.0):
    t = np.linspace(1, 0, steps + 1)
    return rescale_t * t / (1 + (rescale_t - 1) * t)


# ---------------------------------------------------------------- octree sampling

def systematic_rows(probs, counts, u):
    """idol systematic_sample for one batch, row i's offset u0 = u[i] / n_i. probs [Np, P] float32 torch, counts [Np]
    int64 torch, u [Np] float32 torch -> [Np, P] int64."""
    P = probs.size(-1)
    probs = probs.to(torch.float32).clamp_min(0)
    row_sums = probs.sum(1, keepdim=True)
    zero_mask = row_sums.eq(0)
    probs = probs / row_sums.clamp_min(1)
    if zero_mask.any():
        probs = probs.clone()
        probs[zero_mask.expand_as(probs)] = 1.0 / P
    out = torch.zeros(probs.shape[0], P, dtype=torch.long)
    cdf = probs.cumsum(dim=1).clamp(max=1.0 - 1e-12)
    for n in torch.unique(counts).tolist():
        if n == 0:
            continue
        rows = (counts == n).nonzero(as_tuple=False).squeeze(1)
        u0 = u.index_select(0, rows)[:, None] / float(n)
        grid = torch.arange(n, dtype=torch.float32)[None, :] / float(n)
        us = (u0 + grid).clamp(max=1.0 - 1e-12)
        idx = torch.searchsorted(cdf.index_select(0, rows), us).clamp_max(P - 1)
        buf = torch.zeros(rows.numel(), P, dtype=torch.float32)
        buf.scatter_add_(1, idx, torch.ones_like(idx, dtype=buf.dtype))
        out.index_copy_(0, rows, buf.to(torch.long))
    return out


CHILD_OFFSET = torch.tensor([[i, j, k] for k in [0, 1] for j in [0, 1] for i in [0, 1]], dtype=torch.long)


def octree_descend(probs_fn, pool, leaf, num_points=8192, level=MAX_LEVEL, rec=None):
    """idol OctreeProbabilityFixedlenDecoder.sample for one batch. probs_fn(cells [1, L, 3] float32 torch, l) ->
    child probabilities [1, L, 8] (the softmax of the decoder's logits). pool [8, >= rows] and leaf [1, num_points, 3]
    float32 torch. -> anchors [1, num_points, 3] in [0, 1]^3."""
    prev = torch.zeros(1, 3, dtype=torch.long)
    counts = torch.full((1,), num_points, dtype=torch.long)
    for l in range(1, level + 1):
        res_p = 1 << (l - 1)
        cells = (prev.to(torch.float32) + 0.5) / res_p
        probs = probs_fn(cells[None], l)[0]
        sampled = systematic_rows(probs, counts, pool[l - 1, :prev.shape[0]]).flatten()
        child = (prev[:, None, :] * 2 + CHILD_OFFSET[None]).flatten(0, 1)
        mask = sampled > 0
        if rec is not None:
            rec[f'oct_cells_{l}'] = cells.numpy()
            rec[f'oct_probs_{l}'] = probs.float().numpy()
            rec[f'oct_counts_{l}'] = counts.numpy()
        prev, counts = child[mask], sampled[mask]
    coords = torch.repeat_interleave(prev, counts, dim=0)
    return ((coords.to(torch.float32) + leaf[0]) / (1 << level))[None]


# ---------------------------------------------------------------- Gaussians and PLY

def inverse_sigmoid(x):
    return torch.log(x / (1 - x))


def inverse_softplus(x):
    return x + torch.log(-torch.expm1(-x))


LAYOUT = [('_xyz', 3), ('_features_dc', 3), ('_scaling', 3), ('_rotation', 4), ('_opacity', 1), ('_offset_scale', 1)]


def to_gaussians(points, features, rep, perturbation, base_offset_scale):
    """GaussianFixedlenDecoder.to_representation + Gaussian.__init__ for one batch, float32.
    points [1, L, 3], features [1, L, 480] torch -> dict of activated tensors (z-up): xyz [N, 3], features_dc [N, 1, 3],
    scaling [N, 3], rotation [N, 4] (w, x, y, z), opacity [N, 1]."""
    features = features.to(torch.float32)
    L = features.shape[1]
    sl, start = {}, 0
    for name, size in LAYOUT:
        sl[name] = features[0, :, start:start + PER_ANCHOR * size].reshape(L, PER_ANCHOR, size)
        start += PER_ANCHOR * size
    lr = rep['lr']
    offset_scale = F.softplus(sl['_offset_scale'] + base_offset_scale)
    offset = sl['_xyz'] * lr['_xyz'] + perturbation
    offset = torch.tanh(offset) * 0.5 * rep['perturbe_size'] * offset_scale
    raw_xyz = (offset + points[0, :, None, :]).flatten(0, 1)
    scaling_offset = inverse_softplus(torch.tensor(rep['scaling_bias']))
    opacity_offset = inverse_sigmoid(torch.tensor(rep['opacity_bias']))
    scaling = F.softplus(sl['_scaling'].flatten(0, 1) * lr['_scaling'] + scaling_offset)
    return {
        'xyz': raw_xyz * 1.0 - 0.5,
        'features_dc': (sl['_features_dc'].flatten(0, 1) * lr['_features_dc'])[:, None, :],
        'scaling': torch.sqrt(torch.square(scaling) + rep['3d_filter_kernel_size'] ** 2),
        'rotation': F.normalize(sl['_rotation'].flatten(0, 1) * lr['_rotation'] + torch.tensor([1.0, 0.0, 0.0, 0.0])),
        'opacity': torch.sigmoid(sl['_opacity'].flatten(0, 1) * lr['_opacity'] + opacity_offset),
    }


def quaternion_multiply(a, b):
    aw, ax, ay, az = a
    bw, bx, by, bz = b[:, 0], b[:, 1], b[:, 2], b[:, 3]
    return np.stack([aw * bw - ax * bx - ay * by - az * bz, aw * bx + ax * bw + ay * bz - az * by,
                     aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw], axis=1)


PLY_NAMES = ['x', 'y', 'z', 'nx', 'ny', 'nz', 'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity',
             'scale_0', 'scale_1', 'scale_2', 'rot_0', 'rot_1', 'rot_2', 'rot_3']


def ply_rows(g):
    """idol Gaussian.save_ply's attribute rows [N, 17] float32 (z-up turned to y-up)."""
    xyz = g['xyz'].detach().cpu().numpy()
    f_dc = g['features_dc'].detach().transpose(1, 2).flatten(start_dim=1).contiguous().cpu().numpy()
    eps = 1e-6
    opacities = inverse_sigmoid(g['opacity'].clamp(eps, 1 - eps)).detach().cpu().numpy()
    scale = torch.log(g['scaling'].clamp_min(eps)).detach().cpu().numpy()
    rotation = g['rotation'].detach().cpu().numpy()
    xyz = np.stack([xyz[:, 0], -xyz[:, 2], xyz[:, 1]], axis=1)
    rotation = quaternion_multiply(np.array([np.sqrt(0.5), np.sqrt(0.5), 0.0, 0.0]), rotation)
    return np.concatenate((xyz, np.zeros_like(xyz), f_dc, opacities, scale, rotation), axis=1).astype(np.float32)


def write_ply(rows, path):
    """Binary little-endian PLY with the 17 float properties of save_ply (the same bytes plyfile writes)."""
    header = ['ply', 'format binary_little_endian 1.0', f'element vertex {rows.shape[0]}']
    header += [f'property float {n}' for n in PLY_NAMES] + ['end_header']
    with open(path, 'wb') as f:
        f.write(('\n'.join(header) + '\n').encode('ascii'))
        f.write(np.ascontiguousarray(rows, dtype='<f4').tobytes())


def read_ply(path):
    with open(path, 'rb') as f:
        data = f.read()
    end = data.index(b'end_header\n') + len(b'end_header\n')
    head = data[:end].decode('ascii').splitlines()
    names = [l.split()[-1] for l in head if l.startswith('property')]
    n = int([l for l in head if l.startswith('element vertex')][0].split()[-1])
    return names, np.frombuffer(data[end:], dtype='<f4').reshape(n, len(names))