trellis-2-mrp-mlx/trellis2/gltf_validation.py
m3ultra 0db816b55d
Some checks failed
CodeQL Advanced / Analyze (${{ matrix.language }}) (none, c-cpp) (push) Has been cancelled
CodeQL Advanced / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
vertex baker: export contract + vertex normals; validator accepts COLOR_0 asset class
- _export_pbr vertex branch returns (trimesh, pre_simplified) per contract
- materialize vertex_normals pre-export (glTF NORMAL, same trick as _raw_trimesh)
- gltf_validation: COLOR_0-without-UVs primitives are a legitimate color-bearing
  static asset (glTF default material x COLOR_0); texture checks skip for them

Full gate now EXIT=0: 71.96s e2e, 26.5GB peak (from A0 216.6s / 75.4GB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:53:35 +10:00

159 lines
6.8 KiB
Python

"""Small glTF 2.0 validator for generated TRELLIS runtime outputs."""
from __future__ import annotations
import base64
import hashlib
import io
import json
import struct
from pathlib import Path
from typing import Any
from PIL import Image
JSON_CHUNK = 0x4E4F534A
BIN_CHUNK = 0x004E4942
def _load_glb(path: Path) -> tuple[dict[str, Any], bytes, bytes]:
payload = path.read_bytes()
if len(payload) < 20:
raise ValueError(f"truncated GLB: {path}")
magic, version, declared_length = struct.unpack_from("<4sII", payload, 0)
if magic != b"glTF" or version != 2 or declared_length != len(payload):
raise ValueError(f"not a complete glTF 2.0 binary: {path}")
document = None
binary = b""
offset = 12
while offset < len(payload):
chunk_length, chunk_type = struct.unpack_from("<II", payload, offset)
offset += 8
chunk = payload[offset : offset + chunk_length]
offset += chunk_length
if chunk_type == JSON_CHUNK:
document = json.loads(chunk.rstrip(b" \t\r\n\0").decode("utf-8"))
elif chunk_type == BIN_CHUNK:
binary = chunk
if document is None:
raise ValueError(f"missing GLB JSON chunk: {path}")
return document, binary, payload
def _view(document: dict[str, Any], binary: bytes, index: int) -> bytes:
view = document["bufferViews"][index]
start = int(view.get("byteOffset", 0))
return binary[start : start + int(view["byteLength"])]
def _image(document: dict[str, Any], binary: bytes, index: int) -> bytes:
descriptor = document["images"][index]
if "bufferView" in descriptor:
return _view(document, binary, int(descriptor["bufferView"]))
uri = descriptor.get("uri", "")
if uri.startswith("data:"):
return base64.b64decode(uri.split(",", 1)[1])
raise ValueError("all runtime textures must be embedded")
def _texture_payload(document: dict[str, Any], binary: bytes, info: dict[str, Any]) -> bytes:
texture = document["textures"][int(info["index"])]
return _image(document, binary, int(texture["source"]))
def inspect_glb(path: Path, *, require_pbr: bool) -> dict[str, Any]:
document, binary, payload = _load_glb(path)
if document.get("asset", {}).get("version") != "2.0":
raise ValueError("asset.version must be 2.0")
if document.get("animations") or document.get("cameras"):
raise ValueError("static outputs must not contain animations or cameras")
if document.get("extensions", {}).get("KHR_lights_punctual", {}).get("lights"):
raise ValueError("static outputs must not contain lights")
vertices = 0
triangles = 0
bounds_min = [float("inf")] * 3
bounds_max = [float("-inf")] * 3
base_hashes = []
mr_hashes = []
primitive_count = 0
for mesh in document.get("meshes", []):
for primitive in mesh.get("primitives", []):
primitive_count += 1
if int(primitive.get("mode", 4)) != 4:
raise ValueError("only TRIANGLES primitives are supported")
attributes = primitive.get("attributes", {})
required = ["POSITION", "NORMAL"]
missing = [name for name in required if name not in attributes]
# a color-bearing static asset may carry UVs (texture bake) OR
# per-vertex colors (vertex bake) — either satisfies require_pbr
if require_pbr and "TEXCOORD_0" not in attributes \
and "COLOR_0" not in attributes:
missing.append("TEXCOORD_0|COLOR_0")
if missing:
raise ValueError(f"primitive is missing attributes: {', '.join(missing)}")
position = document["accessors"][int(attributes["POSITION"])]
if not position.get("min") or not position.get("max"):
raise ValueError("POSITION accessor has no bounds")
vertices += int(position["count"])
for axis in range(3):
bounds_min[axis] = min(bounds_min[axis], float(position["min"][axis]))
bounds_max[axis] = max(bounds_max[axis], float(position["max"][axis]))
index_count = (
int(document["accessors"][int(primitive["indices"])]["count"])
if "indices" in primitive
else int(position["count"])
)
if index_count <= 0 or index_count % 3:
raise ValueError("invalid triangle index count")
triangles += index_count // 3
_vertex_colored = ("COLOR_0" in attributes
and "TEXCOORD_0" not in attributes)
if require_pbr and not _vertex_colored:
if "material" not in primitive:
raise ValueError("PBR primitive has no material")
material = document["materials"][int(primitive["material"])]
pbr = material.get("pbrMetallicRoughness", {})
for key, hashes in (
("baseColorTexture", base_hashes),
("metallicRoughnessTexture", mr_hashes),
):
if key not in pbr:
raise ValueError(f"PBR material is missing {key}")
texture = _texture_payload(document, binary, pbr[key])
hashes.append(hashlib.sha256(texture).hexdigest())
with Image.open(io.BytesIO(_texture_payload(document, binary, pbr["baseColorTexture"]))) as image:
if "A" not in image.getbands():
raise ValueError("base-color texture is missing generated alpha")
if primitive_count == 0 or vertices == 0 or triangles == 0:
raise ValueError("GLB contains no non-empty mesh")
dimensions = [bounds_max[index] - bounds_min[index] for index in range(3)]
if any(dimension <= 0 for dimension in dimensions):
raise ValueError("GLB has empty bounds")
return {
"sha256": hashlib.sha256(payload).hexdigest(),
"bytes": len(payload),
"vertices": vertices,
"triangles": triangles,
"primitives": primitive_count,
"bounds_min": bounds_min,
"bounds_max": bounds_max,
"dimensions": dimensions,
"base_color_image_hashes": base_hashes,
"metallic_roughness_image_hashes": mr_hashes,
}
def validate_output_pair(raw_path: Path, candidate_path: Path) -> dict[str, Any]:
raw = inspect_glb(raw_path, require_pbr=False)
candidate = inspect_glb(candidate_path, require_pbr=True)
for raw_size, candidate_size in zip(raw["dimensions"], candidate["dimensions"]):
relative_delta = abs(raw_size - candidate_size) / max(raw_size, 1e-8)
if relative_delta > 0.05:
raise ValueError(
"raw_full.glb and candidate_pbr.glb bounds differ by more than 5%"
)
return {"raw_full": raw, "candidate_pbr": candidate, "bounds_consistent": True}