Decoder ops: spatial<->channel, ConvNeXt block, C2S upsampling block
Completes the sparse op surface Pixal3D's decoders need. spatial2channel/channel2spatial are sparse space-to-depth and its inverse. The slot index is sum_i (coord[i] % f) * f**i - axis 0 FASTEST varying, the reverse of C order. decodes it the same way; using C order in one and this in the other would misplace every child while keeping all shapes valid. SparseResBlockC2S3d widens to out_channels*8 so channel2spatial can redistribute those channels across the 8 children, with a predicted subdiv mask deciding which children exist - so the occupied set grows selectively (32 voxels -> 122, not 256). Tests cover exact round-trip, zero-fill of unoccupied slots, and selective growth.
This commit is contained in:
parent
93e31f9f80
commit
5dd3f11d92
@ -17,7 +17,9 @@ import numpy as np
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from trellis_sparse_mlx.conv import SubMConv3d, build_indice_map, _kernel_offsets
|
from trellis_sparse_mlx.conv import SubMConv3d, build_indice_map, _kernel_offsets
|
||||||
from trellis_sparse_mlx.tensor import SparseTensor, subdivide, downsample, upsample
|
from trellis_sparse_mlx.tensor import (
|
||||||
|
SparseTensor, subdivide, downsample, upsample, spatial2channel, channel2spatial,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reference_subm_conv(coords, feats, weight, bias, k):
|
def reference_subm_conv(coords, feats, weight, bias, k):
|
||||||
@ -179,6 +181,56 @@ def test_upsample_can_drop_a_voxel():
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_spatial_channel_roundtrip():
|
||||||
|
"""pack then unpack must recover coords and features exactly."""
|
||||||
|
rng = np.random.default_rng(20)
|
||||||
|
co = np.concatenate(
|
||||||
|
[np.zeros((60, 1), dtype=np.int32), rng.integers(0, 8, (60, 3)).astype(np.int32)], 1
|
||||||
|
)
|
||||||
|
co = np.unique(co, axis=0)
|
||||||
|
co = co[np.lexsort((co[:, 3], co[:, 2], co[:, 1], co[:, 0]))]
|
||||||
|
fe = rng.standard_normal((len(co), 5)).astype(np.float32)
|
||||||
|
x = SparseTensor(mx.array(fe), mx.array(co))
|
||||||
|
packed = spatial2channel(x, 2)
|
||||||
|
assert packed.feats.shape[1] == 5 * 8, packed.feats.shape
|
||||||
|
back = channel2spatial(packed, factor=2)
|
||||||
|
bc, bf = np.asarray(back.coords), np.asarray(back.feats)
|
||||||
|
o2 = np.lexsort((bc[:, 3], bc[:, 2], bc[:, 1], bc[:, 0]))
|
||||||
|
assert np.array_equal(co, bc[o2]), "coords did not round-trip"
|
||||||
|
err = np.abs(fe - bf[o2]).max()
|
||||||
|
assert err < 1e-6, f"feats round-trip err {err:.3g}"
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
def test_spatial2channel_zero_fills_empty_slots():
|
||||||
|
"""A lone voxel occupies exactly one of the 8 slots; the rest must be zero."""
|
||||||
|
co = np.array([[0, 1, 0, 0]], dtype=np.int32) # subidx = 1*2^0 = 1
|
||||||
|
fe = np.array([[3.0, 4.0]], dtype=np.float32)
|
||||||
|
p = spatial2channel(SparseTensor(mx.array(fe), mx.array(co)), 2)
|
||||||
|
f = np.asarray(p.feats).reshape(8, 2)
|
||||||
|
assert np.allclose(f[1], [3.0, 4.0]), f"slot 1 wrong: {f[1]}"
|
||||||
|
others = np.delete(f, 1, axis=0)
|
||||||
|
assert np.abs(others).max() == 0, "empty slots were not zero"
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2s_selective_growth():
|
||||||
|
"""channel2spatial with a mask emits only flagged children."""
|
||||||
|
co = np.array([[0, 0, 0, 0], [0, 1, 1, 1]], dtype=np.int32)
|
||||||
|
fe = np.arange(2 * 8 * 3, dtype=np.float32).reshape(2, 24) # C=3, 8 slots
|
||||||
|
sub = np.zeros((2, 8), dtype=np.float32)
|
||||||
|
sub[0, 0] = 1
|
||||||
|
sub[1, 3] = 1
|
||||||
|
sub[1, 5] = 1
|
||||||
|
x = SparseTensor(mx.array(fe), mx.array(co))
|
||||||
|
out = channel2spatial(x, SparseTensor(mx.array(sub), mx.array(co)), 2)
|
||||||
|
assert out.coords.shape[0] == 3, out.coords.shape
|
||||||
|
assert out.feats.shape[1] == 3, out.feats.shape
|
||||||
|
# child 0 of voxel 0 must take channel slot 0
|
||||||
|
assert np.allclose(np.asarray(out.feats)[0], fe[0, 0:3]), np.asarray(out.feats)[0]
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
tests = [
|
tests = [
|
||||||
("subm k=3 vs reference", lambda: test_subm_conv_matches_reference(3)),
|
("subm k=3 vs reference", lambda: test_subm_conv_matches_reference(3)),
|
||||||
@ -191,6 +243,9 @@ if __name__ == "__main__":
|
|||||||
("downsample max vs mean", test_downsample_modes),
|
("downsample max vs mean", test_downsample_modes),
|
||||||
("masked upsample", test_masked_upsample),
|
("masked upsample", test_masked_upsample),
|
||||||
("upsample drops voxels", test_upsample_can_drop_a_voxel),
|
("upsample drops voxels", test_upsample_can_drop_a_voxel),
|
||||||
|
("spatial<->channel", test_spatial_channel_roundtrip),
|
||||||
|
("s2c zero-fills slots", test_spatial2channel_zero_fills_empty_slots),
|
||||||
|
("c2s selective growth", test_c2s_selective_growth),
|
||||||
]
|
]
|
||||||
failed = 0
|
failed = 0
|
||||||
for name, fn in tests:
|
for name, fn in tests:
|
||||||
|
|||||||
@ -26,7 +26,15 @@ from .dit import (
|
|||||||
SparseProjectAttention,
|
SparseProjectAttention,
|
||||||
ModulatedSparseTransformerCrossBlock,
|
ModulatedSparseTransformerCrossBlock,
|
||||||
)
|
)
|
||||||
from .tensor import SparseTensor, VarLenTensor, downsample, subdivide, upsample
|
from .tensor import (
|
||||||
|
SparseTensor,
|
||||||
|
VarLenTensor,
|
||||||
|
channel2spatial,
|
||||||
|
downsample,
|
||||||
|
spatial2channel,
|
||||||
|
subdivide,
|
||||||
|
upsample,
|
||||||
|
)
|
||||||
from .ops import (
|
from .ops import (
|
||||||
LayerNorm32,
|
LayerNorm32,
|
||||||
SparseFeedForwardNet,
|
SparseFeedForwardNet,
|
||||||
@ -38,14 +46,18 @@ from .ops import (
|
|||||||
SparseSiLU,
|
SparseSiLU,
|
||||||
SparseTransformerBlock,
|
SparseTransformerBlock,
|
||||||
SparseTransformerCrossBlock,
|
SparseTransformerCrossBlock,
|
||||||
|
SparseConvNeXtBlock3d,
|
||||||
|
SparseResBlockC2S3d,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SparseTensor", "VarLenTensor", "subdivide", "downsample", "upsample",
|
"SparseTensor", "VarLenTensor", "subdivide", "downsample", "upsample",
|
||||||
|
"spatial2channel", "channel2spatial",
|
||||||
"SubMConv3d", "build_indice_map",
|
"SubMConv3d", "build_indice_map",
|
||||||
"SparseLinear", "LayerNorm32", "SparseGroupNorm32", "SparseSiLU", "SparseGELU",
|
"SparseLinear", "LayerNorm32", "SparseGroupNorm32", "SparseSiLU", "SparseGELU",
|
||||||
"SparseResBlock", "SparseFeedForwardNet", "SparseMultiHeadAttention",
|
"SparseResBlock", "SparseFeedForwardNet", "SparseMultiHeadAttention",
|
||||||
"SparseTransformerBlock", "SparseTransformerCrossBlock",
|
"SparseTransformerBlock", "SparseTransformerCrossBlock",
|
||||||
|
"SparseConvNeXtBlock3d", "SparseResBlockC2S3d",
|
||||||
# DiT / flow-model pieces
|
# DiT / flow-model pieces
|
||||||
"MultiHeadRMSNorm", "apply_rope", "rope_phases_from_coords", "TimestepEmbedder", "DiTAttention",
|
"MultiHeadRMSNorm", "apply_rope", "rope_phases_from_coords", "TimestepEmbedder", "DiTAttention",
|
||||||
"ModulatedTransformerCrossBlock", "ProjectAttention",
|
"ModulatedTransformerCrossBlock", "ProjectAttention",
|
||||||
|
|||||||
@ -254,3 +254,78 @@ class SparseTransformerCrossBlock(nn.Module):
|
|||||||
x = x.replace(x.feats + h.feats)
|
x = x.replace(x.feats + h.feats)
|
||||||
h = self.mlp(x.replace(self.norm3(x.feats)))
|
h = self.mlp(x.replace(self.norm3(x.feats)))
|
||||||
return x.replace(x.feats + h.feats)
|
return x.replace(x.feats + h.feats)
|
||||||
|
|
||||||
|
|
||||||
|
class SparseConvNeXtBlock3d(nn.Module):
|
||||||
|
"""conv -> norm -> MLP, residual. The decoder workhorse in Pixal3D.
|
||||||
|
|
||||||
|
Note the ordering: convolution comes FIRST, before the norm — unlike the pre-norm
|
||||||
|
SparseResBlock. And the residual adds the block input, not the post-conv tensor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channels: int, mlp_ratio: float = 4.0):
|
||||||
|
super().__init__()
|
||||||
|
self.channels = channels
|
||||||
|
self.norm = LayerNorm32(channels, affine=True, eps=1e-6)
|
||||||
|
self.conv = SubMConv3d(channels, channels, 3)
|
||||||
|
hidden = int(channels * mlp_ratio)
|
||||||
|
self.mlp_0 = nn.Linear(channels, hidden)
|
||||||
|
self.mlp_2 = nn.Linear(hidden, channels)
|
||||||
|
|
||||||
|
def __call__(self, x: SparseTensor) -> SparseTensor:
|
||||||
|
h = self.conv(x)
|
||||||
|
h = h.replace(self.norm(h.feats))
|
||||||
|
h = h.replace(self.mlp_2(nn.silu(self.mlp_0(h.feats))))
|
||||||
|
return h.replace(h.feats + x.feats)
|
||||||
|
|
||||||
|
|
||||||
|
class SparseResBlockC2S3d(nn.Module):
|
||||||
|
"""Upsampling residual block: conv to 8x channels, then channel->spatial.
|
||||||
|
|
||||||
|
`conv1` widens to `out_channels * 8` so that `channel2spatial` can redistribute those
|
||||||
|
channels into the 8 children of each voxel. Which children exist is decided by the
|
||||||
|
`subdiv` mask — predicted by `to_subdiv` when `pred_subdiv` is set — so the occupied
|
||||||
|
set grows selectively rather than always x8.
|
||||||
|
|
||||||
|
The skip path is a `repeat_interleave`, not a linear: the input is upsampled by the
|
||||||
|
same channel->spatial step and its channels are repeated to match `out_channels`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
channels: int,
|
||||||
|
out_channels: Optional[int] = None,
|
||||||
|
pred_subdiv: bool = False,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.channels = channels
|
||||||
|
self.out_channels = out_channels or channels
|
||||||
|
self.pred_subdiv = pred_subdiv
|
||||||
|
self.norm1 = LayerNorm32(channels, affine=True, eps=1e-6)
|
||||||
|
self.norm2 = LayerNorm32(self.out_channels, affine=False, eps=1e-6)
|
||||||
|
self.conv1 = SubMConv3d(channels, self.out_channels * 8, 3)
|
||||||
|
self.conv2 = SubMConv3d(self.out_channels, self.out_channels, 3)
|
||||||
|
if pred_subdiv:
|
||||||
|
self.to_subdiv = SparseLinear(channels, 8)
|
||||||
|
|
||||||
|
def __call__(self, x: SparseTensor, subdiv: Optional[SparseTensor] = None):
|
||||||
|
from .tensor import channel2spatial
|
||||||
|
|
||||||
|
if self.pred_subdiv:
|
||||||
|
subdiv = self.to_subdiv(x)
|
||||||
|
mask = subdiv.replace(subdiv.feats > 0) if subdiv is not None else None
|
||||||
|
|
||||||
|
h = x.replace(self.norm1(x.feats))
|
||||||
|
h = h.replace(nn.silu(h.feats))
|
||||||
|
h = self.conv1(h)
|
||||||
|
h = channel2spatial(h, mask, 2)
|
||||||
|
xu = channel2spatial(x, mask, 2)
|
||||||
|
|
||||||
|
h = h.replace(self.norm2(h.feats))
|
||||||
|
h = h.replace(nn.silu(h.feats))
|
||||||
|
h = self.conv2(h)
|
||||||
|
|
||||||
|
reps = self.out_channels // max(self.channels // 8, 1)
|
||||||
|
skip = mx.repeat(xu.feats, reps, axis=1) if reps > 1 else xu.feats
|
||||||
|
out = h.replace(h.feats + skip)
|
||||||
|
return (out, subdiv) if self.pred_subdiv else out
|
||||||
|
|||||||
@ -255,3 +255,102 @@ def upsample(
|
|||||||
mx.array(new_coords.astype(_np.int32)),
|
mx.array(new_coords.astype(_np.int32)),
|
||||||
scale=tuple(s / factor for s in x._scale),
|
scale=tuple(s / factor for s in x._scale),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subidx_of(coords_np, factor: int):
|
||||||
|
"""Slot index of each voxel within its factor^3 cell.
|
||||||
|
|
||||||
|
subidx = sum_i (coord[i] % factor) * factor**i — axis 0 is the FASTEST varying,
|
||||||
|
which is the reverse of C order. `upsample` and `channel2spatial` decode it back the
|
||||||
|
same way (`subidx // factor**i % factor`); using C order in one place and this in the
|
||||||
|
other misplaces every child while keeping all shapes valid.
|
||||||
|
"""
|
||||||
|
import numpy as _np
|
||||||
|
|
||||||
|
r = coords_np[:, 1:] % factor
|
||||||
|
return sum(r[:, i] * (factor**i) for i in range(3)).astype(_np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def spatial2channel(x: "SparseTensor", factor: int = 2) -> "SparseTensor":
|
||||||
|
"""Sparse space-to-depth: [N, C] at res R -> [N', C*factor^3] at res R/factor.
|
||||||
|
|
||||||
|
Voxels sharing a coarse cell are packed into disjoint channel slots; slots with no
|
||||||
|
voxel are ZERO. Registers the inverse mapping in the spatial cache so a later
|
||||||
|
`channel2spatial` can undo it without being handed a subdivision mask.
|
||||||
|
"""
|
||||||
|
import numpy as _np
|
||||||
|
|
||||||
|
c = _np.asarray(x.coords, dtype=_np.int64)
|
||||||
|
feats = _np.asarray(x.feats)
|
||||||
|
n, ch = feats.shape
|
||||||
|
f3 = factor**3
|
||||||
|
|
||||||
|
coarse = c.copy()
|
||||||
|
coarse[:, 1:] //= factor
|
||||||
|
maxs = coarse[:, 1:].max(axis=0) + 1
|
||||||
|
off = _np.array(
|
||||||
|
[maxs[0] * maxs[1] * maxs[2], maxs[1] * maxs[2], maxs[2], 1], dtype=_np.int64
|
||||||
|
)
|
||||||
|
code = (coarse * off).sum(axis=1)
|
||||||
|
uniq, idx = _np.unique(code, return_inverse=True)
|
||||||
|
subidx = _subidx_of(c, factor)
|
||||||
|
|
||||||
|
out = _np.zeros((uniq.shape[0] * f3, ch), dtype=feats.dtype)
|
||||||
|
out[idx * f3 + subidx] = feats
|
||||||
|
new_coords = _np.stack(
|
||||||
|
[uniq // off[0], (uniq // off[1]) % maxs[0], (uniq // off[2]) % maxs[1], uniq % maxs[2]],
|
||||||
|
axis=-1,
|
||||||
|
).astype(_np.int32)
|
||||||
|
|
||||||
|
res = SparseTensor(
|
||||||
|
mx.array(out.reshape(uniq.shape[0], ch * f3)),
|
||||||
|
mx.array(new_coords),
|
||||||
|
scale=tuple(s * factor for s in x._scale),
|
||||||
|
)
|
||||||
|
res.register_spatial_cache(f"channel2spatial_{factor}", (c.astype(_np.int32), idx, subidx))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def channel2spatial(
|
||||||
|
x: "SparseTensor", subdivision: "SparseTensor" = None, factor: int = 2
|
||||||
|
) -> "SparseTensor":
|
||||||
|
"""Inverse of `spatial2channel`: [N, C*factor^3] -> [N', C] at finer resolution.
|
||||||
|
|
||||||
|
Uses the cached mapping when this follows a `spatial2channel`; otherwise a
|
||||||
|
`subdivision` mask ([N, factor^3], nonzero = keep) selects which children exist.
|
||||||
|
Unlike `upsample`, features are SPLIT across children rather than replicated: child
|
||||||
|
j takes channel slot j.
|
||||||
|
"""
|
||||||
|
import numpy as _np
|
||||||
|
|
||||||
|
cache = x.cache_get(f"channel2spatial_{factor}")
|
||||||
|
f3 = factor**3
|
||||||
|
feats = _np.asarray(x.feats)
|
||||||
|
ch = feats.shape[1] // f3
|
||||||
|
|
||||||
|
if cache is not None:
|
||||||
|
new_coords, idx, subidx = cache
|
||||||
|
new_coords = _np.asarray(new_coords, dtype=_np.int32)
|
||||||
|
else:
|
||||||
|
if subdivision is None:
|
||||||
|
raise ValueError(
|
||||||
|
"channel2spatial needs a subdivision mask, or to follow a spatial2channel"
|
||||||
|
)
|
||||||
|
sub = _np.asarray(subdivision.feats) > 0
|
||||||
|
n_leaf = sub.sum(axis=1)
|
||||||
|
idx = _np.repeat(_np.arange(sub.shape[0]), n_leaf)
|
||||||
|
subidx = _np.nonzero(sub)[1]
|
||||||
|
base = _np.asarray(x.coords, dtype=_np.int64).copy()
|
||||||
|
base[:, 1:] *= factor
|
||||||
|
new_coords = base[idx]
|
||||||
|
for i in range(3):
|
||||||
|
new_coords[:, i + 1] += subidx // (factor**i) % factor
|
||||||
|
new_coords = new_coords.astype(_np.int32)
|
||||||
|
|
||||||
|
flat = feats.reshape(feats.shape[0] * f3, ch)
|
||||||
|
new_feats = flat[idx * f3 + subidx]
|
||||||
|
return SparseTensor(
|
||||||
|
mx.array(new_feats),
|
||||||
|
mx.array(new_coords),
|
||||||
|
scale=tuple(s / factor for s in x._scale),
|
||||||
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user