viobench 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. viobench-0.0.1/PKG-INFO +58 -0
  2. viobench-0.0.1/README.md +23 -0
  3. viobench-0.0.1/pyproject.toml +44 -0
  4. viobench-0.0.1/setup.cfg +4 -0
  5. viobench-0.0.1/tests/test_datasets.py +330 -0
  6. viobench-0.0.1/tests/test_debug.py +142 -0
  7. viobench-0.0.1/tests/test_dnn.py +71 -0
  8. viobench-0.0.1/tests/test_evaluation.py +168 -0
  9. viobench-0.0.1/tests/test_fuel.py +164 -0
  10. viobench-0.0.1/tests/test_generic_host.py +60 -0
  11. viobench-0.0.1/tests/test_metrics.py +116 -0
  12. viobench-0.0.1/tests/test_openvins.py +222 -0
  13. viobench-0.0.1/tests/test_openvins_math.py +27 -0
  14. viobench-0.0.1/tests/test_orbslam3.py +164 -0
  15. viobench-0.0.1/tests/test_packaging.py +44 -0
  16. viobench-0.0.1/tests/test_regularize.py +101 -0
  17. viobench-0.0.1/tests/test_sandbox.py +375 -0
  18. viobench-0.0.1/tests/test_server.py +72 -0
  19. viobench-0.0.1/tests/test_video.py +168 -0
  20. viobench-0.0.1/tests/test_vins_marginalization.py +26 -0
  21. viobench-0.0.1/tests/test_vins_mono.py +120 -0
  22. viobench-0.0.1/tests/test_workspace.py +53 -0
  23. viobench-0.0.1/viobench/__init__.py +26 -0
  24. viobench-0.0.1/viobench/__main__.py +5 -0
  25. viobench-0.0.1/viobench/solution/examples/native/cpp_minimal/CMakeLists.txt +12 -0
  26. viobench-0.0.1/viobench/solution/examples/native/cpp_minimal/README.md +10 -0
  27. viobench-0.0.1/viobench/solution/examples/native/cpp_minimal/solution.cpp +97 -0
  28. viobench-0.0.1/viobench/solution/examples/python/minimal/solution.py +29 -0
  29. viobench-0.0.1/viobench/solution/runtime/native/README.md +22 -0
  30. viobench-0.0.1/viobench/solution/runtime/native/build.sh +18 -0
  31. viobench-0.0.1/viobench/solution/runtime/native/viobench_abi.h +51 -0
  32. viobench-0.0.1/viobench/solution/runtime/native/viobench_debug_main.c +136 -0
  33. viobench-0.0.1/viobench/solution/runtime/native/viobench_reactor.cmake +24 -0
  34. viobench-0.0.1/viobench/solution/runtime/python/README.md +55 -0
  35. viobench-0.0.1/viobench/solution/runtime/python/build.sh +9 -0
  36. viobench-0.0.1/viobench/solution/runtime/python/bundler/__init__.py +10 -0
  37. viobench-0.0.1/viobench/solution/runtime/python/bundler/bundle.py +159 -0
  38. viobench-0.0.1/viobench/solution/runtime/python/bundler/embed.py +75 -0
  39. viobench-0.0.1/viobench/solution/runtime/python/cpython/build-wasm.sh +137 -0
  40. viobench-0.0.1/viobench/solution/runtime/python/cpython/patches/numpy.patch +99 -0
  41. viobench-0.0.1/viobench/solution/runtime/python/cpython/patches/opencv.patch +54 -0
  42. viobench-0.0.1/viobench/solution/runtime/python/cpython/python.sh +5 -0
  43. viobench-0.0.1/viobench/solution/runtime/python/cpython/scripts/collect_numpy.py +33 -0
  44. viobench-0.0.1/viobench/solution/runtime/python/cpython/scripts/freeze.py +38 -0
  45. viobench-0.0.1/viobench/solution/runtime/python/cpython/scripts/link.py +69 -0
  46. viobench-0.0.1/viobench/solution/runtime/python/cpython/smoke_vo.py +44 -0
  47. viobench-0.0.1/viobench/solution/runtime/python/cpython/test.sh +39 -0
  48. viobench-0.0.1/viobench/solution/runtime/python/guest/viobench_guest.py +167 -0
  49. viobench-0.0.1/viobench/solution/runtime/python/reactor.c +160 -0
  50. viobench-0.0.1/viobench/solution/toolchain/download_dependencies.sh +113 -0
  51. viobench-0.0.1/viobench/solution/workspace.py +86 -0
  52. viobench-0.0.1/viobench/tools/__init__.py +12 -0
  53. viobench-0.0.1/viobench/tools/abi/__init__.py +39 -0
  54. viobench-0.0.1/viobench/tools/abi/config.py +73 -0
  55. viobench-0.0.1/viobench/tools/abi/module.py +50 -0
  56. viobench-0.0.1/viobench/tools/abi/wasm_binary.py +224 -0
  57. viobench-0.0.1/viobench/tools/cli.py +334 -0
  58. viobench-0.0.1/viobench/tools/config.json +15 -0
  59. viobench-0.0.1/viobench/tools/datasets/__init__.py +11 -0
  60. viobench-0.0.1/viobench/tools/datasets/episode.py +220 -0
  61. viobench-0.0.1/viobench/tools/datasets/feed.py +34 -0
  62. viobench-0.0.1/viobench/tools/datasets/generate/__init__.py +1 -0
  63. viobench-0.0.1/viobench/tools/datasets/generate/hyperdrone_world.h.in +50 -0
  64. viobench-0.0.1/viobench/tools/datasets/generate/simulation.py +396 -0
  65. viobench-0.0.1/viobench/tools/datasets/regularize.py +87 -0
  66. viobench-0.0.1/viobench/tools/datasets/storage.py +288 -0
  67. viobench-0.0.1/viobench/tools/geometry.py +105 -0
  68. viobench-0.0.1/viobench/tools/runner/__init__.py +9 -0
  69. viobench-0.0.1/viobench/tools/runner/check.py +52 -0
  70. viobench-0.0.1/viobench/tools/runner/direct.py +157 -0
  71. viobench-0.0.1/viobench/tools/runner/engine.py +62 -0
  72. viobench-0.0.1/viobench/tools/runner/leaderboard.py +94 -0
  73. viobench-0.0.1/viobench/tools/runner/metrics.py +136 -0
  74. viobench-0.0.1/viobench/tools/runner/run.py +188 -0
  75. viobench-0.0.1/viobench/tools/runner/sandbox.py +263 -0
  76. viobench-0.0.1/viobench/tools/runner/score.py +59 -0
  77. viobench-0.0.1/viobench/tools/runner/video.py +104 -0
  78. viobench-0.0.1/viobench/tools/server/__init__.py +1 -0
  79. viobench-0.0.1/viobench/tools/server/app.py +128 -0
  80. viobench-0.0.1/viobench/tools/server/config.py +45 -0
  81. viobench-0.0.1/viobench/tools/server/store.py +159 -0
  82. viobench-0.0.1/viobench/tools/server/teams.py +30 -0
  83. viobench-0.0.1/viobench/tools/server/ui/app.js +461 -0
  84. viobench-0.0.1/viobench/tools/server/ui/index.html +76 -0
  85. viobench-0.0.1/viobench/tools/server/ui/style.css +101 -0
  86. viobench-0.0.1/viobench/tools/server/worker.py +86 -0
  87. viobench-0.0.1/viobench.egg-info/PKG-INFO +58 -0
  88. viobench-0.0.1/viobench.egg-info/SOURCES.txt +90 -0
  89. viobench-0.0.1/viobench.egg-info/dependency_links.txt +1 -0
  90. viobench-0.0.1/viobench.egg-info/entry_points.txt +2 -0
  91. viobench-0.0.1/viobench.egg-info/requires.txt +32 -0
  92. viobench-0.0.1/viobench.egg-info/top_level.txt +1 -0
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: viobench
3
+ Version: 0.0.1
4
+ Summary: Compute-aware relative state estimation challenge: run and score solution .wasm modules, episode datasets, submission server, and the recipes to build a solution
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: wasmtime==50.0.1
9
+ Requires-Dist: numpy>=1.26
10
+ Provides-Extra: data
11
+ Requires-Dist: opencv-python-headless>=4.9; extra == "data"
12
+ Requires-Dist: pyyaml>=6; extra == "data"
13
+ Provides-Extra: sim
14
+ Requires-Dist: opencv-python-headless>=4.9; extra == "sim"
15
+ Requires-Dist: pyyaml>=6; extra == "sim"
16
+ Requires-Dist: hyperdrone==0.1.10; extra == "sim"
17
+ Provides-Extra: train
18
+ Requires-Dist: torch; extra == "train"
19
+ Requires-Dist: onnx; extra == "train"
20
+ Provides-Extra: server
21
+ Requires-Dist: fastapi; extra == "server"
22
+ Requires-Dist: uvicorn; extra == "server"
23
+ Requires-Dist: python-multipart; extra == "server"
24
+ Requires-Dist: tzdata; extra == "server"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: opencv-python-headless>=4.9; extra == "dev"
28
+ Requires-Dist: pyyaml>=6; extra == "dev"
29
+ Requires-Dist: torch; extra == "dev"
30
+ Requires-Dist: onnx; extra == "dev"
31
+ Requires-Dist: fastapi; extra == "dev"
32
+ Requires-Dist: python-multipart; extra == "dev"
33
+ Requires-Dist: tzdata; extra == "dev"
34
+ Requires-Dist: httpx; extra == "dev"
35
+
36
+ # viobench: compute-aware relative state estimation
37
+
38
+ Estimate a flying camera's pose relative to its first frame, and its twist, from camera frames and IMU data. A submission is one
39
+ WebAssembly module; the evaluator meters its compute deterministically (Wasmtime fuel) and places it on an
40
+ accuracy-vs-compute curve.
41
+
42
+ | | |
43
+ |---|---|
44
+ | [`viobench/tools/`](viobench/tools/) | host side: `abi`, `datasets`, `runner`, `server`, `cli` |
45
+ | [`viobench/solution/`](viobench/solution/) | guest side: toolchain, runtimes, examples; what `viobench init` copies into a workspace |
46
+ | [`docs/`](docs/) | [TASK](docs/TASK.md), [STUDENT_GUIDE](docs/STUDENT_GUIDE.md), [DEBUGGING](docs/DEBUGGING.md), [ABI](docs/ABI.md), [RUNTIME](docs/RUNTIME.md) |
47
+ | [`tests/`](tests/) | pytest suite |
48
+
49
+ ```bash
50
+ python -m venv .venv && . .venv/bin/activate && pip install -e ".[dev,data]"
51
+ viobench build-runtime # viobench/solution/build/runtime/dist/runtime.wasm, 10–15 min once
52
+ pytest
53
+ viobench bundle viobench/solution/examples/python/klt_imu -o klt.wasm && viobench check klt.wasm
54
+ viobench evaluate klt.wasm data/val -o report-klt
55
+ viobench leaderboard report-* --plot leaderboard.png
56
+ ```
57
+
58
+ `viobench --help` lists all commands. Evaluation needs only wasmtime: no Docker, no GPU.
@@ -0,0 +1,23 @@
1
+ # viobench: compute-aware relative state estimation
2
+
3
+ Estimate a flying camera's pose relative to its first frame, and its twist, from camera frames and IMU data. A submission is one
4
+ WebAssembly module; the evaluator meters its compute deterministically (Wasmtime fuel) and places it on an
5
+ accuracy-vs-compute curve.
6
+
7
+ | | |
8
+ |---|---|
9
+ | [`viobench/tools/`](viobench/tools/) | host side: `abi`, `datasets`, `runner`, `server`, `cli` |
10
+ | [`viobench/solution/`](viobench/solution/) | guest side: toolchain, runtimes, examples; what `viobench init` copies into a workspace |
11
+ | [`docs/`](docs/) | [TASK](docs/TASK.md), [STUDENT_GUIDE](docs/STUDENT_GUIDE.md), [DEBUGGING](docs/DEBUGGING.md), [ABI](docs/ABI.md), [RUNTIME](docs/RUNTIME.md) |
12
+ | [`tests/`](tests/) | pytest suite |
13
+
14
+ ```bash
15
+ python -m venv .venv && . .venv/bin/activate && pip install -e ".[dev,data]"
16
+ viobench build-runtime # viobench/solution/build/runtime/dist/runtime.wasm, 10–15 min once
17
+ pytest
18
+ viobench bundle viobench/solution/examples/python/klt_imu -o klt.wasm && viobench check klt.wasm
19
+ viobench evaluate klt.wasm data/val -o report-klt
20
+ viobench leaderboard report-* --plot leaderboard.png
21
+ ```
22
+
23
+ `viobench --help` lists all commands. Evaluation needs only wasmtime: no Docker, no GPU.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "viobench"
7
+ version = "0.0.1"
8
+ description = "Compute-aware relative state estimation challenge: run and score solution .wasm modules, episode datasets, submission server, and the recipes to build a solution"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "wasmtime==50.0.1",
14
+ "numpy>=1.26",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ data = ["opencv-python-headless>=4.9", "pyyaml>=6"]
19
+ sim = ["opencv-python-headless>=4.9", "pyyaml>=6", "hyperdrone==0.1.10"]
20
+ train = ["torch", "onnx"]
21
+ server = ["fastapi", "uvicorn", "python-multipart", "tzdata"] # tzdata: the quota's Pacific-time days where the OS has no zoneinfo
22
+ dev = ["pytest", "opencv-python-headless>=4.9", "pyyaml>=6", "torch", "onnx", "fastapi", "python-multipart", "tzdata", "httpx"] # httpx: the server tests' client
23
+
24
+ [project.scripts]
25
+ viobench = "viobench.tools.cli:main"
26
+
27
+ # viobench.tools is the host side; viobench.solution is the guest side and consists of files, not modules, except
28
+ # workspace.py and the Python runtime's bundler, reached through implicit namespace packages (no __init__.py in solution/).
29
+ [tool.setuptools.packages.find]
30
+ include = ["viobench", "viobench.tools*", "viobench.solution", "viobench.solution.runtime", "viobench.solution.runtime.python", "viobench.solution.runtime.python.bundler"]
31
+ namespaces = true
32
+
33
+ [tool.setuptools.package-data]
34
+ "viobench.tools.datasets.generate" = ["*.h.in"]
35
+ "viobench.tools" = ["config.json"]
36
+ "viobench.tools.server" = ["ui/*"]
37
+ # the build recipe and examples that `viobench init` copies into new workspaces
38
+ "viobench.solution" = [
39
+ "toolchain/*", "runtime/native/*", "runtime/python/*", "runtime/python/guest/*", "runtime/python/cpython/*",
40
+ "runtime/python/cpython/patches/*", "runtime/python/cpython/scripts/*", "examples/python/minimal/*", "examples/native/cpp_minimal/*",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,330 @@
1
+ import gc
2
+ import zipfile
3
+
4
+ import numpy as np
5
+ import pytest
6
+
7
+ from viobench.tools.abi.config import make_config
8
+ from viobench.tools.datasets.episode import Episode, FrameSequence, interpolate_poses
9
+ from viobench.tools.datasets.storage import (
10
+ episode_file,
11
+ episodes,
12
+ list_episodes,
13
+ read_episode,
14
+ write_episode,
15
+ )
16
+ from viobench.tools.geometry import poses_to_matrices, quaternion_to_matrix
17
+
18
+
19
+ def synthetic_episode(n_frames=8, rate=20.0, imu_rate=200.0, width=32, height=24, channels=1, lead_in=0.0):
20
+ """lead_in: seconds of IMU (and ground truth) recorded before the first frame."""
21
+ rng = np.random.default_rng(0)
22
+ config = make_config(width=width, height=height, channels=channels, frame_rate_hz=rate, num_frames=n_frames)
23
+ T_body_camera = np.asarray(config["imu"]["T_imu_cam"]).reshape(4, 4)
24
+ duration = n_frames / rate
25
+ gt_t = np.arange(-lead_in, duration + 1 / imu_rate, 1 / imu_rate)
26
+ # smooth trajectory: circle with yaw
27
+ angle = 0.5 * gt_t
28
+ position = np.column_stack([np.cos(angle), np.sin(angle), 0.1 * gt_t])
29
+ quaternion = np.column_stack([np.zeros_like(angle), np.zeros_like(angle), np.sin(angle / 2), np.cos(angle / 2)])
30
+ ground_truth = np.column_stack([gt_t, position, quaternion])
31
+ imu_t = gt_t[1:]
32
+ imu = np.column_stack([imu_t, rng.normal(0, 0.1, (len(imu_t), 3)) + [0, 0, 9.81], rng.normal(0, 0.01, (len(imu_t), 3))])
33
+ frame_times = (np.arange(n_frames) + 1) / rate
34
+ frames = rng.integers(0, 256, (n_frames, height, width) + (() if channels == 1 else (channels,)), dtype=np.uint8)
35
+ return Episode("synthetic", config, frame_times, frames, imu, ground_truth, T_body_camera, {"condition": "test"})
36
+
37
+
38
+ def test_episode_windows_and_ground_truth():
39
+ episode = synthetic_episode()
40
+ episode.check_consistency()
41
+ windows = [imu for _, _, imu in episode.steps()]
42
+ assert len(windows) == 8
43
+ assert all(len(w) == 10 for w in windows[1:]) # 200 Hz / 20 Hz
44
+ assert windows[0][-1, 0] <= episode.frame_times[0] and all(w[0, 0] > t_prev for w, t_prev in zip(windows[1:], episode.frame_times[:-1]))
45
+ relative = episode.ground_truth_relative()
46
+ np.testing.assert_allclose(relative[0], [0, 0, 0, 0, 0, 0, 1], atol=1e-12)
47
+ # consistency with a direct computation at frame 3
48
+ T_wc = poses_to_matrices(episode.camera_poses_world())
49
+ expected = np.linalg.inv(T_wc[0]) @ T_wc[3]
50
+ np.testing.assert_allclose(poses_to_matrices(relative)[3], expected, atol=1e-12)
51
+
52
+
53
+ def test_ground_truth_twist_of_an_offset_camera():
54
+ """The synthetic body circles at 0.5 rad/s (yaw = angle), climbing at 0.1 m/s; the camera sits off the body origin, rotated."""
55
+ import dataclasses
56
+
57
+ episode = synthetic_episode()
58
+ R_bc = quaternion_to_matrix([0.1, -0.2, 0.3, 0.9])
59
+ T_body_camera = np.eye(4)
60
+ T_body_camera[:3, :3], T_body_camera[:3, 3] = R_bc, [0.05, -0.02, 0.1]
61
+ episode = dataclasses.replace(episode, T_body_camera=T_body_camera)
62
+ angle = 0.5 * episode.frame_times
63
+ velocity_world = np.column_stack([-0.5 * np.sin(angle), 0.5 * np.cos(angle), np.full_like(angle, 0.1)])
64
+ R_wb = quaternion_to_matrix(np.column_stack([np.zeros_like(angle), np.zeros_like(angle), np.sin(angle / 2), np.cos(angle / 2)]))
65
+ omega_body = np.array([0.0, 0.0, 0.5])
66
+ velocity_body = np.einsum("nji,nj->ni", R_wb, velocity_world) + np.cross(omega_body, T_body_camera[:3, 3]) # camera centre: v + w x r
67
+ expected = np.column_stack([velocity_body @ R_bc, np.tile(R_bc.T @ omega_body, (len(angle), 1))])
68
+ twist = episode.ground_truth_twist()
69
+ np.testing.assert_allclose(twist[:-1], expected[:-1], atol=1e-5)
70
+ np.testing.assert_allclose(twist[-1], expected[-1], atol=2e-3) # the ground truth ends at the last frame: one-sided there
71
+ state = episode.ground_truth_state()
72
+ assert state.shape == (8, 13)
73
+ np.testing.assert_array_equal(state[:, :7], episode.ground_truth_relative())
74
+
75
+
76
+ def test_first_imu_window_is_one_frame_interval():
77
+ episode = synthetic_episode(lead_in=1.5) # a recording may hold IMU from long before its first frame, simulated episodes barely
78
+ first = episode.imu_window(0)
79
+ assert len(first) == 10 and first[0, 0] > episode.frame_times[0] - episode.frame_interval
80
+ np.testing.assert_array_equal(episode.delivered_imu(), np.concatenate([imu for _, _, imu in episode.steps()]))
81
+
82
+
83
+ def test_interpolation_matches_samples():
84
+ times = np.array([0.0, 1.0, 2.0])
85
+ poses = np.array([[0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, np.sin(0.5), np.cos(0.5)], [2, 0, 0, 0, 0, np.sin(1.0), np.cos(1.0)]])
86
+ mid = interpolate_poses(times, poses, [0.5])[0]
87
+ assert mid[0] == pytest.approx(0.5)
88
+ np.testing.assert_allclose(quaternion_to_matrix(mid[3:]), quaternion_to_matrix([0, 0, np.sin(0.25), np.cos(0.25)]), atol=1e-12)
89
+ with pytest.raises(ValueError):
90
+ interpolate_poses(times, poses, [2.5])
91
+
92
+
93
+ def _pngs(path):
94
+ """The PNG members of an episode archive, in archive order (frame order)."""
95
+ with zipfile.ZipFile(path) as archive:
96
+ return [archive.read(name) for name in archive.namelist() if name.endswith(".png")]
97
+
98
+
99
+ def test_write_read_roundtrip(tmp_path):
100
+ episode = synthetic_episode()
101
+ path = write_episode(episode, tmp_path / "ep0.zip", origin_ns=1_400_000_000_000_000_000)
102
+ assert list_episodes(tmp_path) == [path] and list_episodes(path) == [path]
103
+ loaded = read_episode(path)
104
+ loaded.check_consistency()
105
+ np.testing.assert_array_equal(loaded.frames, episode.frames)
106
+ np.testing.assert_allclose(loaded.frame_times, episode.frame_times, atol=1e-9)
107
+ np.testing.assert_allclose(loaded.imu, episode.imu, rtol=1e-6, atol=1e-9)
108
+ np.testing.assert_allclose(loaded.ground_truth, episode.ground_truth, rtol=1e-6, atol=1e-9)
109
+ np.testing.assert_allclose(loaded.T_body_camera, episode.T_body_camera)
110
+ assert loaded.config == episode.config and loaded.meta["condition"] == "test" and loaded.meta["dataset"] == tmp_path.name
111
+ assert loaded.meta["origin_ns"] == 1_400_000_000_000_000_000 and loaded.meta["path"] == str(path)
112
+ with zipfile.ZipFile(path) as archive:
113
+ names = archive.namelist()
114
+ assert names[0] == "ep0/episode.json" and "ep0/mav0/cam0/sensor.yaml" in names and "ep0/mav0/imu0/sensor.yaml" in names
115
+ assert all(info.compress_type == zipfile.ZIP_STORED for info in archive.infolist())
116
+ # the same episode is the same archive, byte for byte: the sets are content-addressed
117
+ assert write_episode(episode, tmp_path / "again" / "ep0.zip", origin_ns=1_400_000_000_000_000_000).read_bytes() == path.read_bytes()
118
+ assert write_episode(loaded, tmp_path / "resaved" / "ep0.zip").read_bytes() == path.read_bytes() # keeps the origin, copies the PNGs
119
+ with pytest.raises(ValueError):
120
+ write_episode(episode, tmp_path / "ep0")
121
+
122
+
123
+ def test_unzipped_episode_reads_the_same(tmp_path):
124
+ episode = synthetic_episode(n_frames=4, width=16, height=12, channels=3)
125
+ path = write_episode(episode, tmp_path / "set" / "ep.zip", attachments={"trajectory.mp4": b"not really a video"})
126
+ assert episode_file(path, "trajectory.mp4") == b"not really a video" and episode_file(path, "notes.txt") is None
127
+ with zipfile.ZipFile(path) as archive:
128
+ archive.extractall(tmp_path / "unzipped")
129
+ directory = tmp_path / "unzipped" / "ep"
130
+ assert list_episodes(tmp_path / "unzipped") == [directory] and (directory / "mav0" / "cam0" / "data.csv").is_file()
131
+ unzipped = read_episode(directory)
132
+ np.testing.assert_array_equal(np.asarray(unzipped.frames), episode.frames)
133
+ assert unzipped.meta["dataset"] == "unzipped" and episode_file(directory, "trajectory.mp4") == b"not really a video"
134
+ assert [e.meta["dataset"] for e in episodes([tmp_path / "set", directory])] == ["set", "unzipped"]
135
+ repacked = write_episode(unzipped, tmp_path / "repacked" / "ep.zip", attachments={"trajectory.mp4": directory / "trajectory.mp4"})
136
+ assert repacked.read_bytes() == path.read_bytes()
137
+
138
+
139
+ @pytest.mark.parametrize("channels", [1, 3])
140
+ def test_write_episode_copies_existing_pngs(tmp_path, channels):
141
+ episode = synthetic_episode(n_frames=4, width=16, height=12, channels=channels)
142
+ source = read_episode(write_episode(episode, tmp_path / "src.zip"))
143
+ assert isinstance(source.frames, FrameSequence) and source.frames.encoded is not None
144
+ copy = write_episode(source, tmp_path / "dst.zip")
145
+ assert _pngs(copy) == _pngs(tmp_path / "src.zip")
146
+ np.testing.assert_array_equal(np.asarray(read_episode(copy).frames), episode.frames)
147
+ modified = read_episode(copy, frames="memory")
148
+ modified.frames[:] = 0
149
+ write_episode(modified, copy)
150
+ np.testing.assert_array_equal(np.asarray(source.frames), episode.frames)
151
+ assert not np.asarray(read_episode(copy).frames).any()
152
+ write_episode(source, copy)
153
+ np.testing.assert_array_equal(np.asarray(read_episode(copy).frames), episode.frames)
154
+
155
+
156
+ @pytest.mark.parametrize("origin_ns", [0, 1_400_000_000_000_000_000])
157
+ def test_write_episode_back_to_its_own_archive(tmp_path, origin_ns):
158
+ episode = synthetic_episode(n_frames=4, width=16, height=12)
159
+ path = write_episode(episode, tmp_path / "episode.zip", origin_ns=origin_ns)
160
+ original_pngs = _pngs(path)
161
+ loaded = read_episode(path) # streams its frames from the archive that is about to be replaced
162
+ loaded.meta["condition"] = "corrected"
163
+ write_episode(loaded, path)
164
+ assert _pngs(path) == original_pngs
165
+ saved = read_episode(path)
166
+ assert saved.meta["condition"] == "corrected" and saved.meta["origin_ns"] == origin_ns
167
+ np.testing.assert_array_equal(np.asarray(saved.frames), episode.frames)
168
+ np.testing.assert_array_equal(np.asarray(loaded.frames), episode.frames) # the open reader still sees the archive it opened
169
+
170
+
171
+ def test_write_episode_exports_spool_and_survives_cleanup(tmp_path):
172
+ from viobench.tools.datasets.generate.simulation import Spool, spooled_frames
173
+ from viobench.tools.datasets.storage import encode_png
174
+
175
+ episode = synthetic_episode(n_frames=4, width=16, height=12, channels=3) # the simulator spools RGB
176
+ expected = episode.frames.copy()
177
+ spool = Spool(tmp_path / "spool")
178
+ spool_path = spool.path
179
+ paths = [spool_path / f"{index}.png" for index in range(len(expected))]
180
+ for png, frame in zip(paths, expected):
181
+ png.write_bytes(encode_png(frame))
182
+ spooled = [png.read_bytes() for png in paths]
183
+ episode.frames = spooled_frames(paths, expected.shape[1:], spool)
184
+ mapped = episode.frames.map(np.copy)
185
+ assert mapped.encoded is None
186
+ del mapped, spool
187
+ destination = write_episode(episode, tmp_path / "dst.zip")
188
+ assert _pngs(destination) == spooled # copied byte for byte, not re-encoded
189
+ write_episode(episode, destination) # exporting again replaces the archive
190
+ np.testing.assert_array_equal(np.asarray(episode.frames), expected)
191
+ del episode
192
+ gc.collect()
193
+ assert not spool_path.exists()
194
+ np.testing.assert_array_equal(np.asarray(read_episode(destination).frames), expected)
195
+
196
+
197
+ @pytest.mark.parametrize("frames", ["lazy", "memory"])
198
+ def test_failed_frame_write_preserves_destination(tmp_path, monkeypatch, frames):
199
+ from viobench.tools.datasets import storage
200
+
201
+ episode = synthetic_episode(n_frames=4, width=16, height=12)
202
+ source = read_episode(write_episode(episode, tmp_path / "src.zip"), frames=frames)
203
+ episode.frames[:] = 0
204
+ destination = write_episode(episode, tmp_path / "dst.zip")
205
+ before = destination.read_bytes()
206
+
207
+ def fail(*args):
208
+ raise OSError("frame unavailable")
209
+
210
+ if frames == "lazy":
211
+ source.frames.encoded = fail # the stored PNG cannot be read
212
+ else:
213
+ monkeypatch.setattr(storage, "encode_png", fail) # the frame cannot be encoded
214
+ with pytest.raises(OSError, match="frame unavailable"):
215
+ write_episode(source, destination)
216
+ assert destination.read_bytes() == before
217
+ assert sorted(path.name for path in tmp_path.iterdir()) == ["dst.zip", "src.zip"] # no partial archive left behind
218
+
219
+
220
+ def test_simulation_spool_lives_as_long_as_its_frames(tmp_path):
221
+ from viobench.tools.datasets.generate.simulation import Spool, spooled_frames
222
+
223
+ spool = Spool(tmp_path / "spool")
224
+ path = spool.path
225
+ assert path.is_dir() and path.parent == tmp_path / "spool"
226
+ frames = spooled_frames([], (2, 2, 3), spool)
227
+ del spool
228
+ gc.collect()
229
+ assert path.is_dir(), "the frame sequence keeps the spool alive"
230
+ del frames
231
+ gc.collect()
232
+ assert not path.exists(), "the last reference removes the directory"
233
+
234
+
235
+ def test_sim_config_scales_with_duration(tmp_path, monkeypatch):
236
+ from viobench.tools.datasets.generate.simulation import SimConfig
237
+
238
+ monkeypatch.setenv("VIOBENCH_CACHE", str(tmp_path))
239
+ minute = SimConfig()
240
+ assert minute.duration_seconds == 60.0 and minute.step_count(200.0) == 12000 and minute.num_waypoints == 48
241
+ assert SimConfig(duration_seconds=10).num_waypoints == 8 and SimConfig(duration_seconds=10, waypoints=5).num_waypoints == 5
242
+ header = minute.render_header(200.0).read_text()
243
+ assert "NUM_WAYPOINTS = 48;" in header and "INITIALIZATION_HOLD_STEPS = 200;" in header and "@" not in header
244
+ assert SimConfig(duration_seconds=30).render_header(200.0) != minute.render_header(200.0) # a different route length is a different JIT key
245
+
246
+
247
+
248
+ def test_simulate_runs_scene_by_scene_and_names_episodes_by_order(tmp_path, monkeypatch):
249
+ """Rounds of sim.instances parallel flights per scene: crashed flights are dropped, the surplus of the last round is
250
+ discarded, survivors are named {name}-{scene}-{index:04d} by order within the scene, and a scene whose flights
251
+ keep crashing is given up with an error."""
252
+ import sys
253
+ import types
254
+
255
+ from viobench.tools.datasets.generate import simulation
256
+
257
+ monkeypatch.setenv("VIOBENCH_CACHE", str(tmp_path))
258
+ environments = []
259
+
260
+ class FakeHyperDrone:
261
+ def __init__(self, references, config=None, seed=0, parameters=None):
262
+ self.reference, self.seed, self.dt, self.closed = references[0], seed, 0.005, False
263
+ environments.append(self)
264
+
265
+ def close(self):
266
+ self.closed = True
267
+
268
+ monkeypatch.setitem(sys.modules, "hyperdrone", types.SimpleNamespace(HyperDrone=FakeHyperDrone, Config=lambda **kwargs: kwargs))
269
+ crashes = {("B", 7, 0)} # (scene, seed, instance) flights that crash; every flight in scene C crashes
270
+ rounds = []
271
+
272
+ def fake_round(env, sim, seed, scene_name, camera, name, log, progress=(0, None)):
273
+ rounds.append((scene_name, seed, progress))
274
+ for instance in range(sim.instances):
275
+ if (scene_name, seed, instance) in crashes or scene_name == "C":
276
+ continue
277
+ episode = synthetic_episode(n_frames=2)
278
+ episode.name = episode.config["episode"]["name"] = f"{name}-{scene_name}-{seed:05d}-{instance:02d}" # the provisional name
279
+ episode.meta.update(scene=scene_name, seed=seed, instance=instance)
280
+ yield episode
281
+
282
+ monkeypatch.setattr(simulation, "_run_batch", fake_round)
283
+ sim = simulation.SimConfig(instances=2)
284
+
285
+ episodes = list(simulation.simulate(sim, {"A": "ref-a", "B": "ref-b"}, 3, seed=5, name="sim"))
286
+ assert [episode.name for episode in episodes] == ["sim-A-0000", "sim-A-0001", "sim-A-0002", "sim-B-0000", "sim-B-0001", "sim-B-0002"]
287
+ assert all(episode.config["episode"]["name"] == episode.name for episode in episodes)
288
+ # A's second round has one surplus flight, B's first round one crashed flight: neither is an episode
289
+ assert [(episode.meta["seed"], episode.meta["instance"]) for episode in episodes] == [(5, 0), (5, 1), (6, 0), (7, 1), (8, 0), (8, 1)]
290
+ assert rounds == [("A", 5, (0, 3)), ("A", 6, (2, 3)), ("B", 7, (0, 3)), ("B", 8, (1, 3))]
291
+ assert [(env.reference, env.seed) for env in environments] == [("ref-a", 5), ("ref-b", 7)]
292
+ assert all(env.closed for env in environments)
293
+
294
+ generator = simulation.simulate(sim, {"A": "ref-a", "C": "ref-c"}, 1, seed=0)
295
+ assert next(generator).name == "sim-A-0000"
296
+ with pytest.raises(RuntimeError, match=r"C: 0 of 1 episodes after 4 flights \(4 crashed\)"):
297
+ next(generator)
298
+ assert environments[-1].reference == "ref-c" and environments[-1].closed
299
+ with pytest.raises(ValueError):
300
+ list(simulation.simulate(sim, {"A": "ref-a"}, 0))
301
+
302
+ def test_adapt_always_delivers_rgb():
303
+ episode = synthetic_episode(n_frames=3, width=320, height=240) # a grayscale-only set
304
+ config, frames = episode.adapt()
305
+ assert frames.shape == (3, 240, 320, 3)
306
+ for channel in range(3):
307
+ np.testing.assert_array_equal(frames[..., channel], episode.frames) # replicated, and nothing announces it
308
+ assert set(config) == {"width", "height", "fx", "fy", "cx", "cy", "T_cam_imu"}
309
+ np.testing.assert_allclose(np.asarray(config["T_cam_imu"]) @ episode.T_body_camera, np.eye(4), atol=1e-12)
310
+ rgb = synthetic_episode(n_frames=3, width=320, height=240, channels=3)
311
+ assert rgb.adapt()[1] is rgb.frames # RGB at the stored resolution passes through untouched
312
+
313
+
314
+ def test_adapt_rescales_frames_and_intrinsics():
315
+ episode = synthetic_episode(n_frames=2, width=640, height=480)
316
+ config, frames = episode.adapt(resolution=(320, 240))
317
+ assert frames.shape == (2, 240, 320, 3) and (config["width"], config["height"]) == (320, 240)
318
+ assert config["fx"] == pytest.approx(episode.config["camera"]["fx"] / 2)
319
+ assert config["cx"] == pytest.approx((episode.config["camera"]["cx"] + 0.5) / 2 - 0.5)
320
+ # a frame-centre ray keeps its direction: the principal point stays at the image centre
321
+ assert config["cx"] == pytest.approx(320 / 2 - 0.5) and config["cy"] == pytest.approx(240 / 2 - 0.5)
322
+ small = synthetic_episode(n_frames=2, width=320, height=240)
323
+ config, frames = small.adapt(resolution=(640, 480))
324
+ assert frames.shape == (2, 480, 640, 3) and (config["width"], config["height"]) == (640, 480)
325
+ assert config["fx"] == pytest.approx(small.config["camera"]["fx"] * 2)
326
+ assert config["cx"] == pytest.approx((small.config["camera"]["cx"] + 0.5) * 2 - 0.5)
327
+ with pytest.raises(ValueError, match="resolution must be one of"):
328
+ episode.adapt(resolution=(64, 48))
329
+ with pytest.raises(ValueError, match="sets must be stored at"):
330
+ synthetic_episode(n_frames=2, width=64, height=48).adapt(resolution=(320, 240))
@@ -0,0 +1,142 @@
1
+ """The out-of-sandbox debugging facilities: `viobench debug` (Python, in-process), `viobench feed` + the native host
2
+ `debug` executable, `viobench score`."""
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ from viobench.tools.cli import main as cli
12
+ from viobench.tools.datasets.feed import write_feed
13
+ from viobench.tools.datasets.storage import write_episode
14
+ from viobench.tools.runner import direct
15
+ from viobench.tools.runner.score import load_estimates, score
16
+ from tests.test_datasets import synthetic_episode
17
+
18
+ REPO = Path(__file__).resolve().parent.parent
19
+ EXAMPLES = REPO / "viobench" / "solution" / "examples"
20
+
21
+
22
+ def test_direct_run_matches_the_sandbox(engine, runtime_path, tmp_path):
23
+ """dead_reckoning on the host and inside the runtime give the same poses (same code, same inputs, host numpy)."""
24
+ from viobench.solution.runtime.python.bundler.bundle import bundle
25
+ from viobench.tools.runner.run import evaluate
26
+
27
+ episode = synthetic_episode(n_frames=30, width=320, height=240) # 1.5 s: dead reckoning calibrates on the first 0.5 s (identity), then estimates
28
+ dataset = tmp_path / "set"
29
+ write_episode(episode, dataset / f"{episode.name}.zip")
30
+ solution = direct.PythonSolution(EXAMPLES / "python" / "dead_reckoning")
31
+ assert solution.resolution == (320, 240)
32
+ [(_, estimates, metrics)] = direct.run(solution, [dataset], output_dir=tmp_path / "direct", log=lambda *a: None)
33
+ assert estimates.shape == (30, 13) and metrics.held == 0
34
+ np.testing.assert_array_equal(estimates[0], [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0])
35
+ assert np.abs(estimates[-1, 7:]).max() > 0 # dead reckoning reports its twist once it has calibrated
36
+ written = load_estimates(tmp_path / "direct" / "set" / episode.name / "estimate.tum", episode.frame_times)
37
+ np.testing.assert_allclose(written, estimates, atol=1e-8)
38
+
39
+ wasm = tmp_path / "dr.wasm"
40
+ bundle(EXAMPLES / "python" / "dead_reckoning", wasm, runtime=runtime_path)
41
+ evaluate(wasm, [dataset], output_dir=tmp_path / "report", engine=engine, log=lambda *a: None)
42
+ sandboxed = load_estimates(tmp_path / "report" / "logs" / "set" / episode.name / "estimate.tum", episode.frame_times)
43
+ np.testing.assert_allclose(estimates, sandboxed, atol=1e-6)
44
+
45
+
46
+ def test_direct_frame_width_and_host_solution_bundle(solution_dir, tmp_path):
47
+ """frame_width picks the resolution; solution.py may re-export a class from elsewhere; data files next to the code come through `solution_bundle`."""
48
+ directory = solution_dir(
49
+ "from estimator import Est as Solution\n",
50
+ {
51
+ "estimator.py": (
52
+ "import numpy as np\nimport solution_bundle as bundle\n"
53
+ "class Est:\n"
54
+ " frame_width = 640\n"
55
+ " def __init__(self, config):\n"
56
+ " assert config['width'] == 640 and 'channels' not in config\n"
57
+ " self.offset = np.load(bundle.open('data/offset.npy'))\n"
58
+ " assert bundle.exists('data/offset.npy') and bundle.listdir('data') == ['offset.npy'] and bundle.read_text('note.txt') == 'hi'\n"
59
+ " def step(self, t, frame, imu):\n"
60
+ " assert frame.shape == (480, 640, 3)\n"
61
+ " return np.concatenate([self.offset, [0, 0, 0, 1], np.zeros(6)])\n"
62
+ ),
63
+ "note.txt": "hi",
64
+ },
65
+ name="vga",
66
+ )
67
+ import io
68
+
69
+ buffer = io.BytesIO()
70
+ np.save(buffer, np.array([1.0, 2.0, 3.0]))
71
+ (directory / "data").mkdir()
72
+ (directory / "data" / "offset.npy").write_bytes(buffer.getvalue())
73
+ solution = direct.PythonSolution(directory)
74
+ assert solution.resolution == (640, 480)
75
+ episode = synthetic_episode(n_frames=3, width=640, height=480) # grayscale: delivered as RGB like the sandbox does
76
+ steps = list(direct.trace(solution, episode))
77
+ assert len(steps) == 3 and steps[0].frame.shape == (480, 640, 3)
78
+ np.testing.assert_allclose(steps[-1].pose[:3], [1.0, 2.0, 3.0])
79
+
80
+
81
+ def test_direct_cli(tmp_path, capsys):
82
+ episode = synthetic_episode(n_frames=4, width=320, height=240)
83
+ write_episode(episode, tmp_path / "set" / f"{episode.name}.zip")
84
+ assert cli(["debug", str(EXAMPLES / "python" / "minimal"), str(tmp_path / "set"), "-o", str(tmp_path / "out")]) == 0
85
+ assert "ATE" in capsys.readouterr().out
86
+ assert (tmp_path / "out" / "set" / episode.name / "estimate.tum").is_file()
87
+ assert (tmp_path / "out" / "set" / episode.name / "twist.txt").is_file()
88
+ from tests.test_video import read_video
89
+
90
+ fps, frames = read_video(tmp_path / "out" / "set" / episode.name / "trajectory.mp4")
91
+ assert fps == pytest.approx(20.0) and len(frames) == episode.num_frames
92
+
93
+
94
+ def test_feed_and_score(tmp_path, capsys):
95
+ episode = synthetic_episode(n_frames=6, width=640, height=480, lead_in=1.0)
96
+ write_episode(episode, tmp_path / "set" / f"{episode.name}.zip")
97
+ feed = write_feed(episode, tmp_path / "feed", resolution=(320, 240))
98
+ assert (feed / "shape.txt").read_text().split() == ["6", "240", "320", "3", "60"] # IMU: six windows of one frame interval
99
+ frames = np.fromfile(feed / "frames.u8", dtype=np.uint8).reshape(6, 240, 320, 3)
100
+ assert frames.std() > 0
101
+ config = json.loads((feed / "config.json").read_text())
102
+ assert config["width"] == 320 and "channels" not in config
103
+ imu = np.fromfile(feed / "imu.f64").reshape(-1, 7)
104
+ np.testing.assert_allclose(imu, episode.delivered_imu())
105
+ gt = np.fromfile(feed / "gt.f64").reshape(6, 7)
106
+ np.testing.assert_allclose(gt, episode.ground_truth_relative())
107
+ # a perfect estimate scores zero; a frame missing from the files repeats the previous pose (identity for the first)
108
+ tum = tmp_path / "perfect.tum"
109
+ np.savetxt(tum, np.column_stack([episode.frame_times, gt]), fmt="%.12g")
110
+ np.savetxt(tmp_path / "twist.txt", np.column_stack([episode.frame_times, episode.ground_truth_twist()]), fmt="%.12g")
111
+ metrics = score(tmp_path / "set" / f"{episode.name}.zip", tum)
112
+ assert metrics.ate_rmse < 1e-9 and metrics.velocity_rmse < 1e-6 and metrics.held == 0
113
+ assert "ATE 0.000" in capsys.readouterr().out
114
+ rows = np.column_stack([episode.frame_times, gt])[1:] # first frame missing
115
+ np.savetxt(tum, rows, fmt="%.12g")
116
+ assert np.isnan(load_estimates(tum, episode.frame_times)[0, :7]).all()
117
+ assert score(tmp_path / "set" / f"{episode.name}.zip", tum, log=lambda *a: None).held == 1
118
+ assert cli(["score", str(tmp_path / "set" / f"{episode.name}.zip"), str(tum)]) == 0
119
+
120
+
121
+ @pytest.mark.skipif(shutil.which("cmake") is None or (shutil.which("cc") is None and shutil.which("gcc") is None and shutil.which("clang") is None),
122
+ reason="needs cmake and a host C/C++ compiler")
123
+ def test_native_host_debug_build(tmp_path):
124
+ """cpp_minimal configured for the host builds `debug`, which replays a feed and writes identity poses."""
125
+ source = EXAMPLES / "native" / "cpp_minimal"
126
+ build = tmp_path / "build-host"
127
+ configure = subprocess.run(["cmake", "-S", str(source), "-B", str(build)], capture_output=True, text=True)
128
+ assert configure.returncode == 0, configure.stderr
129
+ assert "debug" in configure.stdout
130
+ compile_ = subprocess.run(["cmake", "--build", str(build)], capture_output=True, text=True)
131
+ assert compile_.returncode == 0, compile_.stderr
132
+ episode = synthetic_episode(n_frames=5, width=320, height=240)
133
+ write_episode(episode, tmp_path / "set" / f"{episode.name}.zip")
134
+ feed = write_feed(episode, tmp_path / "feed")
135
+ tum = tmp_path / "estimate.tum"
136
+ run = subprocess.run([str(build / "debug"), str(feed), str(tum)], capture_output=True, text=True)
137
+ assert run.returncode == 0, run.stderr
138
+ assert "5/5 frames ok" in run.stdout
139
+ estimates = load_estimates(tum, episode.frame_times)
140
+ np.testing.assert_allclose(estimates, np.tile([0, 0, 0, 0, 0, 0, 1.0, 0, 0, 0, 0, 0, 0], (5, 1)))
141
+ metrics = score(tmp_path / "set" / f"{episode.name}.zip", tum, log=lambda *a: None)
142
+ assert metrics.held == 0