Skip to content

Commit ba5c119

Browse files
committed
create random assignment for heterogenous cloning
1 parent 329ca16 commit ba5c119

File tree

6 files changed

+153
-69
lines changed

6 files changed

+153
-69
lines changed

scripts/demos/multi_asset.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ class MultiObjectSceneCfg(InteractiveSceneCfg):
105105

106106
# rigid object
107107
object: RigidObjectCfg = RigidObjectCfg(
108-
prim_path="/World/envs/env_.*/Object",
108+
prim_path="{ENV_REGEX_NS}/Object",
109109
spawn=sim_utils.MultiAssetSpawnerCfg(
110110
assets_cfg=[
111111
sim_utils.ConeCfg(
@@ -136,7 +136,7 @@ class MultiObjectSceneCfg(InteractiveSceneCfg):
136136
object_collection: RigidObjectCollectionCfg = RigidObjectCollectionCfg(
137137
rigid_objects={
138138
"object_A": RigidObjectCfg(
139-
prim_path="/World/envs/env_.*/Object_A",
139+
prim_path="{ENV_REGEX_NS}/Object_A",
140140
spawn=sim_utils.SphereCfg(
141141
radius=0.1,
142142
visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), metallic=0.2),
@@ -149,7 +149,7 @@ class MultiObjectSceneCfg(InteractiveSceneCfg):
149149
init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, -0.5, 2.0)),
150150
),
151151
"object_B": RigidObjectCfg(
152-
prim_path="/World/envs/env_.*/Object_B",
152+
prim_path="{ENV_REGEX_NS}/Object_B",
153153
spawn=sim_utils.CuboidCfg(
154154
size=(0.1, 0.1, 0.1),
155155
visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), metallic=0.2),
@@ -162,7 +162,7 @@ class MultiObjectSceneCfg(InteractiveSceneCfg):
162162
init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.5, 2.0)),
163163
),
164164
"object_C": RigidObjectCfg(
165-
prim_path="/World/envs/env_.*/Object_C",
165+
prim_path="{ENV_REGEX_NS}/Object_C",
166166
spawn=sim_utils.ConeCfg(
167167
radius=0.1,
168168
height=0.3,
@@ -180,7 +180,7 @@ class MultiObjectSceneCfg(InteractiveSceneCfg):
180180

181181
# articulation
182182
robot: ArticulationCfg = ArticulationCfg(
183-
prim_path="/World/envs/env_.*/Robot",
183+
prim_path="{ENV_REGEX_NS}/Robot",
184184
spawn=sim_utils.MultiUsdFileCfg(
185185
usd_path=[
186186
f"{ISAACLAB_NUCLEUS_DIR}/Robots/ANYbotics/ANYmal-C/anymal_c.usd",

source/isaaclab/isaaclab/sim/spawners/cloner.py renamed to source/isaaclab/isaaclab/scene/cloner.py

Lines changed: 120 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,79 @@ def usd_replicate(
99
sources: list[str],
1010
destinations: list[str],
1111
env_ids: torch.Tensor,
12-
mapping: torch.Tensor,
12+
mask: torch.Tensor | None = None,
1313
positions: torch.Tensor | None = None,
1414
quaternions: torch.Tensor | None = None,
1515
) -> None:
16+
"""Replicate USD prims to per-environment destinations.
17+
18+
Copies each source prim spec to destination templates for selected environments
19+
(``mask``). Optionally authors translate/orient from position/quaternion buffers.
20+
Replication runs in path-depth order (parents before children) for robust composition.
21+
22+
Args:
23+
stage: USD stage.
24+
sources: Source prim paths.
25+
destinations: Destination formattable templates with ``"{}"`` for env index.
26+
env_ids: Environment indices.
27+
mask: Optional per-source or shared mask. ``None`` selects all.
28+
positions: Optional positions (``[E, 3]``) -> ``xformOp:translate``.
29+
quaternions: Optional orientations (``[E, 4]``) in ``wxyz`` -> ``xformOp:orient``.
30+
31+
Returns:
32+
None
33+
"""
1634
rl = stage.GetRootLayer()
17-
with Sdf.ChangeBlock():
18-
for i, src in enumerate(sources):
19-
tmpl = destinations[i]
20-
for wid in env_ids[mapping[i]].tolist():
21-
dp = tmpl.format(wid)
22-
Sdf.CreatePrimInLayer(rl, dp)
23-
Sdf.CopySpec(rl, Sdf.Path(src), rl, Sdf.Path(dp))
24-
25-
if positions is not None or quaternions is not None:
26-
ps = rl.GetPrimAtPath(dp)
27-
# translate
28-
if positions is not None:
29-
p = positions[wid]
30-
t_attr = ps.GetAttributeAtPath(dp + ".xformOp:translate")
31-
if t_attr is None:
32-
t_attr = Sdf.AttributeSpec(ps, "xformOp:translate", Sdf.ValueTypeNames.Double3)
33-
t_attr.default = Gf.Vec3d(float(p[0]), float(p[1]), float(p[2]))
34-
# orient (always set as Quatd)
35-
if quaternions is not None:
36-
q = quaternions[wid]
37-
o_attr = ps.GetAttributeAtPath(dp + ".xformOp:orient")
38-
if o_attr is None:
39-
o_attr = Sdf.AttributeSpec(ps, "xformOp:orient", Sdf.ValueTypeNames.Quatd)
40-
o_attr.default = Gf.Quatd(float(q[0]), Gf.Vec3d(float(q[1]), float(q[2]), float(q[3])))
41-
# op order
42-
order = ps.GetAttributeAtPath(dp + ".xformOpOrder") or Sdf.AttributeSpec(ps, UsdGeom.Tokens.xformOpOrder, Sdf.ValueTypeNames.TokenArray)
43-
order.default = Vt.TokenArray(["xformOp:translate", "xformOp:orient", "xformOp:scale"])
35+
36+
# Group replication by destination path depth so ancestors land before deeper paths.
37+
# This avoids composition issues for nested or interdependent specs.
38+
def dp_depth(template: str) -> int:
39+
dp = template.format(0)
40+
return Sdf.Path(dp).pathElementCount
41+
42+
order = sorted(range(len(sources)), key=lambda i: dp_depth(destinations[i]))
43+
44+
# Process in layers of equal depth, committing at each depth to stabilize composition
45+
depth_to_indices: dict[int, list[int]] = {}
46+
for i in order:
47+
d = dp_depth(destinations[i])
48+
depth_to_indices.setdefault(d, []).append(i)
49+
50+
for depth in sorted(depth_to_indices.keys()):
51+
with Sdf.ChangeBlock():
52+
for i in depth_to_indices[depth]:
53+
src = sources[i]
54+
tmpl = destinations[i]
55+
# Select target environments for this source (supports None, [E], or [S, E])
56+
target_envs = env_ids if mask is None else env_ids[mask[i]]
57+
for wid in target_envs.tolist():
58+
dp = tmpl.format(wid)
59+
Sdf.CreatePrimInLayer(rl, dp)
60+
Sdf.CopySpec(rl, Sdf.Path(src), rl, Sdf.Path(dp))
61+
62+
if positions is not None or quaternions is not None:
63+
ps = rl.GetPrimAtPath(dp)
64+
op_names = []
65+
if positions is not None:
66+
p = positions[wid]
67+
t_attr = ps.GetAttributeAtPath(dp + ".xformOp:translate")
68+
if t_attr is None:
69+
t_attr = Sdf.AttributeSpec(ps, "xformOp:translate", Sdf.ValueTypeNames.Double3)
70+
t_attr.default = Gf.Vec3d(float(p[0]), float(p[1]), float(p[2]))
71+
op_names.append("xformOp:translate")
72+
if quaternions is not None:
73+
q = quaternions[wid]
74+
o_attr = ps.GetAttributeAtPath(dp + ".xformOp:orient")
75+
if o_attr is None:
76+
o_attr = Sdf.AttributeSpec(ps, "xformOp:orient", Sdf.ValueTypeNames.Quatd)
77+
o_attr.default = Gf.Quatd(float(q[0]), Gf.Vec3d(float(q[1]), float(q[2]), float(q[3])))
78+
op_names.append("xformOp:orient")
79+
# Only author xformOpOrder for the ops we actually authored
80+
if op_names:
81+
op_order = ps.GetAttributeAtPath(dp + ".xformOpOrder") or Sdf.AttributeSpec(
82+
ps, UsdGeom.Tokens.xformOpOrder, Sdf.ValueTypeNames.TokenArray
83+
)
84+
op_order.default = Vt.TokenArray(op_names)
4485

4586

4687
def physx_replicate(
@@ -49,16 +90,24 @@ def physx_replicate(
4990
destinations: list[str], # e.g. ["/World/envs/env_{}/Robot", "/World/envs/env_{}/Object"]
5091
env_ids: torch.Tensor, # env_ids
5192
mapping: torch.Tensor, # (num_sources, num_envs) bool; True -> place sources[i] into world=j
52-
use_env_ids: bool = True, # hint: only used when a row covers all worlds
5393
use_fabric: bool = False,
5494
) -> None:
55-
"""
56-
PhysX replication that matches the (sources, destinations, mapping) convention.
57-
For each source i, we build the per-world destination list:
58-
dests_i = [ destinations[i].format(w) for w in worlds where mapping[i, w] is True ]
95+
"""Replicate prims via PhysX replicator with per-row mapping.
96+
97+
Builds per-source destination lists from ``mapping`` and calls PhysX ``replicate``.
98+
Rows covering all environments use ``useEnvIds=True``; partial rows use ``False``.
99+
The replicator is registered for the call and then unregistered.
59100
60-
If a row covers ALL worlds, we pass useEnvIds=True to PhysX so per-env filtering is automatic.
61-
If a row is partial, we force useEnvIds=False so cross-row collisions within the same env remain possible.
101+
Args:
102+
stage: USD stage.
103+
sources: Source prim paths (``S``).
104+
destinations: Destination templates (``S``) with ``"{}"`` for env index.
105+
env_ids: Environment indices (``[E]``).
106+
mapping: Bool/int mask (``[S, E]``) selecting envs per source.
107+
use_fabric: Use Fabric for replication.
108+
109+
Returns:
110+
None
62111
"""
63112

64113
stage_id = UsdUtils.StageCache.Get().Insert(stage).ToLongInt()
@@ -86,16 +135,27 @@ def attach_end_fn(_stage_id: int):
86135

87136

88137
def filter_collisions(
89-
stage: Usd.Stage, physicsscene_path: str, collision_root_path: str, prim_paths: list[str], global_paths: list[str] = []
90-
):
91-
"""Filters collisions between clones. Clones will not collide with each other, but can collide with objects specified in global_paths.
138+
stage: Usd.Stage,
139+
physicsscene_path: str,
140+
collision_root_path: str,
141+
prim_paths: list[str],
142+
global_paths: list[str] = []
143+
) -> None:
144+
"""Create inverted collision groups for clones.
92145
93-
Args:
94-
physicsscene_path (str): Path to PhysicsScene object in stage.
95-
collision_root_path (str): Path to place collision groups under.
96-
prim_paths (List[str]): Paths of objects to filter out collision.
97-
global_paths (List[str]): Paths of objects to generate collision (e.g. ground plane).
146+
Creates one PhysicsCollisionGroup per prim under ``collision_root_path``, enabling
147+
inverted filtering so clones don't collide across groups. Optionally adds a global
148+
group that collides with all.
98149
150+
Args:
151+
stage: USD stage.
152+
physicsscene_path: Path to PhysicsScene prim.
153+
collision_root_path: Root scope for collision groups.
154+
prim_paths: Per-clone prim paths.
155+
global_paths: Optional global-collider paths.
156+
157+
Returns:
158+
None
99159
"""
100160

101161
physx_scene = PhysxSchema.PhysxSceneAPI(stage.GetPrimAtPath(physicsscene_path))
@@ -181,6 +241,23 @@ def filter_collisions(
181241

182242

183243
def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cpu"):
244+
"""Create a centered grid of transforms for ``N`` instances.
245+
246+
Computes ``(x, y)`` coordinates in a roughly square grid centered at the origin
247+
with the provided spacing, places the third coordinate according to ``up_axis``,
248+
and returns identity orientations (``wxyz``) for each instance.
249+
250+
Args:
251+
N: Number of instances.
252+
spacing: Distance between neighboring grid positions.
253+
up_axis: Up axis for positions ("z", "y", or "x").
254+
device: Torch device for returned tensors.
255+
256+
Returns:
257+
A tuple ``(pos, ori)`` where:
258+
- ``pos`` is a tensor of shape ``(N, 3)`` with positions.
259+
- ``ori`` is a tensor of shape ``(N, 4)`` with identity quaternions in ``(w, x, y, z)``.
260+
"""
184261
# rows/cols
185262
rows = int(math.ceil(math.sqrt(N)))
186263
cols = int(math.ceil(N / rows))

source/isaaclab/isaaclab/scene/interactive_scene.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
from isaaclab.sim import SimulationContext
3131
from isaaclab.sim.utils import get_current_stage_id
3232
from isaaclab.terrains import TerrainImporter, TerrainImporterCfg
33-
from isaaclab.sim.spawners.cloner import physx_replicate, filter_collisions, usd_replicate, grid_transforms
3433

34+
from .cloner import physx_replicate, filter_collisions, usd_replicate, grid_transforms
3535
from .interactive_scene_cfg import InteractiveSceneCfg
3636

3737

@@ -145,14 +145,7 @@ def __init__(self, cfg: InteractiveSceneCfg):
145145
prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4)
146146
self._default_env_origins, _ = grid_transforms(self.cfg.num_envs, self.cfg.env_spacing, device=self.device)
147147
# copy empty prim of env_0 to env_1, env_2, ..., env_{num_envs-1} with correct location.
148-
usd_replicate(
149-
stage=self.stage,
150-
sources=[self.env_fmt.format(0)],
151-
destinations=[self.env_fmt],
152-
env_ids=self._ALL_INDICES,
153-
mapping=torch.ones((1, self.num_envs), dtype=torch.bool),
154-
positions=self._default_env_origins
155-
)
148+
usd_replicate(self.stage, [self.env_fmt.format(0)], [self.env_fmt], self._ALL_INDICES, positions=self._default_env_origins)
156149

157150
self._global_prim_paths = list()
158151
if self._is_scene_setup_from_cfg():
@@ -182,15 +175,20 @@ def clone_environments(self, copy_from_source: bool = False):
182175
self.template_path,
183176
predicate=lambda prim: str(prim.GetPath()).split("/")[-1].startswith(self.prototype_name)
184177
)
185-
prototype_set = set(["/".join(str(prototype.GetPath()).split("/")[:-1]) for prototype in prototypes])
186-
for prototype_path in prototype_set:
187-
protos = sim_utils.find_matching_prim_paths(f"{prototype_path}/.*")
178+
prototype_root_set = set(["/".join(str(prototype.GetPath()).split("/")[:-1]) for prototype in prototypes])
179+
for prototype_root in prototype_root_set:
180+
protos = sim_utils.find_matching_prim_paths(f"{prototype_root}/.*")
188181
protos = [proto for proto in protos if proto.split("/")[-1].startswith(self.prototype_name)]
189182
m = torch.zeros((len(protos), self.num_envs), dtype=torch.bool, device=self.device)
190-
m[self._ALL_INDICES % len(protos), self._ALL_INDICES] = True
183+
# Optionally select prototypes randomly per environment; else round-robin by modulo
184+
if self.cfg.random_heterogenous_cloning:
185+
rand_idx = torch.randint(len(protos), (self.num_envs,), device=self.device)
186+
m[rand_idx, self._ALL_INDICES] = True
187+
else:
188+
m[self._ALL_INDICES % len(protos), self._ALL_INDICES] = True
191189

192190
clone_plan['src'].extend(protos)
193-
clone_plan['dest'].extend([prototype_path.replace(self.template_path, self.env_fmt)] * len(protos))
191+
clone_plan['dest'].extend([prototype_root.replace(self.template_path, self.env_fmt)] * len(protos))
194192
clone_plan['mapping'] = torch.cat((clone_plan['mapping'].reshape(-1, m.size(1)), m), dim=0)
195193

196194
proto_idx = clone_plan['mapping'].to(torch.int32).argmax(dim=1)
@@ -205,14 +203,14 @@ def clone_environments(self, copy_from_source: bool = False):
205203
else:
206204
src = [tpl.format(int(idx)) for tpl, idx in zip(clone_plan['dest'], proto_idx.tolist())]
207205
replicate_args = src, clone_plan['dest'], self._ALL_INDICES, clone_plan['mapping']
208-
209-
physx_replicate(self.stage, *replicate_args)
210-
usd_replicate(self.stage, *replicate_args, positions=self._default_env_origins)
211206
else:
212207
mapping = torch.ones((1, self.num_envs), device=self.device, dtype=torch.bool)
213208
replicate_args = [self.env_fmt.format(0)], [self.env_fmt], self._ALL_INDICES, mapping
209+
210+
if not copy_from_source:
211+
# skip physx cloning, this means physx will walk and parse the stage one by one faithfully
214212
physx_replicate(self.stage, *replicate_args)
215-
usd_replicate(self.stage, *replicate_args, positions=self._default_env_origins)
213+
usd_replicate(self.stage, *replicate_args, positions=self._default_env_origins)
216214

217215
def filter_collisions(self, global_prim_paths: list[str] | None = None):
218216
"""Filter environments collisions.

source/isaaclab/isaaclab/scene/interactive_scene_cfg.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,11 @@ class MySceneCfg(InteractiveSceneCfg):
124124
default to ``False``.
125125
126126
"""
127+
128+
random_heterogenous_cloning: bool = True
129+
"""Randomly assign prototypes to environments. Default is True.
130+
131+
When enabled, each environment selects a prototype at random from the available
132+
prototypes under a given template root. When disabled, environments use a
133+
round-robin assignment based on ``env_index % num_prototypes``.
134+
"""

source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from isaacsim.core.utils.stage import get_current_stage
1515
from pxr import Usd
1616

17-
import isaaclab.sim as sim_utils
1817
from isaaclab.sim.spawners.from_files import UsdFileCfg
1918

2019
if TYPE_CHECKING:
@@ -124,8 +123,6 @@ def spawn_multi_usd_file(
124123
for usd_path in usd_paths:
125124
usd_cfg = usd_template_cfg.replace(usd_path=usd_path)
126125
multi_asset_cfg.assets_cfg.append(usd_cfg)
127-
# set random choice
128-
multi_asset_cfg.random_choice = cfg.random_choice
129126

130127
# propagate the contact sensor settings
131128
# note: the default value for activate_contact_sensors in MultiAssetSpawnerCfg is False.

source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,8 @@ class MultiUsdFileCfg(UsdFileCfg):
6464
6565
If False, the asset configurations are spawned in the order they are provided in the list.
6666
If True, a random asset configuration is selected for each spawn.
67+
.. warning::
68+
69+
This attribute is deprecated. Use :attr:`~isaaclab.scene.InteractiveSceneCfg.random_heterogenous_spawning`
70+
instead.
6771
"""

0 commit comments

Comments
 (0)