File size: 19,443 Bytes
0d96152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
"""
PER-INSTANCE-ENVIRONMENT evaluator.

The shared evaluator builds ONE environment from instance_idx=0 and then draws start/goal
pairs from instances 0..9. But instances_data/<map>.pkl stores a DIFFERENT obstacle layout
per instance (env_empty_nowait_2d_extra_objects.py:23 reads loaded_set[instance_idx][0][0]),
so from instance 1 onward robots are asked to start or finish INSIDE an obstacle:
basic-6 has 1-4 of 12 endpoints buried, dense-3 up to 5 of 6. Success needs every robot
collision-free, so ~9/10 of the basic/dense test set is unsolvable by construction and the
score is capped near 0.10 -- exactly the ceiling every method hit (SMD, MMD, ours).
empty is unaffected (no obstacles); room/shelf/corridor take the fixed-geometry branch and
sample fresh start/goals, so they were never affected.

This evaluator rebuilds the dataset/env/task/guide per test instance with instance_idx=i so
that instance i's start/goals meet instance i's obstacles. Trajectory files (and hence
normalisation statistics) do not depend on instance_idx, so the model is unchanged.

Evaluates the composite SMD models trained on the merged (original + PP)
dataset with the longer training budget (train_smd_merged_all.py's
merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py
(same test instances, same feasibility criterion) -- only the checkpoint/
dataset-stats source changes -- so results are directly comparable to the
paper and to both prior reproductions' eval_results.json.

Also reports the any-of-N-samples-feasible success rate alongside the
original sample-0-only metric: checking only the arbitrary first sample in
the batch under-reports real capability relative to the more standard
"at least one of N generated samples is feasible" convention.

Generalized version of evaluate_smd.py: runs SMD inference (diffusion
sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any
scenario defined in scenarios.py, and reports success rate / path length.

Unlike the first evaluate_smd.py, feasibility is checked using the repo's
own task.get_trajs_collision_and_free (real self-collision + object/SDF
collision + workspace-boundary checking) instead of a hand-rolled
circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped
wall geometry that a spheres-only checker would silently miss.

For empty/basic/dense, test instances come from the instances_data/<map>.pkl
files (start/goal only; the projection step itself needs actual obstacle
positions, read directly from the loaded environment). For corridor/room/
shelf (fixed wall geometry, no per-instance obstacles), test instances are
fresh random collision-free start/goal pairs sampled at evaluation time.

Usage:
    python evaluate_smd_all.py <tag>
    python evaluate_smd_all.py --all
"""
import os
import sys
import pickle
import json
import argparse
from math import ceil

import numpy as np
import torch

REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
sys.path.insert(0, REPO_ROOT)

from smd.models import TemporalUnet, UNET_DIM_MULTS
from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity
from smd.models.diffusion_models.sample_functions import ddpm_sample_fn
from smd.trainer import get_dataset, get_model
from smd.utils.loading import load_params_from_yaml
from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory
from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params
from torch_robotics.torch_utils.seed import fix_random_seed

from scenarios import SCENARIOS, get_scenario

N_TEST_INSTANCES = 10
INSTANCE_IDX = 0
N_SAMPLES = 16
# Identical to evaluate_smd_merged_all.py EXCEPT box_cover_cell, which switches on the
# box-obstacle covering in apply_projection_alm. Without it the projection sees num_obs=0
# in Shelf/Room/Conveyor (their walls are box fields, and the released code only read
# fields[0] and required .radii), so it is blind to walls: it "fixes" inter-robot
# collisions by pushing robots straight through the shelves. That is the measured
# signature on Shelf -- raw model 0.90/0.70 at N=6/9, projection destroys it to 0.10/0.00.
# BOX_COVER_CELL is the covering resolution; smaller = tighter fit, more circles, slower.
BOX_COVER_CELL = float(os.environ.get('BOX_COVER_CELL', '0.125'))
PROJ_PARAMS = dict(
    agents_max_speeds=0.05,
    rho=5.0,
    rho_factor=1.05,
    alm_iteration=100,
    tolerance=1e-3,
    projection_step=[15, 5],
    box_cover_cell=BOX_COVER_CELL,
)
WEIGHT_GRAD_COST_COLLISION = 5e-2
WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2
START_GUIDE_STEPS_FRACTION = 0.5
N_GUIDE_STEPS = 25
N_DIFFUSION_STEPS_WITHOUT_NOISE = 5
TRAJECTORY_DURATION = 5.0
EVAL_SEED = 123

BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2}


def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3):
    """
    Feasibility check matching the repo's own official is_collision.py
    criterion exactly (2*robot_radius contact distance for inter-robot
    pairs), rather than task.get_trajs_collision_and_free's composite
    self-collision field, which turned out to use self_collision_margin_robot
    = radius*4 as its hard occupancy threshold -- *twice* the paper's own
    evaluation margin. That mismatch was silently marking genuinely feasible
    (per the paper's own definition) trajectories as failures in any scenario
    where robots achieve real but tighter clearance (0.1-0.2), which turned
    out to matter a lot in cramped scenes (e.g. Room at 6-9 agents).

    Obstacle/wall collision is still checked against the real environment via
    env.compute_sdf (correctly handles both sphere obstacles and box walls),
    just with robot_radius as the margin instead of the stricter self-collision
    field convention.

    pos: (B, H, num_agents, 2) tensor of positions.
    Returns: (B,) bool tensor, True if that sample is fully collision-free.
    """
    B, H, N, _ = pos.shape

    sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N)
    obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1)

    diff = pos.unsqueeze(3) - pos.unsqueeze(2)  # (B,H,N,N,2)
    dist2 = (diff ** 2).sum(-1)  # (B,H,N,N)
    mask = ~torch.eye(N, dtype=torch.bool, device=pos.device)  # ignore diagonal (self pairs)
    min_required2 = (2 * robot_radius) ** 2 - threshold
    pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0)
    robot_free = pair_ok.reshape(B, -1).all(dim=1)

    return obj_free & robot_free


def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon):
    init = {}
    for j in range(num_agents):
        traj = np.stack([
            start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1))
            for i in range(horizon)
        ], axis=0)
        init[j] = traj
    return init


def get_test_instances(cfg, task, tensor_args):
    """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array)."""
    num_agents = cfg['num_agents']
    if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty':
        pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl")
        with open(pkl_path, 'rb') as f:
            instances = pickle.load(f)
        bucket = BUCKET_BY_N[num_agents]
        out = []
        for i in range(min(N_TEST_INSTANCES, len(instances))):
            _, agents_info = instances[i][bucket]
            start_pos = np.stack([a[0] for a in agents_info], axis=0)
            goal_pos = np.stack([a[1] for a in agents_info], axis=0)
            out.append((start_pos, goal_pos))
        return out
    else:
        # Fixed wall geometry (corridor/room/shelf): sample fresh instances here.
        fix_random_seed(EVAL_SEED)
        out = []
        for _ in range(N_TEST_INSTANCES):
            for _ in range(200):
                q_free = task.random_coll_free_q(n_samples=2)
                s, g = q_free[0], q_free[1]
                if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']:
                    break
            start_pos = s.detach().cpu().numpy().reshape(num_agents, 2)
            goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2)
            out.append((start_pos, goal_pos))
        return out


# When True, projection is disabled entirely (proj_params=None), turning the
# composite model into the plain diffusion baseline that SMD's Appendix C
# calls "DM" -- same trained weights, no augmented-Lagrangian projection.
# Results go to eval_results_dm.json so the SMD numbers are never overwritten.
NO_PROJECTION = False


def _build_for_instance(cfg, args, tensor_args, inst_idx):
    """dataset/env/task/guide for ONE test instance's own obstacle layout."""
    train_subset, _, _, _ = get_dataset(
        dataset_class='TrajectoryDataset', use_extra_objects=True,
        obstacle_cutoff_margin=0.01, **args, tensor_args=tensor_args,
        instance_idx=inst_idx, map_name=cfg['map_name'],
    )
    ds = train_subset.dataset
    ds.robot.dt = TRAJECTORY_DURATION / ds.n_support_points
    return ds


