pixal3d_mrp_mlx/tests/test_proj.py
m3ultra 6f40fae0cc All four proj extractors: NAF high-res branch, without natten
Completes the conditioning. shape_512 / shape_1024 / tex_1024 run a second HIGH-RES
branch — NAF upsamples the DINOv3 patch map to 512/1024 guided by the RGB image, the
proj grid samples that too, and the branches concatenate. That is why those stages
have proj_channels = embed_dim*2 (2048).

CORRECTS AN EARLIER CLAIM: I said natten is never imported and can be skipped. True of
Pixal3D's own source — but natten is a dependency of NAF (valeoai/NAF), which arrives
at RUNTIME via torch.hub and is not vendored. That is what README Step 3 is for. The
warning was real; the reason was one level down.

natten is a dead end on Apple Silicon regardless:
  cutlass-fna  requires libnatten, which the arm64 build does not produce
  flex-fna     CPU only ('not on a CUDA, ROCm, or CPU device: mps'), AND refuses
               different head dims for QK vs V — which is exactly NAF's shape
               (qk=64, v=256). Worked around, CPU took 243s at 256px and was
               OOM-KILLED (exit 137) at the 512 the pipeline actually needs.

REPLACED BY AN EXACT REDUCTION, not an approximation. NAF resizes K/V from the 32x32
patch map with nearest-exact and then dilates by exactly the upsample factor, so the
dilated high-res neighborhood samples one position per low-res cell and collapses to a
plain clamped 9x9 neighborhood on the 32x32 grid, shared by every high-res pixel in
that cell. Verified against natten's own kernel at three dilations:

  LR  16 -> HR   64 (dil 4)   max diff 7.153e-07
  LR  16 -> HR  128 (dil 8)   max diff 7.153e-07
  LR  32 -> HR  256 (dil 8)   max diff 7.153e-07     (float32 epsilon)

Grouping queries by low-res cell also avoids materialising the high-res neighborhood,
which would be ~87GB of gathered V at 512. Result, on MPS:

  32 ->  512   2.4s      (natten: OOM-killed)
  64 ->  512   0.2s
  64 -> 1024   0.9s

Also fixes a caching bug that stranded NAF on whichever device loaded first, and one
in my own wiring: the high-res branch must reuse the SAME ProjGrid at image_size, not
a new one at naf_target_size. The normalised coordinate carries a 1/resolution term,
so the latter lands ~0.001 off in [-1,1] — a sub-pixel shift on every voxel, in the
one model whose entire premise is pixel alignment.

