BonanDing commited on
Commit
0691e5a
·
1 Parent(s): dd214aa

Make DeMemWM FOV candidates deterministic

Browse files
.exp_artifact/dememwm_dynamic_multiview_memory_selection_plan.md CHANGED
@@ -342,7 +342,7 @@ Add DeMemWM multiview dynamic policy
342
 
343
  ## Substep 2: Make FOV Candidate Construction Deterministic And Reusable
344
 
345
- Status: `[ ]`
346
 
347
  Goal:
348
 
 
342
 
343
  ## Substep 2: Make FOV Candidate Construction Deterministic And Reusable
344
 
345
+ Status: `[x]`
346
 
347
  Goal:
348
 
datasets/video/memory_selection.py CHANGED
@@ -17,6 +17,7 @@ _FOV_HALF_H = 105.0 / 2.0
17
  _FOV_HALF_V = 75.0 / 2.0
18
  _POSE_DISTANCE_SCALE = 30.0
19
  _ANGLE_DISTANCE_SCALE = 180.0
 
20
 
21
 
22
  def cfg_get(cfg, key: str, default=None):
@@ -80,25 +81,47 @@ def _pose_directions(poses: torch.Tensor) -> torch.Tensor:
80
  return directions / torch.linalg.vector_norm(directions, dim=-1, keepdim=True).clamp_min(1e-6)
81
 
82
 
83
- def _sample_points_in_sphere(center: torch.Tensor) -> torch.Tensor:
84
- device = center.device
85
- dtype = center.dtype
 
 
 
 
86
 
87
- samples_r = torch.rand(_FOV_NUM_POINTS, device=device, dtype=dtype)
88
- samples_phi = torch.rand(_FOV_NUM_POINTS, device=device, dtype=dtype)
89
- samples_u = torch.rand(_FOV_NUM_POINTS, device=device, dtype=dtype)
90
- r = _FOV_RADIUS * torch.pow(samples_r, 1.0 / 3.0)
91
- phi = 2.0 * torch.pi * samples_phi
92
- theta = torch.acos(1.0 - 2.0 * samples_u)
93
 
