ayzeksalimli's picture
Push project (code, README, Docker/compose, models) — no .github/workflows
9f85448 verified
Raw
History Blame Contribute Delete
7.51 kB
"""Minimal glTF binary reader."""
from __future__ import annotations
import json
import struct
from dataclasses import dataclass, field
from pathlib import Path
import cv2
import numpy as np
_JSON_CHUNK = 0x4E4F534A
_BIN_CHUNK = 0x004E4942
_DTYPES = {
5120: np.int8, 5121: np.uint8, 5122: np.int16,
5123: np.uint16, 5125: np.uint32, 5126: np.float32,
}
_COUNTS = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT2": 4, "MAT3": 9, "MAT4": 16}
@dataclass
class Primitive:
"""One drawable mesh piece."""
positions: np.ndarray
normals: np.ndarray
joints: np.ndarray
weights: np.ndarray
triangles: np.ndarray
color: tuple[int, int, int] = (185, 180, 175)
uv: np.ndarray | None = None
texture: np.ndarray | None = None
@dataclass
class Node:
"""One scene node."""
name: str
matrix: np.ndarray
children: list[int] = field(default_factory=list)
parent: int | None = None
@dataclass
class Gltf:
"""Loaded model."""
nodes: list[Node]
primitives: list[Primitive]
joints: list[int]
inverse_bind: np.ndarray
globals: np.ndarray
def _node_matrix(node: dict) -> np.ndarray:
"""Local transform of one node."""
if "matrix" in node:
return np.array(node["matrix"], dtype=np.float64).reshape(4, 4).T
m = np.eye(4)
if "rotation" in node:
x, y, z, w = node["rotation"]
m[:3, :3] = np.array([
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
])
if "scale" in node:
m[:3, :3] = m[:3, :3] @ np.diag(node["scale"])
if "translation" in node:
m[:3, 3] = node["translation"]
return m
def _decode_image(js: dict, blob: bytes, image_index: int) -> np.ndarray | None:
"""Decode one embedded glTF image to a BGR array."""
images = js.get("images") or []
if not 0 <= image_index < len(images):
return None
image = images[image_index]
if "bufferView" not in image:
return None
view = js["bufferViews"][image["bufferView"]]
start = view.get("byteOffset", 0)
raw = np.frombuffer(blob, dtype=np.uint8, count=view["byteLength"], offset=start)
decoded = cv2.imdecode(raw, cv2.IMREAD_COLOR)
return decoded if decoded is not None and decoded.size else None
def _material_texture(js: dict, blob: bytes, material_index: int) -> np.ndarray | None:
"""Base-color texture of one material, if it has an embedded one."""
pbr = js["materials"][material_index].get("pbrMetallicRoughness", {})
ref = pbr.get("baseColorTexture")
if ref is None:
return None
textures = js.get("textures") or []
if not 0 <= ref.get("index", -1) < len(textures):
return None
source = textures[ref["index"]].get("source")
return None if source is None else _decode_image(js, blob, source)
class _Reader:
"""Accessor decoder."""
def __init__(self, js: dict, blob: bytes) -> None:
self.js = js
self.blob = blob
def __call__(self, index: int) -> np.ndarray:
"""Read one accessor."""
acc = self.js["accessors"][index]
dtype = _DTYPES[acc["componentType"]]
ncomp = _COUNTS[acc["type"]]
count = acc["count"]
if "bufferView" not in acc:
return np.zeros((count, ncomp), dtype=np.float64)
view = self.js["bufferViews"][acc["bufferView"]]
start = view.get("byteOffset", 0) + acc.get("byteOffset", 0)
stride = view.get("byteStride") or np.dtype(dtype).itemsize * ncomp
item = np.dtype(dtype).itemsize * ncomp
if stride == item:
raw = np.frombuffer(self.blob, dtype=dtype, count=count * ncomp, offset=start)
out = raw.reshape(count, ncomp)
else:
rows = [np.frombuffer(self.blob, dtype=dtype, count=ncomp, offset=start + i * stride)
for i in range(count)]
out = np.stack(rows)
return out.astype(np.float64) if dtype == np.float32 else out
def load(path: str | Path) -> Gltf:
"""Read a glb file."""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Character model not found: {path}")
data = path.read_bytes()
magic, _version, total = struct.unpack_from("<III", data, 0)
if magic != 0x46546C67:
raise ValueError(f"Not a binary glTF: {path}")
js: dict | None = None
blob = b""
off = 12
while off < total:
length, kind = struct.unpack_from("<II", data, off)
chunk = data[off + 8:off + 8 + length]
if kind == _JSON_CHUNK:
js = json.loads(chunk)
elif kind == _BIN_CHUNK:
blob = bytes(chunk)
off += 8 + length + ((4 - length % 4) % 4 if length % 4 else 0)
if js is None:
raise ValueError(f"No json chunk in {path}")
read = _Reader(js, blob)
nodes = [Node(name=n.get("name", f"node{i}"), matrix=_node_matrix(n),
children=list(n.get("children", []))) for i, n in enumerate(js["nodes"])]
for i, node in enumerate(nodes):
for c in node.children:
nodes[c].parent = i
globals_ = np.tile(np.eye(4), (len(nodes), 1, 1))
order = [i for i in range(len(nodes)) if nodes[i].parent is None]
while order:
i = order.pop(0)
parent = nodes[i].parent
base = globals_[parent] if parent is not None else np.eye(4)
globals_[i] = base @ nodes[i].matrix
order.extend(nodes[i].children)
skin = js["skins"][0]
joints = list(skin["joints"])
inverse_bind = read(skin["inverseBindMatrices"]).reshape(-1, 4, 4).transpose(0, 2, 1)
primitives: list[Primitive] = []
for mesh in js.get("meshes", []):
for prim in mesh["primitives"]:
attrs = prim["attributes"]
if "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs:
continue
pos = read(attrs["POSITION"])
normals = read(attrs["NORMAL"]) if "NORMAL" in attrs else np.zeros_like(pos)
jnt = read(attrs["JOINTS_0"]).astype(np.int32)
wts = read(attrs["WEIGHTS_0"]).astype(np.float64)
wsum = wts.sum(axis=1, keepdims=True)
wts = wts / np.where(wsum > 1e-9, wsum, 1.0)
if "indices" in prim:
idx = read(prim["indices"]).astype(np.int64).reshape(-1)
else:
idx = np.arange(len(pos), dtype=np.int64)
color = (185, 180, 175)
texture = None
mat = prim.get("material")
if mat is not None:
pbr = js["materials"][mat].get("pbrMetallicRoughness", {})
base = pbr.get("baseColorFactor")
if base:
color = tuple(int(np.clip(c * 255, 0, 255)) for c in base[2::-1])
texture = _material_texture(js, blob, mat)
uv = read(attrs["TEXCOORD_0"]) if "TEXCOORD_0" in attrs else None
if texture is None:
uv = None
primitives.append(Primitive(positions=pos, normals=normals, joints=jnt,
weights=wts, triangles=idx.reshape(-1, 3),
color=color, uv=uv, texture=texture))
return Gltf(nodes=nodes, primitives=primitives, joints=joints,
inverse_bind=inverse_bind, globals=globals_)