27/27 green (15 proj incl. the NAF stage vs upstream at corr 1.00000000, 5 sampler,
5 naf vs natten, 2 decoders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:39:29 +10:00

232 lines
9.7 KiB
Python

"""Numerical oracle for the proj conditioning — diffed against upstream, not transcribed.
The proj extractor contains NO sparse convolution, so upstream runs on CPU torch here
and is a real oracle (same situation as the flow models, unlike the sparse decoders).
CLAUDE.md's rule applies: do not reason about correctness, measure it. Three of the
bugs found in this port were invisible to weight-key matching and only fell out of a
correlation against upstream.
Each check reports max abs diff and correlation against the real thing.
"""
import sys
from pathlib import Path
import mlx.core as mx
import numpy as np
import torch
import torch.nn.functional as F
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO / "upstream" / "Pixal3D"))
from pixal3d_mlx import proj as P # noqa: E402
from pixal3d.trainers.flow_matching.mixins.image_conditioned_proj import ( # noqa: E402
ProjGrid as UpstreamProjGrid,
project_points_to_image_batch,
sample_features,
)
FOV = 0.6911 # ~39.6 deg, a typical Pixal3D estimate
PASS, FAIL = [], []
def _report(name, mine, ref, tol=2e-5):
a, b = np.asarray(mine, dtype=np.float64).ravel(), np.asarray(ref, dtype=np.float64).ravel()
diff = float(np.abs(a - b).max())
corr = 1.0 if (a.std() == 0 and b.std() == 0) else float(np.corrcoef(a, b)[0, 1])
ok = diff <= tol and corr > 0.999999
(PASS if ok else FAIL).append(name)
print(f" {'PASS' if ok else 'FAIL'} {name:<38} max diff {diff:.3e} corr {corr:.8f}")
def test_bilinear_sample():
"""grid_sample(align_corners=False, padding_mode='border').
Deliberately includes coordinates well outside [-1,1] — that is the only way the
border-padding and clamp-before-corners behaviour is exercised at all. A naive
lerp passes an in-bounds-only test and fails silently on every silhouette edge,
which is exactly where a pixel-aligned model lives.
"""
rng = np.random.default_rng(0)
b, h, w, c, k = 2, 9, 11, 5, 400
fmap = rng.standard_normal((b, h, w, c)).astype(np.float32)
grid = rng.uniform(-1.8, 1.8, (b, k, 2)).astype(np.float32) # deliberately out of range
mine = P.bilinear_sample(mx.array(fmap), mx.array(grid))
ref = sample_features(
torch.from_numpy(fmap).permute(0, 3, 1, 2), torch.from_numpy(grid)
).permute(0, 2, 1)
_report("bilinear_sample vs grid_sample", mine, ref.numpy())
# and again strictly inside, so an out-of-range-only success can't hide an
# interior interpolation error
grid_in = rng.uniform(-0.95, 0.95, (b, k, 2)).astype(np.float32)
mine_in = P.bilinear_sample(mx.array(fmap), mx.array(grid_in))
ref_in = sample_features(
torch.from_numpy(fmap).permute(0, 3, 1, 2), torch.from_numpy(grid_in)
).permute(0, 2, 1)
_report("bilinear_sample (interior only)", mine_in, ref_in.numpy())
def test_project_points():
rng = np.random.default_rng(1)
b, n, res = 2, 500, 512
pts = rng.uniform(-1, 1, (b, n, 3)).astype(np.float32)
tm = np.broadcast_to(P._FRONT_VIEW, (b, 4, 4)).copy()
tm[:, 1, 3] = -1.7
px, depth, valid = P.project_points(mx.array(pts), mx.array(tm), FOV, res)
r_px, r_depth, r_valid = project_points_to_image_batch(
torch.from_numpy(pts), torch.from_numpy(tm), torch.tensor([FOV] * b), res
)
_report("project_points pixels", px, r_px.numpy(), tol=1e-3)
_report("project_points depth", depth, r_depth.numpy(), tol=1e-5)
same = bool((np.asarray(valid) == r_valid.numpy()).all())
(PASS if same else FAIL).append("valid mask")
print(f" {'PASS' if same else 'FAIL'} {'project_points valid mask':<38} exact match={same}")
def test_distance_from_fov():
"""Upstream's helper lives in inference.py; replicate its exact call here."""
rot = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]])
res, mesh_scale = 512, 1.0
gp = torch.tensor([-1.0, 0.0, 0.0]) @ rot.T
gp = gp / mesh_scale / 2
f_pixels = float((16.0 / torch.tan(torch.tensor(FOV / 2.0))) * res / 32.0)
x_ndc = 0.0 - res / 2.0
ref = f_pixels * float(gp[0]) / x_ndc - float(gp[1])
mine = P.distance_from_fov(FOV, mesh_scale, res)
_report("distance_from_fov", np.array([mine]), np.array([ref]), tol=1e-6)
def test_proj_grid_full():
"""The whole ProjGrid forward at the ss stage's real shapes."""
rng = np.random.default_rng(2)
b, res, grid_res, d = 1, 512, 16, 1024
patch_n = res // 16
fmap = rng.standard_normal((b, patch_n, patch_n, d)).astype(np.float32)
distance = P.distance_from_fov(FOV, 1.0, res)
mine = P.ProjGrid(grid_resolution=grid_res, image_resolution=res)(
mx.array(fmap), FOV, distance, 1.0
)
up = UpstreamProjGrid(grid_resolution=grid_res, image_resolution=res)
ref = up(torch.from_numpy(fmap), torch.tensor([FOV]),
torch.tensor([distance]), torch.tensor([1.0]), None)
_report("ProjGrid forward (ss, 16^3)", mine, ref.numpy())
# the lattice itself must match before the projection can mean anything
_report("grid lattice", P.ProjGrid(grid_res, res).grid_points, up.grid_points.numpy(), tol=1e-7)
def test_full_extractor():
"""End-to-end: our DINOv3 + ProjGrid vs upstream's DinoV3ProjFeatureExtractor.
This is the check that matters — it covers token splitting, the parameterless
final layer_norm, the patch reshape and the projection together, on the real
ViT-L/16 weights.
Upstream's `extract_features` reaches for `self.model.layer`, which transformers
5.x moved to `self.model.model.layer`. We restore the attribute rather than
rewriting the method, so upstream's own unmodified code is what executes.
"""
from pixal3d.trainers.flow_matching.mixins.image_conditioned_proj import (
DinoV3ProjFeatureExtractor,
)
from pixal3d_mlx.cond import ProjConditioner
from pixal3d_mlx.dino import MODEL_NAME, _layers
res, grid_res = 512, 16
rng = np.random.default_rng(3)
img = rng.uniform(0, 1, (1, 3, res, res)).astype(np.float32)
distance = P.distance_from_fov(FOV, 1.0, res)
up = DinoV3ProjFeatureExtractor(model_name=MODEL_NAME, image_size=res,
grid_resolution=grid_res).eval()
if not hasattr(up.model, "layer"):
up.model.layer = _layers(up.model) # transformers 5.x attribute move
r_global, r_proj = up(torch.from_numpy(img), torch.tensor([FOV]),
torch.tensor([distance]), torch.tensor([1.0]), None)
cond, uncond = ProjConditioner("ss", device="cpu")(img, FOV, distance, 1.0)
_report("extractor global tokens", cond["global"], r_global.numpy(), tol=1e-4)
_report("extractor proj features", cond["proj"], r_proj.numpy(), tol=1e-4)
shapes_ok = (cond["global"].shape == tuple(r_global.shape)
and cond["proj"].shape == tuple(r_proj.shape)
and cond["proj"].shape[1] == grid_res ** 3)
(PASS if shapes_ok else FAIL).append("extractor shapes")
print(f" {'PASS' if shapes_ok else 'FAIL'} {'extractor shapes':<38} "
f"global {cond['global'].shape} proj {cond['proj'].shape}")
zeroed = float(np.abs(np.asarray(uncond["proj"])).max()) == 0.0
(PASS if zeroed else FAIL).append("uncond is zeros")
print(f" {'PASS' if zeroed else 'FAIL'} {'uncond is zeros':<38} {zeroed}")
def test_naf_stage_wiring():
"""A NAF stage (shape_512) end to end against upstream's extractor.
SCOPE: `_patch_naf_attention` replaces NAF's natten call globally, so upstream's
extractor runs through the SAME neighborhood kernel we do — this test therefore
verifies the WIRING (guide resizing, which proj_grid the high-res branch reuses,
concat order, proj_channels = embed_dim*2), NOT the kernel. The kernel is verified
separately against natten itself in tests/test_naf.py, which is where that
equivalence has to be established.
"""
from pixal3d.trainers.flow_matching.mixins.image_conditioned_proj import (
DinoV3ProjFeatureExtractor,
)
from pixal3d_mlx.cond import CONFIGS, ProjConditioner
from pixal3d_mlx.dino import MODEL_NAME, _layers
from pixal3d_mlx.naf import _patch_naf_attention, load_naf
cfg = CONFIGS["shape_512"]
res, grid_res = cfg["image_size"], cfg["grid_resolution"]
rng = np.random.default_rng(5)
img = rng.uniform(0, 1, (1, 3, res, res)).astype(np.float32)
distance = P.distance_from_fov(FOV, 1.0, res)
load_naf("cpu")
_patch_naf_attention()
up = DinoV3ProjFeatureExtractor(
model_name=MODEL_NAME, image_size=res, grid_resolution=grid_res,
use_naf_upsample=True, naf_target_size=cfg["naf_target_size"],
).eval()
if not hasattr(up.model, "layer"):
up.model.layer = _layers(up.model)
up._load_naf()
r_global, r_proj = up(torch.from_numpy(img), torch.tensor([FOV]),
torch.tensor([distance]), torch.tensor([1.0]), None)
cond, _ = ProjConditioner("shape_512", device="cpu")(img, FOV, distance, 1.0)
_report("naf stage global", cond["global"], r_global.numpy(), tol=1e-4)
_report("naf stage proj (lr+hr concat)", cond["proj"], r_proj.numpy(), tol=1e-3)
d = up.embed_dim
ok = (cond["proj"].shape == (1, grid_res ** 3, d * 2) == tuple(r_proj.shape))
(PASS if ok else FAIL).append("naf proj_channels")
print(f" {'PASS' if ok else 'FAIL'} {'naf proj_channels = embed_dim*2':<38} "
f"{cond['proj'].shape}")
if __name__ == "__main__":
torch.set_grad_enabled(False)
for fn in (test_bilinear_sample, test_project_points,
test_distance_from_fov, test_proj_grid_full, test_full_extractor,
test_naf_stage_wiring):
fn()
print(f"\n{len(PASS)}/{len(PASS) + len(FAIL)} passed")
if FAIL:
print("FAILED: " + ", ".join(FAIL))
sys.exit(1)