def _make_guide(ds, tensor_args):
    n = ds.n_support_points
    dt = TRAJECTORY_DURATION / n
    cost_l, w_l = [], []
    for cf in ds.task.get_collision_fields():
        cost_l.append(CostCollision(ds.robot, n, field=cf, sigma_coll=1.0, tensor_args=tensor_args))
        w_l.append(WEIGHT_GRAD_COST_COLLISION)
    cost_l.append(CostGPTrajectory(ds.robot, n, dt, sigma_gp=1.0, tensor_args=tensor_args))
    w_l.append(WEIGHT_GRAD_COST_SMOOTHNESS)
    comp = CostComposite(ds.robot, n, cost_l, weights_cost_l=w_l, tensor_args=tensor_args)
    return GuideManagerTrajectoriesWithVelocity(
        ds, comp, clip_grad=True, interpolate_trajectories_for_collision=True,
        num_interpolated_points=ceil(n * 1.5), tensor_args=tensor_args)


def _startgoal(cfg, inst_idx):
    """start/goal for THIS instance (same pkl indexing the env used)."""
    import pickle as _pk
    p = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl")
    with open(p, 'rb') as f:
        inst = _pk.load(f)
    ag = inst[inst_idx][BUCKET_BY_N[cfg['num_agents']]][1]
    return (np.stack([a[0] for a in ag]), np.stack([a[1] for a in ag]))


