243 lines
11 KiB
Python
Executable File
243 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Convert the Kimodo SMPL-X safetensors checkpoint to a self-describing GGUF.
|
|
|
|
This converter deliberately implements only the safe safetensors and NPY
|
|
formats. It never imports torch, never deserializes pickle, and writes to a
|
|
temporary sibling before atomically publishing the GGUF.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import struct
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
ALIGNMENT = 32
|
|
GGUF_MAGIC, GGUF_VERSION, GGML_TYPE_F32 = 0x46554747, 3, 0
|
|
TYPE_UINT64, TYPE_STRING = 10, 8
|
|
TYPE_UINT32 = 4
|
|
|
|
@dataclass(frozen=True)
|
|
class Tensor:
|
|
name: str
|
|
shape: tuple[int, ...]
|
|
start: int
|
|
size: int
|
|
source: Path
|
|
source_size: int | None = None
|
|
|
|
|
|
def checked_file_size(path: Path) -> int:
|
|
size = path.stat().st_size
|
|
if size < 0:
|
|
raise ValueError(f"{path}: invalid file size")
|
|
return size
|
|
|
|
|
|
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
result: dict[str, object] = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ValueError(f"duplicate JSON key: {key}")
|
|
result[key] = value
|
|
return result
|
|
|
|
def sha256(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for part in iter(lambda: f.read(1024 * 1024), b""): h.update(part)
|
|
return h.hexdigest()
|
|
|
|
def string(value: str) -> bytes:
|
|
encoded = value.encode("utf-8")
|
|
return struct.pack("<Q", len(encoded)) + encoded
|
|
|
|
def read_safetensors(path: Path) -> list[Tensor]:
|
|
file_size = checked_file_size(path)
|
|
if file_size < 8:
|
|
raise ValueError(f"{path}: truncated safetensors header")
|
|
with path.open("rb") as f:
|
|
raw_header_len = f.read(8)
|
|
if len(raw_header_len) != 8:
|
|
raise ValueError(f"{path}: truncated safetensors header")
|
|
header_len = struct.unpack("<Q", raw_header_len)[0]
|
|
if header_len > 128 * 1024 * 1024 or header_len > file_size - 8:
|
|
raise ValueError(f"{path}: safetensors header too large or truncated")
|
|
raw_header = f.read(header_len)
|
|
if len(raw_header) != header_len:
|
|
raise ValueError(f"{path}: truncated safetensors header")
|
|
try:
|
|
header = json.loads(raw_header, object_pairs_hook=reject_duplicate_keys)
|
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error:
|
|
raise ValueError(f"{path}: invalid safetensors JSON header") from error
|
|
if not isinstance(header, dict):
|
|
raise ValueError(f"{path}: safetensors header is not an object")
|
|
data_start = 8 + header_len
|
|
payload_size = file_size - data_start
|
|
tensors: list[Tensor] = []
|
|
ranges: list[tuple[int, int]] = []
|
|
for name, desc in header.items():
|
|
if name == "__metadata__": continue
|
|
if not isinstance(name, str) or not name or len(name) > 4096 or not isinstance(desc, dict):
|
|
raise ValueError(f"{path}: invalid safetensors tensor descriptor")
|
|
if desc.get("dtype") != "F32": raise ValueError(f"{name}: expected F32, got {desc.get('dtype')}")
|
|
raw_shape = desc.get("shape")
|
|
raw_offsets = desc.get("data_offsets")
|
|
if not isinstance(raw_shape, list) or not raw_shape or len(raw_shape) > 8:
|
|
raise ValueError(f"{name}: malformed tensor shape")
|
|
if not isinstance(raw_offsets, list) or len(raw_offsets) != 2:
|
|
raise ValueError(f"{name}: malformed tensor byte range")
|
|
if any(not isinstance(n, int) or isinstance(n, bool) or n <= 0 for n in raw_shape):
|
|
raise ValueError(f"{name}: malformed tensor shape")
|
|
if any(not isinstance(n, int) or isinstance(n, bool) for n in raw_offsets):
|
|
raise ValueError(f"{name}: malformed tensor byte range")
|
|
shape = tuple(raw_shape)
|
|
start, end = raw_offsets
|
|
if start < 0 or end < start or end > payload_size:
|
|
raise ValueError(f"{name}: tensor byte range is outside safetensors payload")
|
|
size = end - start
|
|
expected = 4
|
|
for n in shape: expected *= n
|
|
if size != expected:
|
|
raise ValueError(f"{name}: malformed tensor shape or byte range")
|
|
ranges.append((start, end))
|
|
tensors.append(Tensor(name.removeprefix("denoiser.backbone."), shape, data_start + start, size, path))
|
|
for (_, previous_end), (start, _) in zip(sorted(ranges), sorted(ranges)[1:]):
|
|
if start < previous_end:
|
|
raise ValueError(f"{path}: overlapping safetensors tensor byte ranges")
|
|
return sorted(tensors, key=lambda t: t.name)
|
|
|
|
def read_npy(path: Path, name: str) -> Tensor:
|
|
file_size = checked_file_size(path)
|
|
with path.open("rb") as f:
|
|
if f.read(6) != b"\x93NUMPY": raise ValueError(f"{path}: not an NPY file")
|
|
major, _ = struct.unpack("BB", f.read(2))
|
|
if major not in (1, 2, 3): raise ValueError(f"{path}: unsupported NPY version")
|
|
width = 2 if major == 1 else 4
|
|
raw_header_len = f.read(width)
|
|
if len(raw_header_len) != width: raise ValueError(f"{path}: truncated NPY header")
|
|
header_len = struct.unpack("<H" if major == 1 else "<I", raw_header_len)[0]
|
|
if header_len > 1024 * 1024 or header_len > file_size - (6 + 2 + width):
|
|
raise ValueError(f"{path}: invalid NPY header length")
|
|
header = f.read(header_len).decode("latin1")
|
|
try:
|
|
descriptor = ast.literal_eval(header)
|
|
except (SyntaxError, ValueError) as error:
|
|
raise ValueError(f"{path}: invalid NPY header") from error
|
|
if not isinstance(descriptor, dict) or descriptor.get("fortran_order") is not False:
|
|
raise ValueError(f"{path}: expected C-order NPY")
|
|
is_f64 = descriptor.get("descr") == "<f8"
|
|
if not is_f64 and descriptor.get("descr") != "<f4":
|
|
raise ValueError(f"{path}: expected little-endian F32 or F64 NPY")
|
|
raw_shape = descriptor.get("shape")
|
|
if not isinstance(raw_shape, tuple) or not raw_shape or len(raw_shape) > 8 or any(not isinstance(n, int) or isinstance(n, bool) or n <= 0 for n in raw_shape):
|
|
raise ValueError(f"{path}: invalid NPY shape")
|
|
shape = raw_shape
|
|
elements = 1
|
|
for n in shape: elements *= n
|
|
source_size = (8 if is_f64 else 4) * elements
|
|
start = 6 + 2 + width + header_len
|
|
if source_size > file_size - start:
|
|
raise ValueError(f"{path}: truncated NPY payload")
|
|
return Tensor(name, shape, start, 4 * elements, path, source_size)
|
|
|
|
def metadata_string(key: str, value: str) -> bytes:
|
|
return string(key) + struct.pack("<I", TYPE_STRING) + string(value)
|
|
|
|
def metadata_uint(key: str, value: int) -> bytes:
|
|
return string(key) + struct.pack("<I", TYPE_UINT64) + struct.pack("<Q", value)
|
|
|
|
def metadata_uint32(key: str, value: int) -> bytes:
|
|
return string(key) + struct.pack("<I", TYPE_UINT32) + struct.pack("<I", value)
|
|
|
|
def tensor_info(tensor: Tensor, offset: int) -> bytes:
|
|
# GGML stores dim 0 as the contiguous dimension. PyTorch F32 storage is
|
|
# row-major, so reverse dimensions without changing the underlying bytes.
|
|
dims = tuple(reversed(tensor.shape))
|
|
return string(tensor.name) + struct.pack("<I", len(dims)) + b"".join(struct.pack("<Q", n) for n in dims) + struct.pack("<I", GGML_TYPE_F32) + struct.pack("<Q", offset)
|
|
|
|
def copy_range(dst, tensor: Tensor) -> None:
|
|
with tensor.source.open("rb") as src:
|
|
src.seek(tensor.start)
|
|
remaining = tensor.source_size or tensor.size
|
|
if remaining != tensor.size:
|
|
# Upstream normalization statistics are F64. Convert them once at
|
|
# the safe conversion boundary so all runtime tensors are F32.
|
|
while remaining:
|
|
raw = src.read(8)
|
|
if len(raw) != 8: raise RuntimeError(f"unexpected EOF in {tensor.source}")
|
|
dst.write(struct.pack("<f", struct.unpack("<d", raw)[0])); remaining -= 8
|
|
return
|
|
while remaining:
|
|
block = src.read(min(8 * 1024 * 1024, remaining))
|
|
if not block: raise RuntimeError(f"unexpected EOF in {tensor.source}")
|
|
dst.write(block); remaining -= len(block)
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("--input", required=True, type=Path, help="downloaded Kimodo-SMPLX-RP-v1 directory")
|
|
p.add_argument("--output", required=True, type=Path)
|
|
args = p.parse_args()
|
|
root = args.input.resolve()
|
|
ckpt = root / "model.safetensors"
|
|
if not ckpt.is_file(): raise SystemExit("missing model.safetensors")
|
|
tensors = read_safetensors(ckpt)
|
|
expected = 408
|
|
if len(tensors) != expected: raise SystemExit(f"expected {expected} checkpoint tensors, got {len(tensors)}")
|
|
for part in ("global_root", "local_root", "body"):
|
|
for stat in ("mean", "std"):
|
|
tensors.append(read_npy(root / "stats" / "motion" / part / f"{stat}.npy", f"stats.{part}.{stat}"))
|
|
revision = (root / "REVISION").read_text(encoding="utf-8").split()[0]
|
|
meta = [
|
|
metadata_string("general.architecture", "kimodo-motion"),
|
|
metadata_string("general.name", "Kimodo-SMPLX-RP-v1"),
|
|
# GGML's own loader requires general.alignment to be UINT32.
|
|
metadata_uint32("general.alignment", ALIGNMENT),
|
|
metadata_uint("kimodo.format_version", 1),
|
|
metadata_string("kimodo.skeleton", "smplx22"),
|
|
metadata_string("kimodo.model_identity", f"nvidia/Kimodo-SMPLX-RP-v1@{revision}"),
|
|
metadata_string("kimodo.source_revision", revision),
|
|
metadata_string("kimodo.source_sha256", sha256(ckpt)),
|
|
metadata_uint("kimodo.text_embedding_width", 4096),
|
|
metadata_uint("kimodo.motion_dim", 273),
|
|
metadata_uint("kimodo.global_root_dim", 5),
|
|
metadata_uint("kimodo.local_root_dim", 4),
|
|
metadata_uint("kimodo.body_dim", 268),
|
|
metadata_uint("kimodo.hidden_size", 1024),
|
|
metadata_uint("kimodo.layers", 16),
|
|
metadata_uint("kimodo.heads", 8),
|
|
metadata_uint("kimodo.feed_forward_size", 2048),
|
|
metadata_uint("kimodo.num_text_tokens", 50),
|
|
metadata_uint("kimodo.base_diffusion_steps", 1000),
|
|
metadata_uint("kimodo.fps", 30),
|
|
]
|
|
offsets, cursor = [], 0
|
|
for tensor in tensors:
|
|
cursor = (cursor + ALIGNMENT - 1) // ALIGNMENT * ALIGNMENT
|
|
offsets.append(cursor); cursor += tensor.size
|
|
header = struct.pack("<IIQQ", GGUF_MAGIC, GGUF_VERSION, len(tensors), len(meta)) + b"".join(meta)
|
|
header += b"".join(tensor_info(t, off) for t, off in zip(tensors, offsets))
|
|
padding = (-len(header)) % ALIGNMENT
|
|
output = args.output.resolve(); output.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = output.with_name(output.name + ".tmp")
|
|
try:
|
|
with temporary.open("wb") as out:
|
|
out.write(header); out.write(b"\0" * padding)
|
|
written = 0
|
|
for tensor, offset in zip(tensors, offsets):
|
|
out.write(b"\0" * (offset - written)); copy_range(out, tensor); written = offset + tensor.size
|
|
# GGML validates the complete aligned tensor blob, including the
|
|
# final tensor's padding (not merely its logical data bytes).
|
|
out.write(b"\0" * ((-written) % ALIGNMENT))
|
|
os.replace(temporary, output)
|
|
finally:
|
|
if temporary.exists(): temporary.unlink()
|
|
print(f"wrote {output} ({output.stat().st_size} bytes, {len(tensors)} F32 tensors)")
|
|
|
|
if __name__ == "__main__": main()
|