pixal3d_mrp_mlx/pixal3d_mlx/dino.py
m3ultra bd0f3f1941 Proj conditioning: camera back-projection, grid_sample, DINOv3 (12/12 vs upstream)
The pixel-aligned conditioning is the ONLY thing separating this port from the
trellis2_mlx operator already in MODELBEAST — upstream's main branch is the
TRELLIS.2 backbone, so everything else here is TRELLIS.2 with a different head.
This lands that head.

  proj.py  ProjGrid, project_points, bilinear_sample, distance_from_fov — MLX
  dino.py  DINOv3 ViT-L/16 left in torch on MPS (run once per image, outside the
           25-step loop; transformers gives exact parity for free)
  cond.py  encode_image_proj equivalent -> {'global','proj'} + zero uncond

The extractor has no sparse conv, so upstream RUNS on CPU torch here and is a real
oracle. All 12 checks diff against it, not against a transcription:

  bilinear_sample vs grid_sample     max diff 2.4e-07   corr 1.00000000
  project_points pixels/depth/mask   exact
  ProjGrid forward (ss, 16^3)        max diff 1.9e-05   corr 1.00000000
  extractor global tokens            max diff 0.0e+00   corr 1.00000000
  extractor proj features            max diff 4.8e-06   corr 1.00000000

Three details that a plain transcription gets wrong and eyeballing cannot catch:
grid_sample's align_corners=False maps a normalised coord to ((c+1)*size-1)/2, not
(c+1)/2*(size-1) — half a texel, invisible until you compare; padding_mode='border'
clamps the SOURCE INDEX before corners are taken, not the corners after, which
changes the weights on every silhouette edge (tested with deliberately out-of-range
grid coords); and the camera looks down -Z, so a sign slip still yields a plausible
grid that samples the mirror image.

Also corrects a shape assumption from the earlier smoke test: 'global' is CLS + 4
register tokens = [B,5,1024], NOT the 1370 image tokens. The patch tokens go to the
proj branch. That asymmetry IS the architecture.

Note the parameterless final layer_norm in extract_features — not model.norm, which
has weights. Same trap as the ss_flow bug: no checkpoint trace, 200x output error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:52:41 +10:00

109 lines
4.7 KiB
Python

"""DINOv3 image encoder, deliberately left in torch.
This is a stock ViT-L/16 run ONCE per image, outside the 25-step denoising loop, so it
is not worth porting to MLX: torch on MPS runs it fine and `transformers` gives exact
parity with upstream for free. The effort belongs in the 30-block DiT loops, which are
already ported and verified. The output is handed over as mx.arrays so nothing
downstream of here touches torch.
`facebook/dinov3-*` is gated; upstream itself falls back to the ungated `camenduru`
mirror (there is a HACK in pixal3d/pipelines/trellis2_image_to_3d.py doing exactly this
rewrite), so the mirror is the default here too.
"""
from __future__ import annotations
from typing import Tuple
import mlx.core as mx
import numpy as np
MODEL_NAME = "camenduru/dinov3-vitl16-pretrain-lvd1689m"
# ImageNet statistics — upstream's only transform (it assumes the image is pre-resized).
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 3, 1, 1)
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 3, 1, 1)
def _layers(model):
"""The transformer block list, across transformers versions.
Upstream pins transformers==4.57.3 where `DINOv3ViTModel.layer` is a ModuleList on
the model itself. In 5.x it moved to `model.model.layer`. Both are the same 24
blocks; only the attribute path changed.
"""
for owner in (model, getattr(model, "model", None)):
if owner is None:
continue
for attr in ("layer", "layers"):
found = getattr(owner, attr, None)
if found is not None and hasattr(found, "__len__"):
return found
raise AttributeError("could not locate the DINOv3 transformer block list")
class DinoV3Encoder:
"""Wraps DINOv3 and returns (global tokens, patch feature map) as mx.arrays."""
def __init__(self, model_name: str = MODEL_NAME, image_size: int = 512,
device: str | None = None):
import torch
from transformers import DINOv3ViTModel
if device is None:
device = "mps" if torch.backends.mps.is_available() else "cpu"
self.device = device
self.image_size = image_size
self.model = DINOv3ViTModel.from_pretrained(model_name).eval().to(device)
self.model.requires_grad_(False)
self.layers = _layers(self.model)
self.patch_size = self.model.config.patch_size
self.patch_number = image_size // self.patch_size
self.embed_dim = self.model.config.hidden_size
self.num_register_tokens = getattr(self.model.config, "num_register_tokens", 4)
def _hidden_states(self, image):
"""Upstream's `extract_features`, reproduced exactly.
The final `layer_norm` is PARAMETERLESS — it is not `self.model.norm`, which
carries weights. Substituting the model's own norm here changes the scale of
every conditioning vector, and because it has no checkpoint trace it is
precisely the class of bug that weight-key matching cannot catch (see CLAUDE.md
— the same trap cost us a 200x-too-large ss_flow output).
"""
import torch
import torch.nn.functional as F
image = image.to(self.model.embeddings.patch_embeddings.weight.dtype)
hidden_states = self.model.embeddings(image, bool_masked_pos=None)
position_embeddings = self.model.rope_embeddings(image)
for layer_module in self.layers:
hidden_states = layer_module(hidden_states, position_embeddings=position_embeddings)
if isinstance(hidden_states, (tuple, list)):
hidden_states = hidden_states[0]
return F.layer_norm(hidden_states, hidden_states.shape[-1:])
def __call__(self, image_chw01: np.ndarray) -> Tuple[mx.array, mx.array]:
"""[B,3,H,W] float32 in [0,1] -> (global [B,1+regs,D], patches [B,h,w,D]).
`global` is only the CLS token plus the register tokens — the patch tokens do
NOT go into it. They go into the proj branch, which is the whole point of the
architecture: image detail reaches the DiT through back-projection, not
through cross-attention.
"""
import torch
arr = (np.asarray(image_chw01, dtype=np.float32) - _MEAN) / _STD
with torch.no_grad():
z = self._hidden_states(torch.from_numpy(arr).to(self.device))
z = z.float().cpu().numpy()
b, _, d = z.shape
n_reg = self.num_register_tokens
z_global = z[:, : 1 + n_reg] # CLS + registers
z_patch = z[:, 1 + n_reg:] # spatial tokens
z_patch = z_patch.reshape(b, self.patch_number, self.patch_number, d)
return mx.array(z_global), mx.array(z_patch)