337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""Single-file Go2 legacy47 import. No adjacent files or arbitrary ONNX conversion."""
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
from pretrained import (
|
|
BASE_TERMS,
|
|
JOINTS,
|
|
NORMALIZATION_POLICY,
|
|
PretrainedError,
|
|
ValidatedSource,
|
|
_require,
|
|
make_reference_actor,
|
|
validate_actor_state,
|
|
validate_semantics,
|
|
verify_onnx,
|
|
)
|
|
|
|
TEMPLATE = "go2-legacy47-v1"
|
|
SYNTHETIC_COUNT = 1_000_000
|
|
UPLOAD_LIMITS = {"pt": 256 * 1024**2, "onnx": 64 * 1024**2}
|
|
SEMANTICS = {
|
|
"joint_names": JOINTS,
|
|
"observation_names": BASE_TERMS,
|
|
"command_names": ["twist"],
|
|
"action_scale": [0.25],
|
|
"joint_stiffness": [20, 20, 40] * 4,
|
|
"joint_damping": [1, 1, 2] * 4,
|
|
"default_joint_pos": [-0.1, 0.9, -1.8, 0.1, 0.9, -1.8] * 2,
|
|
}
|
|
|
|
|
|
def validate_metadata(metadata, *, required=False):
|
|
"""Check recognized embedded semantics; absence remains a user template assumption."""
|
|
import numpy as np
|
|
|
|
_require(isinstance(metadata, dict), "模型metadata必须是对象")
|
|
checked = []
|
|
for key, expected in SEMANTICS.items():
|
|
if key not in metadata:
|
|
_require(not required, f"ONNX缺少语义metadata: {key}")
|
|
continue
|
|
actual = metadata[key]
|
|
if isinstance(actual, str):
|
|
actual = actual.split(",")
|
|
if isinstance(expected[0], str):
|
|
_require(actual == expected, f"模型metadata冲突: {key}")
|
|
else:
|
|
try:
|
|
actual = np.asarray(actual, dtype=np.float64)
|
|
_require(
|
|
actual.shape == (len(expected),)
|
|
and np.isfinite(actual).all()
|
|
and np.allclose(actual, expected, atol=1e-6, rtol=0),
|
|
f"模型metadata冲突: {key}",
|
|
)
|
|
except (TypeError, ValueError) as error:
|
|
raise PretrainedError(f"模型metadata无效: {key}") from error
|
|
checked.append(key)
|
|
for key in ("contract", "templateId"):
|
|
if key in metadata:
|
|
_require(metadata[key] == TEMPLATE, "模型模板metadata冲突")
|
|
checked.append(key)
|
|
return checked
|
|
|
|
|
|
def _checkpoint(data):
|
|
try:
|
|
checkpoint = torch.load(io.BytesIO(data), map_location="cpu", weights_only=True)
|
|
except Exception as error:
|
|
raise PretrainedError(
|
|
"不支持或不安全的.pt;仅支持weights_only Go2 legacy47 actor checkpoint"
|
|
) from error
|
|
_require(
|
|
isinstance(checkpoint, dict) and "actor_state_dict" in checkpoint,
|
|
"请选择含actor_state_dict的Go2 legacy47 .pt,不接受ZIP工程或任意模型",
|
|
)
|
|
state = checkpoint["actor_state_dict"]
|
|
if isinstance(state, dict) and isinstance(state.get("mlp.0.weight"), torch.Tensor):
|
|
_require(
|
|
tuple(state["mlp.0.weight"].shape) == (512, 47),
|
|
"当前仅支持Go2 legacy47输入;81/97 checkpoint请使用原trial续训,而不是基础策略上传",
|
|
)
|
|
validate_actor_state(state)
|
|
iteration = checkpoint.get("iter")
|
|
_require(
|
|
iteration is None or (type(iteration) is int and iteration >= 0), "无效checkpoint iteration"
|
|
)
|
|
checked = validate_metadata(checkpoint)
|
|
for key in ("metadata", "infos"):
|
|
if key in checkpoint:
|
|
checked.extend(validate_metadata(checkpoint[key]))
|
|
nested = checkpoint[key].get("metadata")
|
|
if nested is not None:
|
|
checked.extend(validate_metadata(nested))
|
|
return state, iteration, sorted(set(checked))
|
|
|
|
|
|
def _onnx(data):
|
|
import onnx
|
|
from onnx import helper, numpy_helper
|
|
|
|
model = onnx.load_model_from_string(data)
|
|
graph = model.graph
|
|
_require(
|
|
not model.functions
|
|
and not model.training_info
|
|
and len(model.opset_import) == 1
|
|
and model.opset_import[0].domain == ""
|
|
and model.opset_import[0].version in (17, 18),
|
|
"仅支持标准opset17/18受限MLP ONNX",
|
|
)
|
|
_require(
|
|
not graph.sparse_initializer and not graph.quantization_annotation, "不支持稀疏或量化ONNX"
|
|
)
|
|
_require(len(graph.input) == len(graph.output) == 1, "ONNX必须单输入单输出")
|
|
for value, shape in ((graph.input[0], [1, 47]), (graph.output[0], [1, 12])):
|
|
tensor = value.type.tensor_type
|
|
_require(
|
|
tensor.elem_type == onnx.TensorProto.FLOAT
|
|
and [d.dim_value for d in tensor.shape.dim] == shape
|
|
and all(not d.dim_param for d in tensor.shape.dim),
|
|
"ONNX仅支持float32 [1,47] -> [1,12];其他输入请使用对应训练器",
|
|
)
|
|
expected_shapes = {"obs_normalizer._mean": (1, 47), "onnx::Div_24": (1, 47)}
|
|
for i, shape in zip((0, 2, 4, 6), ((512, 47), (256, 512), (128, 256), (12, 128)), strict=True):
|
|
expected_shapes[f"mlp.{i}.weight"] = shape
|
|
expected_shapes[f"mlp.{i}.bias"] = (shape[0],)
|
|
_require(
|
|
len(graph.initializer) == len(expected_shapes)
|
|
and {t.name for t in graph.initializer} == set(expected_shapes),
|
|
"ONNX initializer不符合受支持MLP",
|
|
)
|
|
tensors = {}
|
|
for tensor in graph.initializer:
|
|
_require(
|
|
tensor.data_location == onnx.TensorProto.DEFAULT and not tensor.external_data,
|
|
"拒绝ONNX external data;必须是单个自包含文件",
|
|
)
|
|
_require(
|
|
tensor.data_type == onnx.TensorProto.FLOAT
|
|
and tuple(tensor.dims) == expected_shapes[tensor.name],
|
|
"ONNX tensor类型/shape不支持",
|
|
)
|
|
tensors[tensor.name] = torch.from_numpy(numpy_helper.to_array(tensor).copy())
|
|
_require(bool(torch.isfinite(tensors[tensor.name]).all()), "ONNX tensor含非有限值")
|
|
# Exact dataflow, not merely op/tensor names: no branch, reorder, alias or extra op.
|
|
expected_ops = ["Sub", "Div", "Gemm", "Elu", "Gemm", "Elu", "Gemm", "Elu", "Gemm"]
|
|
_require(
|
|
[n.op_type for n in graph.node] == expected_ops, "ONNX必须是Sub/Div及4层Gemm+3层ELU精确链路"
|
|
)
|
|
previous = graph.input[0].name
|
|
seen = set(tensors) | {previous}
|
|
_require(len(seen) == len(tensors) + 1, "ONNX输入与initializer重名")
|
|
layer = 0
|
|
for node in graph.node:
|
|
_require(node.domain == "" and not node.overload, "拒绝ONNX custom op")
|
|
attributes = {a.name: helper.get_attribute_value(a) for a in node.attribute}
|
|
_require(len(attributes) == len(node.attribute), "重复ONNX属性")
|
|
if node.op_type in ("Sub", "Div"):
|
|
inputs = [previous, "obs_normalizer._mean" if node.op_type == "Sub" else "onnx::Div_24"]
|
|
_require(not attributes, "不支持normalizer算子属性")
|
|
elif node.op_type == "Gemm":
|
|
inputs = [previous, f"mlp.{layer}.weight", f"mlp.{layer}.bias"]
|
|
layer += 2
|
|
_require(
|
|
set(attributes) <= {"alpha", "beta", "transA", "transB"}
|
|
and attributes.get("alpha", 1.0) == 1.0
|
|
and attributes.get("beta", 1.0) == 1.0
|
|
and attributes.get("transA", 0) == 0
|
|
and attributes.get("transB", 0) == 1,
|
|
"不支持Gemm缩放/转置属性",
|
|
)
|
|
else:
|
|
inputs = [previous]
|
|
_require(
|
|
set(attributes) <= {"alpha"} and attributes.get("alpha", 1.0) == 1.0,
|
|
"仅支持ELU alpha=1",
|
|
)
|
|
_require(
|
|
list(node.input) == inputs
|
|
and len(node.output) == 1
|
|
and node.output[0]
|
|
and node.output[0] not in seen,
|
|
"ONNX实际连边/输出不符合受支持MLP",
|
|
)
|
|
previous = node.output[0]
|
|
seen.add(previous)
|
|
_require(previous == graph.output[0].name, "ONNX输出必须是最后Gemm结果")
|
|
metadata = {p.key: p.value for p in model.metadata_props}
|
|
_require(len(metadata) == len(model.metadata_props), "重复ONNX metadata")
|
|
checked = validate_metadata(metadata, required=True)
|
|
onnx.checker.check_model(model, full_check=True)
|
|
actor = make_reference_actor()
|
|
_require(
|
|
actor.obs_normalizer.eps == 0.01
|
|
and bool((actor.state_dict()["distribution.std_param"] == 1).all()),
|
|
"目标训练默认normalizer/exploration已变化,需要新模板",
|
|
)
|
|
state = actor.state_dict()
|
|
for key in state:
|
|
if key in tensors:
|
|
state[key] = tensors[key]
|
|
std = tensors["onnx::Div_24"] - 0.01
|
|
_require(bool((std > 0).all()), "ONNX denominator必须大于模板epsilon=.01")
|
|
state["obs_normalizer._std"] = std
|
|
state["obs_normalizer._var"] = std.square()
|
|
state["obs_normalizer.count"].fill_(SYNTHETIC_COUNT)
|
|
validate_actor_state(state)
|
|
actor.load_state_dict(state)
|
|
identity = verify_onnx(data, actor)
|
|
return state, checked, identity
|
|
|
|
|
|
def source_identity(fmt, digest):
|
|
return hashlib.sha256(f"{TEMPLATE}:{fmt}:{digest}".encode()).hexdigest()
|
|
|
|
|
|
def import_upload(path, fmt, template, directory):
|
|
"""Run only inside the resource-limited validator process."""
|
|
_require(template == TEMPLATE, "必须明确确认go2-legacy47-v1模板")
|
|
_require(fmt in UPLOAD_LIMITS, "仅支持单个.pt或.onnx,不支持ZIP")
|
|
data = Path(path).read_bytes()
|
|
_require(0 < len(data) <= UPLOAD_LIMITS[fmt], "上传文件为空或过大")
|
|
if fmt == "pt":
|
|
state, iteration, checked = _checkpoint(data)
|
|
identity = None
|
|
else:
|
|
state, checked, identity = _onnx(data)
|
|
iteration = None
|
|
from pretrained import comparison_observations
|
|
|
|
actor = make_reference_actor().eval()
|
|
actor.load_state_dict(state)
|
|
with torch.inference_mode():
|
|
output = actor.mlp(actor.obs_normalizer(comparison_observations()))
|
|
_require(bool(torch.isfinite(output).all()), "actor在随机/物理probe上产生非有限动作")
|
|
directory = Path(directory)
|
|
actor_path = directory / "actor.pt"
|
|
torch.save({"actor_state_dict": state}, actor_path)
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"mode": "pretrained-warm-start",
|
|
"contract": TEMPLATE,
|
|
"sourceFormat": fmt,
|
|
"source_id": source_identity(fmt, digest),
|
|
"source_iteration": iteration,
|
|
"source_actor_dim": 47,
|
|
"source_normalizer_count": state["obs_normalizer.count"].item(),
|
|
"normalization": NORMALIZATION_POLICY
|
|
if fmt == "pt"
|
|
else "synthetic-count/unit-new-features",
|
|
"template_confirmation": {
|
|
"id": TEMPLATE,
|
|
"confirmed_by": "user",
|
|
"assumptions": (
|
|
"Go2 legacy47 observation physics, ordering, 50Hz and action semantics; "
|
|
"not verified source env.yaml"
|
|
),
|
|
},
|
|
"verified_facts": {
|
|
"actor_tensor_shapes": "47-512-256-128-12/float32",
|
|
"metadata_fields": checked,
|
|
"finite_output_probes": 48,
|
|
"activation": "graph-verified-ELU" if fmt == "onnx" else "user-template-assumed-ELU",
|
|
},
|
|
"derived_fields": {}
|
|
if fmt == "pt"
|
|
else {
|
|
"normalizer_count": {"policy": "synthetic", "value": SYNTHETIC_COUNT},
|
|
"normalizer_std": "denominator - 0.01",
|
|
"normalizer_var": "std squared",
|
|
"epsilon": 0.01,
|
|
"exploration_std": {"policy": "fresh-target-default", "value": 1.0},
|
|
},
|
|
"onnx_identity": identity,
|
|
"critic": "fresh-target-initialization",
|
|
"optimizer": "fresh",
|
|
"iteration": 0,
|
|
"base_observation_terms": BASE_TERMS,
|
|
"joint_names": JOINTS,
|
|
"artifacts": {
|
|
"upload": {"name": f"upload.{fmt}", "sha256": digest, "bytes": len(data)},
|
|
"checkpoint": {
|
|
"name": "actor.pt",
|
|
"sha256": hashlib.sha256(actor_path.read_bytes()).hexdigest(),
|
|
"bytes": actor_path.stat().st_size,
|
|
"origin": "service-derived-actor-only",
|
|
},
|
|
},
|
|
}
|
|
return manifest
|
|
|
|
|
|
def read_uploaded_source(checkpoint, *, allowed_roots, manifest_path, target_env, target_agent):
|
|
"""Load only the service-derived actor and bound provenance, never adjacent sidecars."""
|
|
from pretrained_sources import regular_bytes
|
|
|
|
roots = [Path(p).resolve(strict=True) for p in allowed_roots]
|
|
checkpoint, manifest_path = Path(checkpoint), Path(manifest_path)
|
|
root = next(
|
|
(r for r in roots if checkpoint.is_relative_to(r) and manifest_path.is_relative_to(r)), None
|
|
)
|
|
_require(root is not None, "上传artifact不在受控根内")
|
|
manifest = json.loads(regular_bytes(manifest_path, root, 64 * 1024))
|
|
_require(
|
|
manifest.get("contract") == TEMPLATE and manifest.get("sourceFormat") in UPLOAD_LIMITS,
|
|
"无效上传manifest模板/格式",
|
|
)
|
|
artifacts = manifest["artifacts"]
|
|
_require(
|
|
manifest["source_id"]
|
|
== source_identity(manifest["sourceFormat"], artifacts["upload"]["sha256"]),
|
|
"上传原始SHA身份不匹配",
|
|
)
|
|
data = regular_bytes(checkpoint, root, UPLOAD_LIMITS["pt"])
|
|
_require(
|
|
hashlib.sha256(data).hexdigest() == artifacts["checkpoint"]["sha256"], "上传actor SHA不匹配"
|
|
)
|
|
from pretrained import _plain
|
|
from src.tasks.velocity.config.go2.env_cfgs import unitree_go2_flat_env_cfg
|
|
from src.tasks.velocity.config.go2.rl_cfg import unitree_go2_ppo_runner_cfg
|
|
|
|
validate_semantics(
|
|
_plain(asdict(unitree_go2_flat_env_cfg())),
|
|
_plain(asdict(unitree_go2_ppo_runner_cfg())),
|
|
target_env,
|
|
target_agent,
|
|
)
|
|
state, _, _ = _checkpoint(data)
|
|
return ValidatedSource(state, manifest)
|