3ad29356c9
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
495 lines
19 KiB
Python
495 lines
19 KiB
Python
"""Offline collision-from-visuals cooking for the complete LeKiwi arm subtree.
|
|
|
|
Run in build/venvs/collision, NOT the training or LeRobot environment. CoACD is
|
|
never imported by the browser/bridge. Each convex part is a separate MJCF mesh.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib.metadata
|
|
import json
|
|
import os
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
# Fix the numerical execution environment before importing numpy/CoACD.
|
|
os.environ["OMP_NUM_THREADS"] = "4"
|
|
os.environ["OPENBLAS_NUM_THREADS"] = "1"
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
CONFIG = ROOT / "robot_profiles/lekiwi-collision-source.json"
|
|
VERSIONS = {"coacd": "1.0.14", "trimesh": "4.12.2", "numpy": "2.1.3", "scipy": "1.17.0"}
|
|
PARAMETERS = {
|
|
"threshold": 0.003,
|
|
"real_metric": True,
|
|
"preprocess_mode": "auto",
|
|
"preprocess_resolution": 60,
|
|
"resolution": 1000,
|
|
"mcts_nodes": 12,
|
|
"mcts_iterations": 60,
|
|
"mcts_max_depth": 3,
|
|
"decimate": True,
|
|
"max_ch_vertex": 96,
|
|
"seed": 42,
|
|
}
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def visuals(source, config):
|
|
"""Select every visual in the subtree, including welded motors/accessories."""
|
|
urdf = (source / "LeKiwi.urdf").read_bytes()
|
|
if digest(urdf) != config["urdfSha256"]:
|
|
raise ValueError("URDF 与固定版本不符")
|
|
robot = ET.fromstring(urdf)
|
|
parents = {
|
|
j.find("child").get("link"): j.find("parent").get("link") for j in robot.findall("joint")
|
|
}
|
|
result = []
|
|
for link in robot.findall("link"):
|
|
ancestor = link.get("name")
|
|
visited = set()
|
|
while ancestor in parents and ancestor != config["rootLink"]:
|
|
if ancestor in visited:
|
|
raise ValueError("URDF 运动链存在环")
|
|
visited.add(ancestor)
|
|
ancestor = parents[ancestor]
|
|
if ancestor != config["rootLink"]:
|
|
continue
|
|
for visual in link.findall("visual"):
|
|
mesh = visual.find("geometry/mesh")
|
|
if mesh is None:
|
|
raise ValueError("当前固定资产只支持 STL 视觉网格,不能静默遗漏几何")
|
|
filename = mesh.get("filename")
|
|
path = (source / filename).resolve()
|
|
if not path.is_relative_to(source.resolve()) or filename not in config["meshes"]:
|
|
raise ValueError(f"未批准的源网格:{filename}")
|
|
sha = digest(path.read_bytes())
|
|
if sha != config["meshes"][filename]:
|
|
raise ValueError(f"源网格 SHA-256 不匹配:{filename}")
|
|
scale = [float(x) for x in mesh.get("scale", "1 1 1").split()]
|
|
if len(scale) != 3 or not all(0 < x < float("inf") for x in scale):
|
|
raise ValueError("无效的视觉网格缩放")
|
|
result.append(
|
|
{
|
|
"link": link.get("name"),
|
|
"visual": visual.get("name"),
|
|
"mesh": filename,
|
|
"sha256": sha,
|
|
"scale": scale,
|
|
}
|
|
)
|
|
if not result or {v["mesh"] for v in result} != set(config["meshes"]):
|
|
raise ValueError("整臂视觉覆盖清单不完整")
|
|
return result
|
|
|
|
|
|
def swept_bounds(vertices, axis):
|
|
"""Conservative axial/radial intervals for a full revolution, not pose sampling."""
|
|
import numpy as np
|
|
from scipy.spatial import ConvexHull
|
|
|
|
axial = vertices @ axis
|
|
reference = np.eye(3)[int(np.argmin(np.abs(axis)))]
|
|
x = np.cross(axis, reference)
|
|
x /= np.linalg.norm(x)
|
|
y = np.cross(axis, x)
|
|
projected = vertices @ np.column_stack([x, y])
|
|
hull = ConvexHull(projected)
|
|
radial_min = 0.0
|
|
if hull.equations[:, 2].max() > 0:
|
|
a = projected[hull.vertices]
|
|
b = np.roll(a, -1, axis=0)
|
|
edge = b - a
|
|
t = np.clip(-(a * edge).sum(axis=1) / (edge * edge).sum(axis=1), 0, 1)
|
|
radial_min = float(np.linalg.norm(a + t[:, None] * edge, axis=1).min())
|
|
return (
|
|
float(axial.min()),
|
|
float(axial.max()),
|
|
radial_min,
|
|
float(np.linalg.norm(projected, axis=1).max()),
|
|
)
|
|
|
|
|
|
def intervals_overlap(a, b, margin=0.0001):
|
|
# 0.1 mm conservative allowance for serialized/compiled frame precision.
|
|
return (
|
|
max(a[0], b[0]) <= min(a[1], b[1]) + margin and max(a[2], b[2]) <= min(a[3], b[3]) + margin
|
|
)
|
|
|
|
|
|
def assembly_frames(source, config, coverage):
|
|
"""URDF joint/visual frames for both geometric partitioning and pair policy."""
|
|
import numpy as np
|
|
from scipy.spatial.transform import Rotation
|
|
|
|
robot = ET.parse(source / "LeKiwi.urdf").getroot()
|
|
joints = {j.get("name"): j for j in robot.findall("joint")}
|
|
parents = {j.find("child").get("link"): j for j in joints.values()}
|
|
links = {link.get("name"): link for link in robot.findall("link")}
|
|
poses = {}
|
|
|
|
def origin(node):
|
|
result = np.eye(4)
|
|
if node is not None:
|
|
result[:3, 3] = [float(x) for x in node.get("xyz", "0 0 0").split()]
|
|
result[:3, :3] = Rotation.from_euler(
|
|
"xyz", [float(x) for x in node.get("rpy", "0 0 0").split()]
|
|
).as_matrix()
|
|
return result
|
|
|
|
def pose(link):
|
|
if link not in poses:
|
|
j = parents.get(link)
|
|
poses[link] = (
|
|
np.eye(4)
|
|
if j is None
|
|
else pose(j.find("parent").get("link")) @ origin(j.find("origin"))
|
|
)
|
|
return poses[link]
|
|
|
|
def owner(link):
|
|
if link == config["rootLink"]:
|
|
return "base"
|
|
j = parents[link]
|
|
return owner(j.find("parent").get("link")) if j.get("type") == "fixed" else j.get("name")
|
|
|
|
by_visual = {}
|
|
for item in coverage:
|
|
item["weldJoint"] = owner(item["link"])
|
|
visual = next(
|
|
v for v in links[item["link"]].findall("visual") if v.get("name") == item["visual"]
|
|
)
|
|
by_visual[item["visual"]] = (
|
|
item["weldJoint"],
|
|
pose(item["link"]) @ origin(visual.find("origin")),
|
|
)
|
|
result = []
|
|
for name, core in config["assemblyCores"].items():
|
|
if not (0 < core["radiusM"] <= 0.025 and 0 < core["axialHalfExtentM"] <= 0.06):
|
|
raise ValueError("装配配合区域超出已审核尺寸预算")
|
|
j = joints[name]
|
|
parent = owner(j.find("parent").get("link"))
|
|
transform = pose(j.find("child").get("link"))
|
|
axis = transform[:3, :3] @ np.array([float(x) for x in j.find("axis").get("xyz").split()])
|
|
axis /= np.linalg.norm(axis)
|
|
result.append((name, parent, transform, axis, core))
|
|
return by_visual, result
|
|
|
|
|
|
def joint_policy(source, config, parts, coverage):
|
|
"""Bound exceptions by joint-local geometry, never by a whole body pair."""
|
|
import numpy as np
|
|
|
|
by_visual, frames = assembly_frames(source, config, coverage)
|
|
result = []
|
|
for name, parent, transform, axis, core in frames:
|
|
members = []
|
|
groups = {parent: {}, name: {}}
|
|
for part in parts:
|
|
group, visual_pose = by_visual[part["visual"]]
|
|
if group not in (parent, name):
|
|
continue
|
|
vertices = np.fromstring(part["vertices"], sep=" ").reshape(-1, 3)
|
|
offsets = vertices @ visual_pose[:3, :3].T + visual_pose[:3, 3] - transform[:3, 3]
|
|
bounds = swept_bounds(offsets, axis)
|
|
groups[group][part["name"]] = bounds
|
|
if (
|
|
bounds[3] <= core["radiusM"]
|
|
and max(abs(bounds[0]), abs(bounds[1])) <= core["axialHalfExtentM"]
|
|
):
|
|
members.append(part["name"])
|
|
core_set = set(members)
|
|
pairs = []
|
|
filtered = pruned = 0
|
|
for a, bound_a in groups[parent].items():
|
|
for b, bound_b in groups[name].items():
|
|
if a in core_set or b in core_set:
|
|
filtered += 1
|
|
elif not intervals_overlap(bound_a, bound_b):
|
|
pruned += 1
|
|
else:
|
|
pairs.append([a, b])
|
|
if not pairs:
|
|
raise ValueError(f"不能关闭整对相邻连杆碰撞:{name}")
|
|
result.append(
|
|
{
|
|
"joint": name,
|
|
"parent": parent,
|
|
**core,
|
|
"coreParts": members,
|
|
"pairs": pairs,
|
|
"coreFilteredPairs": filtered,
|
|
"sweptPrunedPairs": pruned,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def clip_convex(vertices, normal, offset):
|
|
"""Intersect a convex hull with a half-space, retaining all crossing edges."""
|
|
import numpy as np
|
|
from scipy.spatial import ConvexHull, QhullError
|
|
|
|
signed = vertices @ normal - offset
|
|
if signed.max() <= 1e-10:
|
|
return vertices
|
|
if signed.min() >= -1e-10:
|
|
return None
|
|
hull = ConvexHull(vertices)
|
|
result = list(vertices[signed <= 0])
|
|
for triangle in hull.simplices:
|
|
for i in range(3):
|
|
a, b = triangle[i], triangle[(i + 1) % 3]
|
|
if signed[a] * signed[b] < 0:
|
|
result.append(
|
|
vertices[a] + (vertices[b] - vertices[a]) * signed[a] / (signed[a] - signed[b])
|
|
)
|
|
points = np.unique(np.asarray(result), axis=0)
|
|
if len(points) < 4:
|
|
return None
|
|
try:
|
|
# Remove edge/triangulation interpolation points BEFORE rounding;
|
|
# rounding collinear points first invents tiny zigzag facet vertices.
|
|
points = np.unique(np.round(points[ConvexHull(points).vertices], 6), axis=0)
|
|
if len(points) < 4:
|
|
return None
|
|
hull = ConvexHull(points)
|
|
except QhullError:
|
|
return None # sub-micrometre cutting sliver collapsed by serialization
|
|
if hull.volume <= 1e-15:
|
|
return None
|
|
return points[hull.vertices]
|
|
|
|
|
|
def split_core(vertices, planes):
|
|
"""Partition, not delete: core and every outside fragment retain external contact."""
|
|
result = []
|
|
pending = vertices
|
|
for normal, offset in planes:
|
|
outside = clip_convex(pending, -normal, -offset)
|
|
if outside is not None:
|
|
result.append(outside)
|
|
pending = clip_convex(pending, normal, offset)
|
|
if pending is None:
|
|
return result
|
|
result.append(pending)
|
|
return result
|
|
|
|
|
|
def partition_assembly_cores(source, config, parts, coverage):
|
|
"""Split straddling hulls so a bearing contact cannot lock a structural hull.
|
|
|
|
A 16-sided inscribed prism stays inside each reviewed cylinder. The 2um
|
|
inset keeps rounding from reclassifying its core as structural. No region
|
|
is removed; only whole resulting core parts receive local mating exceptions.
|
|
"""
|
|
import numpy as np
|
|
from scipy.spatial import ConvexHull
|
|
|
|
by_visual, frames = assembly_frames(source, config, coverage)
|
|
shapes = [
|
|
(part["visual"], np.fromstring(part["vertices"], sep=" ").reshape(-1, 3)) for part in parts
|
|
]
|
|
for name, parent, transform, axis, core in frames:
|
|
x = np.cross(axis, np.eye(3)[int(np.argmin(np.abs(axis)))])
|
|
x /= np.linalg.norm(x)
|
|
y = np.cross(axis, x)
|
|
normals = [np.cos(t) * x + np.sin(t) * y for t in np.arange(16) * (2 * np.pi / 16)] + [
|
|
axis,
|
|
-axis,
|
|
]
|
|
distances = [(core["radiusM"] - 0.000002) * np.cos(np.pi / 16)] * 16 + [
|
|
core["axialHalfExtentM"] - 0.000002
|
|
] * 2
|
|
divided = []
|
|
for visual, vertices in shapes:
|
|
group, pose = by_visual[visual]
|
|
if group not in (parent, name):
|
|
divided.append((visual, vertices))
|
|
continue
|
|
origin = pose[:3, 3] - transform[:3, 3]
|
|
bounds = swept_bounds(vertices @ pose[:3, :3].T + origin, axis)
|
|
if (
|
|
(
|
|
bounds[3] <= core["radiusM"]
|
|
and max(abs(bounds[0]), abs(bounds[1])) <= core["axialHalfExtentM"]
|
|
)
|
|
or bounds[2] > core["radiusM"]
|
|
or bounds[0] > core["axialHalfExtentM"]
|
|
or bounds[1] < -core["axialHalfExtentM"]
|
|
):
|
|
divided.append((visual, vertices))
|
|
continue
|
|
planes = [
|
|
(pose[:3, :3].T @ n, distance - n @ origin)
|
|
for n, distance in zip(normals, distances, strict=True)
|
|
]
|
|
pieces = split_core(vertices, planes)
|
|
original = ConvexHull(vertices)
|
|
volume = sum(ConvexHull(piece).volume for piece in pieces)
|
|
if abs(volume - original.volume) > max(original.volume * 0.01, original.area * 0.00001):
|
|
raise ValueError(f"配合区切分体积误差超出微米量化预算:{visual}")
|
|
divided.extend((visual, piece) for piece in pieces)
|
|
shapes = divided
|
|
result = []
|
|
for item in coverage:
|
|
pieces = [v for visual, v in shapes if visual == item["visual"]]
|
|
pieces.sort(key=lambda v: tuple(v.mean(axis=0)))
|
|
item["coacdParts"] = item["parts"]
|
|
item["parts"] = len(pieces)
|
|
if not 1 <= len(pieces) <= 256:
|
|
raise ValueError("装配区域分割超出预算")
|
|
for i, vertices in enumerate(pieces):
|
|
if len(vertices) > 256:
|
|
raise ValueError(f"装配分割凸包顶点超出预算:{item['visual']} / {len(vertices)}")
|
|
result.append(
|
|
{
|
|
"name": f"{item['link']}__h{i:03d}",
|
|
"visual": item["visual"],
|
|
"vertices": " ".join(
|
|
f"{value:.6f}" for point in sorted(map(tuple, vertices)) for value in point
|
|
),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def cook(source, config, cache):
|
|
import coacd
|
|
import numpy as np
|
|
import trimesh
|
|
from scipy.spatial import ConvexHull
|
|
|
|
for name, version in VERSIONS.items():
|
|
if importlib.metadata.version(name) != version:
|
|
raise ValueError(f"请使用离线固定依赖:{name}=={version}")
|
|
coacd.set_log_level("info")
|
|
parts = []
|
|
coverage = []
|
|
for visual in visuals(source, config):
|
|
key = digest(
|
|
json.dumps(
|
|
{
|
|
"source": visual["sha256"],
|
|
"scale": visual["scale"],
|
|
"parameters": PARAMETERS,
|
|
"versions": VERSIONS,
|
|
"threads": 4,
|
|
},
|
|
sort_keys=True,
|
|
).encode()
|
|
)
|
|
cached = cache / f"{key}.json"
|
|
mesh = trimesh.load(source / visual["mesh"], force="mesh")
|
|
mesh.apply_scale(visual["scale"])
|
|
if cached.exists():
|
|
hulls = json.loads(cached.read_text())
|
|
else:
|
|
print(f"生成 {visual['mesh']}", flush=True)
|
|
raw = coacd.run_coacd(coacd.Mesh(mesh.vertices, mesh.faces), **PARAMETERS)
|
|
hulls = [sorted(np.round(v, 6).tolist()) for v, _ in raw]
|
|
hulls.sort(key=lambda vertices: tuple(np.mean(vertices, axis=0)))
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
temporary = cached.with_suffix(".tmp")
|
|
temporary.write_text(json.dumps(hulls))
|
|
temporary.replace(cached)
|
|
if not 1 <= len(hulls) <= 128:
|
|
raise ValueError(f"凸包数量超出预算:{visual['mesh']}")
|
|
# An explicit sampled coverage diagnostic, not a certified Hausdorff bound.
|
|
samples = np.concatenate([mesh.vertices, mesh.triangles_center])[::3]
|
|
outside = np.full(len(samples), np.inf)
|
|
for index, vertices in enumerate(hulls):
|
|
v = np.asarray(vertices)
|
|
if (
|
|
v.ndim != 2
|
|
or v.shape[1] != 3
|
|
or not 4 <= len(v) <= PARAMETERS["max_ch_vertex"]
|
|
or not np.isfinite(v).all()
|
|
):
|
|
raise ValueError("凸包顶点损坏或超出预算")
|
|
if (v.min(axis=0) < mesh.bounds[0] - 0.002).any() or (
|
|
v.max(axis=0) > mesh.bounds[1] + 0.002
|
|
).any():
|
|
raise ValueError("凸包超出源网格边界预算")
|
|
hull = ConvexHull(v)
|
|
if hull.volume <= 1e-15:
|
|
raise ValueError("退化凸包")
|
|
signed_planes = (samples @ hull.equations[:, :3].T + hull.equations[:, 3]).max(axis=1)
|
|
outside = np.minimum(outside, signed_planes)
|
|
parts.append(
|
|
{
|
|
"name": f"{visual['link']}__h{index:03d}",
|
|
"visual": visual["visual"],
|
|
"vertices": " ".join(f"{x:.6f}" for x in v.ravel()),
|
|
}
|
|
)
|
|
if outside.max() > 0.0015:
|
|
raise ValueError(f"源表面采样覆盖不合格:{visual['mesh']}")
|
|
coverage.append(
|
|
{
|
|
**visual,
|
|
"parts": len(hulls),
|
|
"samples": len(samples),
|
|
"coacdSampleOutsidePlanesMaxM": float(max(0, outside.max())),
|
|
"sourceWatertight": bool(mesh.is_watertight),
|
|
"cacheKey": key,
|
|
}
|
|
)
|
|
print(
|
|
f"完成 {visual['link']}: {len(hulls)} hulls; sampled plane gap={outside.max():.6g} m",
|
|
flush=True,
|
|
)
|
|
parts = partition_assembly_cores(source, config, parts, coverage)
|
|
policy = joint_policy(source, config, parts, coverage)
|
|
geometry_sha = digest(json.dumps(parts, sort_keys=True, separators=(",", ":")).encode())
|
|
physics = {"timestep": 0.001, "solref": [0.002, 1], "solimp": [0.95, 0.99, 0.001]}
|
|
recipe_sha = digest(
|
|
json.dumps(
|
|
{"geometry": geometry_sha, "policy": policy, "physics": physics},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode()
|
|
)
|
|
return {
|
|
"revision": 4,
|
|
"geometrySha256": geometry_sha,
|
|
"recipeSha256": recipe_sha,
|
|
"physics": physics,
|
|
"jointPolicy": policy,
|
|
"source": config,
|
|
"generator": {
|
|
"versions": VERSIONS,
|
|
"parameters": PARAMETERS,
|
|
"threads": 4,
|
|
"corePartition": {"sides": 16, "insetM": 0.000002},
|
|
},
|
|
"coverage": coverage,
|
|
"parts": parts,
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--source", type=Path, default=ROOT / "build/lekiwi/URDF")
|
|
parser.add_argument("--cache", type=Path, default=ROOT / "build/collision-cache")
|
|
parser.add_argument(
|
|
"--output", type=Path, default=ROOT / "robot_profiles/lekiwi-full-collision.json"
|
|
)
|
|
parser.add_argument("--check", action="store_true")
|
|
args = parser.parse_args()
|
|
result = cook(args.source, json.loads(CONFIG.read_text()), args.cache)
|
|
if args.check:
|
|
if json.loads(args.output.read_text()) != result:
|
|
raise ValueError("已生成碰撞数据与源文件/算法参数不一致")
|
|
else:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(result, indent=2) + "\n")
|
|
print(f"整臂碰撞覆盖 {len(result['coverage'])} 个视觉网格 / {len(result['parts'])} 个凸包")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|