94
- offsets = torch.stack(
 
 
 
 
 
 
95
  [
96
- r * torch.sin(theta) * torch.cos(phi),
97
- r * torch.sin(theta) * torch.sin(phi),
98
- r * torch.cos(theta),
99
  ],
100
  dim=-1,
101
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  return center[None, :3] + offsets
103
 
104
 
@@ -194,6 +217,57 @@ def _plucker_scores_tensor(candidate_poses: torch.Tensor, target_poses: torch.Te
194
  return (direction_sim * moment_sim).max(dim=1).values.to(dtype=torch.float32)
195
 
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  def _empty_selection(counts: Mapping[str, int]) -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
198
  indices = {}
199
  masks = {}
@@ -288,37 +362,59 @@ def _select_by_point_union(
288
  fov_threshold: float | None = None,
289
  min_total_coverage: float | None = None,
290
  use_plucker: bool = False,
 
291
  ) -> np.ndarray:
292
- if count <= 0 or len(candidates) == 0 or len(target_positions) == 0:
293
  return np.empty((0,), dtype=np.int64)
294
 
295
- poses_t = _as_pose_tensor(poses)
296
- candidates = _pose_preselect(candidates.astype(np.int64), poses_t, target_positions, cfg)
297
- inside, fov_values = _candidate_fov_masks(poses_t, candidates, target_positions, cfg)
298
- if inside.shape[0] == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  return np.empty((0,), dtype=np.int64)
300
 
301
- valid = torch.ones((len(candidates),), device=poses_t.device, dtype=torch.bool)
 
 
 
 
302
  if fov_threshold is not None:
303
  valid &= fov_values >= float(fov_threshold)
304
  if not bool(valid.any()):
305
  return np.empty((0,), dtype=np.int64)
306
 
307
  valid_idx = torch.nonzero(valid, as_tuple=False).flatten()
308
- candidates_t = torch.as_tensor(candidates, device=poses_t.device, dtype=torch.long).index_select(0, valid_idx)
309
  inside = inside.index_select(0, valid_idx)
310
  fov_values = fov_values.index_select(0, valid_idx)
311
- target_t = torch.as_tensor(target_positions, device=poses_t.device, dtype=torch.long)
312
- if use_plucker:
313
- plucker = _plucker_scores_tensor(poses_t.index_select(0, candidates_t), poses_t.index_select(0, target_t), cfg)
314
- else:
315
- plucker = torch.zeros((candidates_t.shape[0],), device=poses_t.device, dtype=torch.float32)
316
 
317
- remaining = torch.ones((candidates_t.shape[0],), device=poses_t.device, dtype=torch.bool)
318
- covered = torch.zeros((inside.shape[1],), device=poses_t.device, dtype=torch.bool)
319
  selected_rows = []
320
- first_target = int(target_positions[0])
321
- gaps = first_target - candidates_t
322
 
323
  for _ in range(count):
324
  gains = (inside & ~covered[None, :]).float().mean(dim=1)
@@ -338,7 +434,7 @@ def _select_by_point_union(
338
  coverage = float(covered.float().mean().item()) if covered.numel() else 0.0
339
  if min_total_coverage is not None and coverage < float(min_total_coverage):
340
  return np.empty((0,), dtype=np.int64)
341
- selected = candidates_t.index_select(0, torch.as_tensor(selected_rows, device=poses_t.device, dtype=torch.long))
342
  return np.sort(selected.cpu().numpy().astype(np.int64))
343
 
344
 
 
17
  _FOV_HALF_V = 75.0 / 2.0
18
  _POSE_DISTANCE_SCALE = 30.0
19
  _ANGLE_DISTANCE_SCALE = 180.0
20
+ _DETERMINISTIC_UNIT_BALL_CACHE: Dict[Tuple[int, str, torch.dtype], torch.Tensor] = {}
21
 
22
 
23
  def cfg_get(cfg, key: str, default=None):
 
81
  return directions / torch.linalg.vector_norm(directions, dim=-1, keepdim=True).clamp_min(1e-6)
82
 
83
 
84
+ def _deterministic_points_in_unit_ball(num_points: int, device, dtype) -> torch.Tensor:
85
+ """Return deterministic points in the unit ball."""
86
+
87
+ num_points = int(num_points)
88
+ device = torch.device(device)
89
+ if num_points <= 0:
90
+ return torch.zeros((0, 3), device=device, dtype=dtype)
91
 
92
+ cache_key = (num_points, str(device), dtype)
93
+ cached = _DETERMINISTIC_UNIT_BALL_CACHE.get(cache_key)
94
+ if cached is not None:
95
+ return cached
 
 
96
 
97
+ compute_dtype = torch.float64 if dtype == torch.float64 else torch.float32
98
+ idx = torch.arange(num_points, device=device, dtype=compute_dtype)
99
+ inv_n = 1.0 / float(num_points)
100
+ z = 1.0 - 2.0 * ((idx + 0.5) * inv_n)
101
+ xy = torch.sqrt((1.0 - z * z).clamp_min(0.0))
102
+ theta = idx * 2.399963229728653
103
+ directions = torch.stack(
104
  [
105
+ xy * torch.cos(theta),
106
+ xy * torch.sin(theta),
107
+ z,
108
  ],
109
  dim=-1,
110
  )
111
+ radii = torch.pow((idx + 0.5) * inv_n, 1.0 / 3.0)
112
+ points = (directions * radii[:, None]).to(dtype=dtype)
113
+
114
+ if len(_DETERMINISTIC_UNIT_BALL_CACHE) >= 8:
115
+ _DETERMINISTIC_UNIT_BALL_CACHE.clear()
116
+ _DETERMINISTIC_UNIT_BALL_CACHE[cache_key] = points
117
+ return points
118
+
119
+
120
+ def _sample_points_in_sphere(center: torch.Tensor) -> torch.Tensor:
121
+ device = center.device
122
+ dtype = center.dtype
123
+
124
+ offsets = _deterministic_points_in_unit_ball(_FOV_NUM_POINTS, device, dtype) * _FOV_RADIUS
125
  return center[None, :3] + offsets
126
 
127
 
 
217
  return (direction_sim * moment_sim).max(dim=1).values.to(dtype=torch.float32)
218
 
219
 
220
+ def _empty_fov_candidate_pool(device) -> dict[str, torch.Tensor]:
221
+ return {
222
+ "candidates_t": torch.empty((0,), device=device, dtype=torch.long),
223
+ "candidate_poses": torch.empty((0, 5), device=device, dtype=torch.float32),
224
+ "inside": torch.zeros((0, 0), device=device, dtype=torch.bool),
225
+ "fov_values": torch.empty((0,), device=device, dtype=torch.float32),
226
+ "plucker": torch.empty((0,), device=device, dtype=torch.float32),
227
+ "gaps": torch.empty((0,), device=device, dtype=torch.long),
228
+ "positive_fov": torch.empty((0,), device=device, dtype=torch.bool),
229
+ }
230
+
231
+
232
+ def _build_fov_candidate_pool(
233
+ poses: np.ndarray,
234
+ candidates: np.ndarray,
235
+ target_positions: np.ndarray,
236
+ cfg,
237
+ *,
238
+ use_plucker: bool,
239
+ ) -> dict[str, torch.Tensor]:
240
+ """Build deterministic FOV/Plucker/coverage data for candidate frames."""
241
+
242
+ poses_t = _as_pose_tensor(poses)
243
+ device = poses_t.device
244
+ candidates = np.asarray(candidates, dtype=np.int64)
245
+ target_positions = np.asarray(target_positions, dtype=np.int64)
246
+ if len(candidates) == 0 or len(target_positions) == 0:
247
+ return _empty_fov_candidate_pool(device)
248
+
249
+ candidates = _pose_preselect(candidates, poses_t, target_positions, cfg)
250
+ inside, fov_values = _candidate_fov_masks(poses_t, candidates, target_positions, cfg)
251
+ candidates_t = torch.as_tensor(candidates, device=device, dtype=torch.long)
252
+ candidate_poses = poses_t.index_select(0, candidates_t)
253
+ target_t = torch.as_tensor(target_positions, device=device, dtype=torch.long)
254
+ if use_plucker:
255
+ plucker = _plucker_scores_tensor(candidate_poses, poses_t.index_select(0, target_t), cfg)
256
+ else:
257
+ plucker = torch.zeros((candidates_t.shape[0],), device=device, dtype=torch.float32)
258
+
259
+ first_target = torch.full_like(candidates_t, int(target_positions[0]))
260
+ return {
261
+ "candidates_t": candidates_t,
262
+ "candidate_poses": candidate_poses,
263
+ "inside": inside,
264
+ "fov_values": fov_values,
265
+ "plucker": plucker,
266
+ "gaps": first_target - candidates_t,
267
+ "positive_fov": inside.any(dim=1),
268
+ }
269
+
270
+
271
  def _empty_selection(counts: Mapping[str, int]) -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
272
  indices = {}
273
  masks = {}
 
362
  fov_threshold: float | None = None,
363
  min_total_coverage: float | None = None,
364
  use_plucker: bool = False,
365
+ fov_pool: dict[str, torch.Tensor] | None = None,
366
  ) -> np.ndarray:
367
+ if count <= 0:
368
  return np.empty((0,), dtype=np.int64)
369
 
370
+ if fov_pool is None:
371
+ if len(candidates) == 0 or len(target_positions) == 0:
372
+ return np.empty((0,), dtype=np.int64)
373
+ fov_pool = _build_fov_candidate_pool(poses, candidates, target_positions, cfg, use_plucker=use_plucker)
374
+
375
+ return _select_by_point_union_from_pool(
376
+ fov_pool,
377
+ count,
378
+ fov_threshold=fov_threshold,
379
+ min_total_coverage=min_total_coverage,
380
+ )
381
+
382
+
383
+ def _select_by_point_union_from_pool(
384
+ pool: dict[str, torch.Tensor],
385
+ count: int,
386
+ *,
387
+ fov_threshold: float | None = None,
388
+ min_total_coverage: float | None = None,
389
+ ) -> np.ndarray:
390
+ if count <= 0:
391
+ return np.empty((0,), dtype=np.int64)
392
+
393
+ candidates_t = pool["candidates_t"]
394
+ inside = pool["inside"]
395
+ if candidates_t.numel() == 0 or inside.shape[0] == 0 or inside.shape[1] == 0:
396
  return np.empty((0,), dtype=np.int64)
397
 
398
+ device = candidates_t.device
399
+ fov_values = pool["fov_values"]
400
+ plucker = pool["plucker"]
401
+ gaps = pool["gaps"]
402
+ valid = torch.ones((candidates_t.shape[0],), device=device, dtype=torch.bool)
403
  if fov_threshold is not None:
404
  valid &= fov_values >= float(fov_threshold)
405
  if not bool(valid.any()):
406
  return np.empty((0,), dtype=np.int64)
407
 
408
  valid_idx = torch.nonzero(valid, as_tuple=False).flatten()
409
+ candidates_t = candidates_t.index_select(0, valid_idx)
410
  inside = inside.index_select(0, valid_idx)
411
  fov_values = fov_values.index_select(0, valid_idx)
412
+ plucker = plucker.index_select(0, valid_idx)
413
+ gaps = gaps.index_select(0, valid_idx)
 
 
 
414
 
415
+ remaining = torch.ones((candidates_t.shape[0],), device=device, dtype=torch.bool)
416
+ covered = torch.zeros((inside.shape[1],), device=device, dtype=torch.bool)
417
  selected_rows = []
 
 
418
 
419
  for _ in range(count):
420
  gains = (inside & ~covered[None, :]).float().mean(dim=1)
 
434
  coverage = float(covered.float().mean().item()) if covered.numel() else 0.0
435
  if min_total_coverage is not None and coverage < float(min_total_coverage):
436
  return np.empty((0,), dtype=np.int64)
437
+ selected = candidates_t.index_select(0, torch.as_tensor(selected_rows, device=device, dtype=torch.long))
438
  return np.sort(selected.cpu().numpy().astype(np.int64))
439
 
440
 
tests/test_dememwm_latent_dataset.py CHANGED
@@ -206,6 +206,84 @@ class MemorySelectionTests(unittest.TestCase):
206
  self.assertEqual(indices["revisit"].tolist(), [0])
207
  self.assertEqual(masks["revisit"].tolist(), [True])
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  def test_training_revisit_uses_pose_similarity_threshold(self):
210
  poses = np.array(
211
  [
 
206
  self.assertEqual(indices["revisit"].tolist(), [0])
207
  self.assertEqual(masks["revisit"].tolist(), [True])
208
 
209
+ def test_sample_points_in_sphere_is_deterministic_without_torch_rand(self):
210
+ import datasets.video.memory_selection as memory_selection
211
+
212
+ center = torch.tensor([1.0, 2.0, 3.0, 0.0, 0.0], dtype=torch.float32)
213
+ with mock.patch.object(torch, "rand", side_effect=AssertionError("unexpected random FOV sampling")):
214
+ first = memory_selection._sample_points_in_sphere(center)
215
+ second = memory_selection._sample_points_in_sphere(center)
216
+
217
+ self.assertEqual(tuple(first.shape), (memory_selection._FOV_NUM_POINTS, 3))
218
+ self.assertTrue(torch.equal(first, second))
219
+
220
+ def test_fov_revisit_selection_is_deterministic_across_calls(self):
221
+ poses = np.array(
222
+ [
223
+ [0, 0, 0, 0, 0],
224
+ [0, 0, 0, 0, 180],
225
+ [0, 0, 0, 0, 180],
226
+ [0, 0, 0, 0, 180],
227
+ [0, 0, 0, 0, 180],
228
+ [0, 0, 0, 0, 0],
229
+ ],
230
+ dtype=np.float32,
231
+ )
232
+ cfg = _selection_cfg(
233
+ max_anchor_frames=0,
234
+ max_dynamic_frames=0,
235
+ max_revisit_frames=1,
236
+ fov_overlap_threshold=0.5,
237
+ local_context_exclusion_frames=1,
238
+ )
239
+
240
+ first_indices, first_masks = select_memory_indices(poses, np.array([5]), cfg, split="validation")
241
+ second_indices, second_masks = select_memory_indices(poses, np.array([5]), cfg, split="validation")
242
+
243
+ self.assertEqual(first_indices["revisit"].tolist(), [0])
244
+ self.assertEqual(second_indices["revisit"].tolist(), first_indices["revisit"].tolist())
245
+ self.assertEqual(second_masks["revisit"].tolist(), first_masks["revisit"].tolist())
246
+
247
+ def test_empty_fov_candidate_pool_returns_empty_selection(self):
248
+ import datasets.video.memory_selection as memory_selection
249
+
250
+ pool = memory_selection._build_fov_candidate_pool(
251
+ _poses(4),
252
+ np.empty((0,), dtype=np.int64),
253
+ np.array([3], dtype=np.int64),
254
+ _selection_cfg(),
255
+ use_plucker=True,
256
+ )
257
+ selected = memory_selection._select_by_point_union_from_pool(pool, count=1)
258
+
259
+ self.assertEqual(tuple(pool["candidates_t"].shape), (0,))
260
+ self.assertEqual(tuple(pool["candidate_poses"].shape), (0, 5))
261
+ self.assertEqual(tuple(pool["inside"].shape), (0, 0))
262
+ self.assertEqual(pool["positive_fov"].dtype, torch.bool)
263
+ self.assertEqual(selected.tolist(), [])
264
+
265
+ def test_validation_fov_revisit_selection_stays_causal(self):
266
+ poses = np.zeros((8, 5), dtype=np.float32)
267
+ poses[:, 0] = 100.0
268
+ poses[:, 4] = 180.0
269
+ poses[0] = np.asarray([0, 0, 0, 0, 0], dtype=np.float32)
270
+ poses[5] = np.asarray([0, 0, 0, 0, 0], dtype=np.float32)
271
+ poses[6] = np.asarray([0, 0, 0, 0, 0], dtype=np.float32)
272
+ cfg = _selection_cfg(
273
+ causal=False,
274
+ max_anchor_frames=0,
275
+ max_dynamic_frames=0,
276
+ max_revisit_frames=2,
277
+ fov_overlap_threshold=0.5,
278
+ local_context_exclusion_frames=1,
279
+ )
280
+
281
+ indices, masks = select_memory_indices(poses, np.array([5]), cfg, split="validation")
282
+
283
+ selected = indices["revisit"][masks["revisit"]]
284
+ self.assertEqual(selected.tolist(), [0])
285
+ self.assertTrue(np.all(selected < 5))
286
+
287
  def test_training_revisit_uses_pose_similarity_threshold(self):
288
  poses = np.array(
289
  [