def run_scenario(cfg):
    device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu')
    tensor_args = {'device': device, 'dtype': torch.float32}

    model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}")
    out_name = f'eval_results_perinst_{str(BOX_COVER_CELL).replace(".","")}.json'
    out_path = os.path.join(model_dir, out_name)
    if os.path.exists(out_path):
        print(f"[{cfg['tag']}] {out_name} already exists, skipping")
        return json.load(open(out_path))

    args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml'))

    train_subset, train_dataloader, val_subset, val_dataloader = get_dataset(
        dataset_class='TrajectoryDataset',
        use_extra_objects=True,
        obstacle_cutoff_margin=0.01,
        **args,
        tensor_args=tensor_args,
        instance_idx=INSTANCE_IDX,
        map_name=cfg['map_name'],
    )
    dataset = train_subset.dataset
    n_support_points = dataset.n_support_points
    robot = dataset.robot
    task = dataset.task
    env = dataset.env
    dt = TRAJECTORY_DURATION / n_support_points
    robot.dt = dt

    diffusion_configs = dict(
        variance_schedule=args['variance_schedule'],
        n_diffusion_steps=args['n_diffusion_steps'],
        predict_epsilon=args['predict_epsilon'],
    )
    unet_configs = dict(
        state_dim=dataset.state_dim,
        n_support_points=dataset.n_support_points,
        unet_input_dim=args['unet_input_dim'],
        dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']],
    )
    diffusion_model = get_model(
        model_class=args['diffusion_model_class'],
        model=TemporalUnet(**unet_configs),
        tensor_args=tensor_args,
        **diffusion_configs,
        **unet_configs,
    )
    ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth'
    diffusion_model.load_state_dict(
        torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device'])
    )
    diffusion_model.eval()
    freeze_torch_model_params(diffusion_model)
    model = diffusion_model
    model.warmup(horizon=n_support_points, device=device)

    collision_fields = task.get_collision_fields()
    cost_collision_l = []
    weights_grad_cost_l = []
    for cf in collision_fields:
        cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args))
        weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION)
    cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)]
    weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS)
    cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l],
                                    weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args)
    guide = GuideManagerTrajectoriesWithVelocity(
        dataset, cost_composite, clip_grad=True,
        interpolate_trajectories_for_collision=True,
        num_interpolated_points=ceil(n_support_points * 1.5),
        tensor_args=tensor_args,
    )
    t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps)

    # instance i's start/goal MUST be paired with instance i's obstacles
    n_inst = N_TEST_INSTANCES
    test_instances = [_startgoal(cfg, i) for i in range(n_inst)]

    results = []
    for instance_idx, (start_pos, goal_pos) in enumerate(test_instances):
        # rebuild env/task/guide for THIS instance's obstacle layout
        dataset = _build_for_instance(cfg, args, tensor_args, instance_idx)
        robot, task, env = dataset.robot, dataset.task, dataset.env
        guide = _make_guide(dataset, tensor_args)
        num_agents = cfg['num_agents']
        start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args)
        goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args)

        hard_conds = dataset.get_hard_conditions(
            torch.vstack((start_state_pos, goal_state_pos)), normalize=True
        )
        init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points)

        sample_fn_kwargs = dict(
            guide=guide,
            n_guide_steps=N_GUIDE_STEPS,
            t_start_guide=t_start_guide,
            noise_std_extra_schedule_fn=lambda x: 0.5,
        )

        print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====")
        trajs_normalized_iters = model.run_inference(
            None, hard_conds,
            n_samples=N_SAMPLES, horizon=n_support_points,
            return_chain=True,
            sample_fn=ddpm_sample_fn,
            **sample_fn_kwargs,
            n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE,
            dataset=dataset,
            init_traj4proj=init_traj4proj,
            proj_params=None if NO_PROJECTION else PROJ_PARAMS,
        )
        trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters)
        trajs_final = trajs_iters[-1]  # (n_samples, horizon, state_dim)

        # Feasibility checked against the paper's own official criterion
        # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF)
        # -- see check_paths_feasible_batch docstring for why this replaced
        # task.get_trajs_collision_and_free's stricter self-collision field.
        pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2)
        feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env)
        n_feasible_samples = int(feasible_mask.sum().item())
        sample0_feasible = bool(feasible_mask[0].item())

        pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy()
        sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2)
        diffs = np.diff(sample0, axis=1)
        plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean()

        print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} "
              f"feasible_samples={n_feasible_samples}/{pos.shape[0]}")

        # --- SMD Table 3-6 metrics -------------------------------------
        # A: mean |acceleration| over waypoints, averaged over agents, on
        #    sample 0 (finite differences at dt = duration/horizon).
        # C: collision ratio = fraction of the 16 sampled trajectories that
        #    are NOT feasible (their Tables report this as "C").
        dt_ = TRAJECTORY_DURATION / n_support_points
        _s0 = pos[0].reshape(n_support_points, num_agents, 2)
        _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2)
        metric_A = float(np.linalg.norm(_acc, axis=-1).mean())
        metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1)))

        results.append(dict(
            instance_idx=instance_idx,
            metric_A=metric_A,
            metric_C=metric_C,
            sample0_feasible=sample0_feasible,
            avg_path_length=float(plen),
            n_feasible_samples=n_feasible_samples,
            n_samples=int(pos.shape[0]),
        ))

    success_rate = sum(r['sample0_feasible'] for r in results) / len(results)
    any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results)
    feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']]
    avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan')
    total_samples = sum(r['n_samples'] for r in results)
    total_feasible_samples = sum(r['n_feasible_samples'] for r in results)

    summary = dict(
        tag=cfg['tag'], results=results, success_rate=success_rate,
        metric_A=float(np.mean([r['metric_A'] for r in results])),
        metric_C=float(np.mean([r['metric_C'] for r in results])),
        projection_enabled=(not NO_PROJECTION),
        any_of_n_success_rate=any_of_n_success_rate,
        avg_path_length_over_success=avg_len,
        sample_feasibility_rate=total_feasible_samples / total_samples,
    )
    with open(os.path.join(model_dir, out_name), 'w') as f:
        json.dump(summary, f, indent=2)

    print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | "
          f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | "
          f"AVG PATH LEN: {avg_len:.4f} | "
          f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}")
    return summary


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('tag', nargs='?', default=None)
    parser.add_argument('--all', action='store_true')
    parser.add_argument('--no-projection', action='store_true',
                        help="DM baseline: run the same models with projection "
                             "disabled; writes eval_results_dm.json")
    args = parser.parse_args()

    if args.no_projection:
        NO_PROJECTION = True
        print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***")

    if args.all:
        for cfg in SCENARIOS:
            run_scenario(cfg)
    elif args.tag:
        run_scenario(get_scenario(args.tag))
    else:
        parser.error("provide a scenario tag or --all")