feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild a provenance-bound LeKiwi input ZIP without modifying the source repo."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROFILE = json.loads((ROOT / "robot_profiles/lekiwi-v1.json").read_text())
|
||||
SOURCE = PROFILE["source"]
|
||||
MAX_TOTAL = 128 * 1024 * 1024
|
||||
|
||||
|
||||
def digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def prepare(source: Path | None, output: Path, download: bool = False) -> Path:
|
||||
if source is not None:
|
||||
source = source.resolve()
|
||||
if source.name == "URDF":
|
||||
source = source.parent
|
||||
if not (source / "URDF/LeKiwi.urdf").is_file():
|
||||
raise ValueError("Source must be the LeKiwi repository or its URDF directory")
|
||||
if output.resolve().is_relative_to(source):
|
||||
raise ValueError("Output must not modify the reference repository")
|
||||
elif not download:
|
||||
raise ValueError("Pass --source or explicitly opt in with --download")
|
||||
files: dict[str, bytes] = {}
|
||||
total = 0
|
||||
|
||||
def read(relative: str) -> bytes:
|
||||
nonlocal total
|
||||
path = PurePosixPath(relative)
|
||||
if path.is_absolute() or ".." in path.parts or "\\" in relative:
|
||||
raise ValueError(f"Unsafe asset path: {relative}")
|
||||
if source is not None:
|
||||
file = (source / relative).resolve()
|
||||
if not file.is_relative_to(source) or file.stat().st_size > MAX_TOTAL:
|
||||
raise ValueError(f"Invalid asset: {relative}")
|
||||
data = file.read_bytes()
|
||||
else:
|
||||
url = f"https://raw.githubusercontent.com/SIGRobotics-UIUC/LeKiwi/{SOURCE['revision']}/{relative}"
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
data = response.read(MAX_TOTAL + 1)
|
||||
total += len(data)
|
||||
if total > MAX_TOTAL:
|
||||
raise ValueError("Assets exceed 128 MiB")
|
||||
files[relative] = data
|
||||
return data
|
||||
|
||||
urdf = read("URDF/LeKiwi.urdf")
|
||||
if digest(urdf) != SOURCE["urdfSha256"]:
|
||||
raise ValueError("URDF revision/hash is not supported by lekiwi-v1")
|
||||
tree = ET.fromstring(urdf)
|
||||
for mesh in sorted({m.get("filename", "") for m in tree.iter("mesh")}):
|
||||
if not mesh.startswith("meshes/") or not mesh.endswith(".stl"):
|
||||
raise ValueError(f"Unexpected mesh reference: {mesh}")
|
||||
read(f"URDF/{mesh}")
|
||||
read("LICENSE.txt")
|
||||
read("CITATION.cff")
|
||||
manifest = {
|
||||
"profileId": PROFILE["id"],
|
||||
"profileVersion": PROFILE["version"],
|
||||
"source": SOURCE,
|
||||
"files": {p: digest(data) for p, data in files.items()},
|
||||
}
|
||||
files["source-manifest.json"] = json.dumps(manifest, indent=2).encode()
|
||||
files["robot-profile.json"] = json.dumps(
|
||||
{"id": PROFILE["id"], "version": PROFILE["version"]}
|
||||
).encode()
|
||||
files["SIMULATION-NOTICE.md"] = (
|
||||
"# LeKiwi simulation input\n\nSource: "
|
||||
+ SOURCE["repository"]
|
||||
+ "\n\nRevision: "
|
||||
+ SOURCE["revision"]
|
||||
+ "\n\nOriginal URDF/STL files are unmodified, Apache-2.0. "
|
||||
"The platform applies an explicit simulation-only profile after conversion: estimated "
|
||||
"mass/inertia/limits/servos and simplified passive-roller contacts. Not a calibrated "
|
||||
"hardware model; no cameras, training or grasping guarantee.\n"
|
||||
).encode()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for relative, data in files.items():
|
||||
target = output / relative
|
||||
if target.is_symlink() or not target.resolve().is_relative_to(output.resolve()):
|
||||
raise ValueError("Unsafe output path")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
archive = output / "lekiwi-v1.zip"
|
||||
if archive.is_symlink() or not archive.resolve().is_relative_to(output.resolve()):
|
||||
raise ValueError("Unsafe archive output path")
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
|
||||
for relative, data in files.items():
|
||||
info = zipfile.ZipInfo(relative, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
bundle.writestr(info, data, compresslevel=9)
|
||||
return archive
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--source", type=Path)
|
||||
group.add_argument("--download", action="store_true")
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "build/lekiwi")
|
||||
args = parser.parse_args()
|
||||
print(prepare(args.source, args.output, args.download))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user