simview 6.1.2__tar.gz → 6.2.0__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.
- {simview-6.1.2/simview.egg-info → simview-6.2.0}/PKG-INFO +1 -1
- {simview-6.1.2 → simview-6.2.0}/pyproject.toml +1 -1
- {simview-6.1.2 → simview-6.2.0}/simview/__main__.py +39 -7
- {simview-6.1.2 → simview-6.2.0}/simview/columnar.py +46 -19
- {simview-6.1.2 → simview-6.2.0}/simview/diff.py +59 -23
- {simview-6.1.2 → simview-6.2.0}/simview/info.py +41 -1
- {simview-6.1.2 → simview-6.2.0}/simview/live.py +19 -8
- {simview-6.1.2 → simview-6.2.0}/simview/merge.py +83 -19
- {simview-6.1.2 → simview-6.2.0}/simview/model.py +230 -36
- {simview-6.1.2 → simview-6.2.0}/simview/remote.py +7 -2
- {simview-6.1.2 → simview-6.2.0}/simview/scene.py +174 -35
- {simview-6.1.2 → simview-6.2.0}/simview/server.py +84 -24
- {simview-6.1.2 → simview-6.2.0}/simview/state.py +22 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/css/controls.css +12 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/SimView.js +74 -26
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/AnimationController.js +72 -16
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/BatchManager.js +16 -4
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/InteractionController.js +22 -4
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/Scene.js +15 -1
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/StateStore.js +57 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/WindowedField.js +16 -2
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/config.js +2 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/Body.js +42 -16
- simview-6.2.0/simview/static/js/objects/Polyline.js +110 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/StaticObject.js +24 -1
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/Terrain.js +55 -21
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/colormap.js +12 -1
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/BatchLegend.js +17 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/BodyStateWindow.js +4 -1
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/Controls.js +40 -8
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/ErrorMetrics.js +42 -25
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/Legend.js +12 -4
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/PlaybackControls.js +25 -12
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/ScalarPlotter.js +54 -16
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/TerrainProfile.js +10 -13
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/chartControls.js +1 -1
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/blobCodec.js +7 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/episodes.js +33 -9
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/errorMath.js +21 -13
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/interpolate.js +4 -4
- simview-6.2.0/simview/static/js/utils/scalarSeries.js +26 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/terrainSample.js +6 -11
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/viewState.js +13 -4
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/viewerDefaults.js +2 -0
- simview-6.2.0/simview/static/js/utils/visibleRanges.js +7 -0
- {simview-6.1.2 → simview-6.2.0}/simview/terrain.py +24 -15
- {simview-6.1.2 → simview-6.2.0}/simview/utils.py +21 -3
- {simview-6.1.2 → simview-6.2.0}/simview/view.py +35 -11
- {simview-6.1.2 → simview-6.2.0/simview.egg-info}/PKG-INFO +1 -1
- {simview-6.1.2 → simview-6.2.0}/simview.egg-info/SOURCES.txt +3 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_cli.py +52 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_columnar_ondisk.py +43 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_columnar_states.py +60 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_contacts.py +9 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_diff.py +44 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_episodes.py +7 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_info.py +39 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_live.py +89 -12
- {simview-6.1.2 → simview-6.2.0}/tests/test_merge.py +119 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_remote.py +12 -1
- {simview-6.1.2 → simview-6.2.0}/tests/test_roundtrip.py +59 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_scene.py +195 -1
- {simview-6.1.2 → simview-6.2.0}/tests/test_server.py +103 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_show.py +13 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_terrain_query.py +42 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_utils.py +21 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_view.py +35 -0
- {simview-6.1.2 → simview-6.2.0}/LICENSE +0 -0
- {simview-6.1.2 → simview-6.2.0}/MANIFEST.in +0 -0
- {simview-6.1.2 → simview-6.2.0}/README.md +0 -0
- {simview-6.1.2 → simview-6.2.0}/setup.cfg +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/__init__.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/launcher.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/py.typed +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/render.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/main.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/similarity.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/utils.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/AnalysisPanel.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/batchPresets.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/batchVisibility.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/blobWindow.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/bodyTransforms.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/cameraRange.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/csv.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/keyboard.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/liveFollow.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/uplot.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/lib/js-colormaps.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/three.core.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/three.module.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/lib/uPlot.esm.js +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/lib/uPlot.min.css +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/textures/contacts/red-cross0.png +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/static/textures/points/ball1.png +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview/templates/index.html +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview.egg-info/dependency_links.txt +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview.egg-info/entry_points.txt +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview.egg-info/requires.txt +0 -0
- {simview-6.1.2 → simview-6.2.0}/simview.egg-info/top_level.txt +0 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_batch_selection.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_embed_defaults.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_launcher.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_pointcloud_features.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_terrain.py +0 -0
- {simview-6.1.2 → simview-6.2.0}/tests/test_trajectory.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simview
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.2.0
|
|
4
4
|
Summary: THREE.js-based simulation visualization tool
|
|
5
5
|
Author-email: Jan Vlk <vlkjan6@fel.cvut.cz>, Ales Kucera <kuceral4@fel.cvut.cz>, David Korcak <david.korcak@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -14,9 +14,9 @@ logger = logging.getLogger("simview.cli")
|
|
|
14
14
|
|
|
15
15
|
_logging_configured = False
|
|
16
16
|
|
|
17
|
-
# Subcommands with their own scoped flags. "view"
|
|
18
|
-
#
|
|
19
|
-
_SUBCOMMANDS = ("info", "diff", "terrain", "render", "clear")
|
|
17
|
+
# Subcommands with their own scoped flags. "view" also works bare
|
|
18
|
+
# (`simview <file>...`) -- see main()'s routing.
|
|
19
|
+
_SUBCOMMANDS = ("view", "info", "diff", "terrain", "render", "clear", "static-viewer")
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _configure_logging() -> None:
|
|
@@ -71,6 +71,20 @@ def clear_cache():
|
|
|
71
71
|
logger.info("Cache cleared.")
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def export_static_viewer(directory: Path) -> None:
|
|
75
|
+
"""Copy the viewer page (`index.html` + `static/`) into `directory`, so a
|
|
76
|
+
site can host it next to `SimulationScene.save_static` bundles and open
|
|
77
|
+
`index.html#data=<bundle url>` with no SimView server. The page's asset
|
|
78
|
+
URLs are page-relative already, so the files are copied verbatim."""
|
|
79
|
+
from importlib.resources import files
|
|
80
|
+
|
|
81
|
+
package = files("simview")
|
|
82
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
shutil.copy(str(package / "templates" / "index.html"), directory / "index.html")
|
|
84
|
+
shutil.copytree(str(package / "static"), directory / "static", dirs_exist_ok=True)
|
|
85
|
+
logger.info("Static viewer written to %s", directory)
|
|
86
|
+
|
|
87
|
+
|
|
74
88
|
def run_info(path: Path, as_json: bool) -> None:
|
|
75
89
|
"""Print a structural summary of the scene JSON at `path` to stdout."""
|
|
76
90
|
from simview.info import format_text, summarize_scene
|
|
@@ -287,7 +301,13 @@ def save_merged(
|
|
|
287
301
|
payload = json.dumps(merged).encode("utf-8")
|
|
288
302
|
if out_path.suffix == ".gz":
|
|
289
303
|
payload = gzip.compress(payload, compresslevel=1)
|
|
290
|
-
|
|
304
|
+
try:
|
|
305
|
+
# Like SimulationScene.save: create missing parent directories.
|
|
306
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
out_path.write_bytes(payload)
|
|
308
|
+
except OSError as e:
|
|
309
|
+
logger.error("Error: cannot write merged scene to '%s': %s", out_path, e)
|
|
310
|
+
sys.exit(1)
|
|
291
311
|
logger.info("Merged scene written to %s", out_path)
|
|
292
312
|
|
|
293
313
|
|
|
@@ -618,6 +638,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
618
638
|
|
|
619
639
|
sub.add_parser("clear", help="Remove simview's on-disk cache.")
|
|
620
640
|
|
|
641
|
+
static_viewer = sub.add_parser(
|
|
642
|
+
"static-viewer",
|
|
643
|
+
help=(
|
|
644
|
+
"Copy the viewer page (index.html + static/) to DIR, to host it "
|
|
645
|
+
"as plain files next to save_static() bundles (open with #data=)."
|
|
646
|
+
),
|
|
647
|
+
)
|
|
648
|
+
static_viewer.add_argument("directory", type=Path, metavar="DIR")
|
|
649
|
+
|
|
621
650
|
return parser
|
|
622
651
|
|
|
623
652
|
|
|
@@ -638,9 +667,9 @@ def main():
|
|
|
638
667
|
parser.print_help()
|
|
639
668
|
sys.exit(1)
|
|
640
669
|
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
670
|
+
# The bare `simview <file>...` view form has no keyword, so anything that
|
|
671
|
+
# isn't a known subcommand (or -h/--help, which the top-level parser
|
|
672
|
+
# should still handle itself) is routed to "view".
|
|
644
673
|
if argv[0] not in _SUBCOMMANDS and argv[0] not in ("-h", "--help"):
|
|
645
674
|
argv = ["view", *argv]
|
|
646
675
|
|
|
@@ -664,6 +693,9 @@ def main():
|
|
|
664
693
|
if args.command == "clear":
|
|
665
694
|
clear_cache()
|
|
666
695
|
return
|
|
696
|
+
if args.command == "static-viewer":
|
|
697
|
+
export_static_viewer(args.directory)
|
|
698
|
+
return
|
|
667
699
|
|
|
668
700
|
single_file_commands = {
|
|
669
701
|
"info": lambda path, args: run_info(path, as_json=args.json),
|
|
@@ -93,6 +93,10 @@ def blob_floats(value: Any) -> list[float]:
|
|
|
93
93
|
if not value.startswith(BLOB_PREFIX):
|
|
94
94
|
raise ValueError(f"expected a {BLOB_PREFIX} blob, got {value[:32]!r}")
|
|
95
95
|
raw = blob_bytes(value)
|
|
96
|
+
if len(raw) % 4:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"blob has {len(raw)} bytes, not a multiple of 4 (float32)"
|
|
99
|
+
)
|
|
96
100
|
return list(struct.unpack(f"<{len(raw) // 4}f", raw))
|
|
97
101
|
|
|
98
102
|
flat: list[float] = []
|
|
@@ -154,7 +158,12 @@ def _decode_state_field_rows(value, width: int, batch_size: int):
|
|
|
154
158
|
raise StatesShapeMismatch(f"unexpected string value for field: {value!r}")
|
|
155
159
|
flat = np.frombuffer(blob_bytes(value), dtype="<f4")
|
|
156
160
|
else:
|
|
157
|
-
|
|
161
|
+
try:
|
|
162
|
+
arr = np.asarray(value, dtype="<f4")
|
|
163
|
+
except (ValueError, TypeError) as e:
|
|
164
|
+
# Ragged / non-numeric rows: not repackable, keep the legacy layout
|
|
165
|
+
# (as documented) rather than crash the server at load.
|
|
166
|
+
raise StatesShapeMismatch(f"field value is not a numeric array: {e}")
|
|
158
167
|
if arr.ndim == 1:
|
|
159
168
|
if batch_size != 1:
|
|
160
169
|
raise StatesShapeMismatch(
|
|
@@ -291,9 +300,12 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
|
|
|
291
300
|
|
|
292
301
|
T = len(states_data)
|
|
293
302
|
|
|
294
|
-
|
|
303
|
+
# Stack everything first; register_blob only once nothing can still
|
|
304
|
+
# bail out, so a late mismatch doesn't leave orphaned blobs behind in
|
|
305
|
+
# the server (they're never referenced, never freed).
|
|
306
|
+
body_blobs: list[tuple[object, dict[str, bytes]]] = []
|
|
295
307
|
for key in body_order:
|
|
296
|
-
|
|
308
|
+
raw_fields = {}
|
|
297
309
|
for field, per_frame_rows in body_rows[key].items():
|
|
298
310
|
if len(per_frame_rows) != T:
|
|
299
311
|
raise StatesShapeMismatch(
|
|
@@ -303,17 +315,17 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
|
|
|
303
315
|
stacked = np.ascontiguousarray(
|
|
304
316
|
np.stack(per_frame_rows, axis=0), dtype="<f4"
|
|
305
317
|
) # (T, B, k)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if key in any_contacts:
|
|
309
|
-
entry["contacts"] = body_contacts[key]
|
|
310
|
-
bodies_payload.append(entry)
|
|
318
|
+
raw_fields[field] = stacked.tobytes()
|
|
319
|
+
body_blobs.append((key, raw_fields))
|
|
311
320
|
|
|
312
|
-
|
|
321
|
+
scalar_blobs: dict[str, bytes] = {}
|
|
313
322
|
for name in model_data.get("scalarNames") or []:
|
|
314
323
|
per_frame = []
|
|
315
324
|
for state in states_data:
|
|
316
|
-
|
|
325
|
+
try:
|
|
326
|
+
row = np.asarray(state[name], dtype="<f4")
|
|
327
|
+
except (ValueError, TypeError) as e:
|
|
328
|
+
raise StatesShapeMismatch(f"scalar '{name}' is not numeric: {e}")
|
|
317
329
|
if row.ndim == 0:
|
|
318
330
|
row = row.reshape(1)
|
|
319
331
|
if row.shape != (batch_size,):
|
|
@@ -323,14 +335,7 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
|
|
|
323
335
|
)
|
|
324
336
|
per_frame.append(row)
|
|
325
337
|
stacked = np.ascontiguousarray(np.stack(per_frame, axis=0), dtype="<f4")
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
"version": COLUMNAR_VERSION,
|
|
330
|
-
"times": times,
|
|
331
|
-
"bodies": bodies_payload,
|
|
332
|
-
"scalars": scalars_payload,
|
|
333
|
-
}
|
|
338
|
+
scalar_blobs[name] = stacked.tobytes()
|
|
334
339
|
except StatesShapeMismatch as e:
|
|
335
340
|
logger.warning(
|
|
336
341
|
"States data is not columnar-repackable, keeping the legacy "
|
|
@@ -339,6 +344,23 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
|
|
|
339
344
|
)
|
|
340
345
|
return None
|
|
341
346
|
|
|
347
|
+
bodies_payload = []
|
|
348
|
+
for key, raw_fields in body_blobs:
|
|
349
|
+
entry = {
|
|
350
|
+
"name": body_name_value[key],
|
|
351
|
+
"fields": {f: register_blob(raw) for f, raw in raw_fields.items()},
|
|
352
|
+
}
|
|
353
|
+
if key in any_contacts:
|
|
354
|
+
entry["contacts"] = body_contacts[key]
|
|
355
|
+
bodies_payload.append(entry)
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"version": COLUMNAR_VERSION,
|
|
359
|
+
"times": times,
|
|
360
|
+
"bodies": bodies_payload,
|
|
361
|
+
"scalars": {n: register_blob(raw) for n, raw in scalar_blobs.items()},
|
|
362
|
+
}
|
|
363
|
+
|
|
342
364
|
|
|
343
365
|
def expand_columnar_states(states_doc: Any, batch_size: int) -> list[dict]:
|
|
344
366
|
"""Inverse of `columnarize_states`: rebuild the legacy per-frame array.
|
|
@@ -441,6 +463,11 @@ def write_static_bundle(data: dict, directory) -> None:
|
|
|
441
463
|
else:
|
|
442
464
|
externalize(v)
|
|
443
465
|
|
|
466
|
+
externalize(data["model"])
|
|
467
|
+
# The viewer only resolves /blob/ URLs in the model and a columnar states
|
|
468
|
+
# object; a legacy per-frame array's inline __b64__ fields are decoded
|
|
469
|
+
# in place, so they must stay inline.
|
|
470
|
+
if is_columnar(data["states"]):
|
|
471
|
+
externalize(data["states"])
|
|
444
472
|
for name in ("model", "states"):
|
|
445
|
-
externalize(data[name])
|
|
446
473
|
(out / f"{name}.json").write_text(json.dumps(data[name]))
|
|
@@ -162,14 +162,57 @@ def _resolve_frame(
|
|
|
162
162
|
return resolved
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
def pose_resolver(model_data: dict, states_data: list):
|
|
166
|
+
"""`(all_names, resolve)` for walking `states_data` in world space:
|
|
167
|
+
`all_names` is every body name/name-group in the states plus any
|
|
168
|
+
rigidly-attached body (constant `localTransform`, never written into the
|
|
169
|
+
states) resolvable from them, and `resolve(state, batch_idx)` maps each
|
|
170
|
+
body name to its absolute-world `[x, y, z, w, qx, qy, qz]` row for that
|
|
171
|
+
frame (parent chains composed; bodies without a pose that frame are left
|
|
172
|
+
out). Shared by `simview diff` and `simview terrain --along-body`."""
|
|
173
|
+
batch_size = int(model_data.get("simBatches") or 1)
|
|
174
|
+
all_names = collect_body_names(states_data)
|
|
175
|
+
meta = _build_body_meta(model_data, all_names)
|
|
176
|
+
topo_order = _topo_sort_bodies(meta)
|
|
177
|
+
seen_keys = {body_key(name) for name in all_names}
|
|
178
|
+
for name in topo_order:
|
|
179
|
+
if meta[name]["localTransform"] is not None and body_key(name) not in seen_keys:
|
|
180
|
+
seen_keys.add(body_key(name))
|
|
181
|
+
all_names.append(name)
|
|
182
|
+
|
|
183
|
+
def resolve(state: dict, batch_idx: int) -> dict[str, list[float]]:
|
|
184
|
+
return _resolve_frame(
|
|
185
|
+
meta, topo_order, bodies_by_name(state.get("bodies")), batch_size, batch_idx
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return all_names, resolve
|
|
189
|
+
|
|
190
|
+
|
|
165
191
|
def _quat_angle_deg(qa: list[float], qb: list[float]) -> float:
|
|
166
192
|
"""Angular distance in degrees between two [w, x, y, z] quaternions,
|
|
167
|
-
robust to the double-cover ambiguity (q and -q are the same rotation).
|
|
193
|
+
robust to the double-cover ambiguity (q and -q are the same rotation).
|
|
194
|
+
NaN in either quaternion gives NaN, never a spurious 0."""
|
|
168
195
|
dot = qa[0] * qb[0] + qa[1] * qb[1] + qa[2] * qb[2] + qa[3] * qb[3]
|
|
196
|
+
if math.isnan(dot):
|
|
197
|
+
return math.nan
|
|
169
198
|
dot = max(-1.0, min(1.0, abs(dot)))
|
|
170
199
|
return math.degrees(2 * math.acos(dot))
|
|
171
200
|
|
|
172
201
|
|
|
202
|
+
def _nan_aware_stats(values: list[float]) -> dict:
|
|
203
|
+
"""`series_stats` over the finite entries of `values` (NaN for all of
|
|
204
|
+
mean/min/max when every entry is NaN), with `final` the raw last value
|
|
205
|
+
and `nan_count` how many entries were NaN."""
|
|
206
|
+
finite = [v for v in values if not math.isnan(v)]
|
|
207
|
+
stats = series_stats(finite)
|
|
208
|
+
if values and not finite:
|
|
209
|
+
stats = {"mean": math.nan, "min": math.nan, "max": math.nan, "final": None}
|
|
210
|
+
if values:
|
|
211
|
+
stats["final"] = values[-1]
|
|
212
|
+
stats["nan_count"] = len(values) - len(finite)
|
|
213
|
+
return stats
|
|
214
|
+
|
|
215
|
+
|
|
173
216
|
def _resolve_batches(model_data: dict, batch_a: int, batch_b: int) -> int:
|
|
174
217
|
batch_size = int(model_data.get("simBatches") or 1)
|
|
175
218
|
if batch_size < 2:
|
|
@@ -191,7 +234,9 @@ def _first_exceeding(
|
|
|
191
234
|
if threshold is None:
|
|
192
235
|
return None
|
|
193
236
|
for frame_idx, value in zip(frame_indices, values):
|
|
194
|
-
|
|
237
|
+
# A NaN error (NaN pose in either batch) counts as exceeding: it's
|
|
238
|
+
# never "within threshold".
|
|
239
|
+
if value > threshold or math.isnan(value):
|
|
195
240
|
return frame_idx
|
|
196
241
|
return None
|
|
197
242
|
|
|
@@ -230,28 +275,20 @@ def compute_trajectory_diff(
|
|
|
230
275
|
per-axis toggle -- see `static/js/utils/errorMath.js`'s
|
|
231
276
|
`positionAxisError`) and matching `"err_x"`/`"err_y"`/`"err_z"` entries
|
|
232
277
|
in `summary` (mean/max/final of the *signed* value, so directional bias
|
|
233
|
-
is visible).
|
|
234
|
-
|
|
278
|
+
is visible). A NaN pose yields a NaN error for that frame: summary
|
|
279
|
+
mean/min/max skip NaN entries (`nan_count` says how many), and a NaN
|
|
280
|
+
error counts as exceeding any threshold. Raises `ValueError` on invalid
|
|
281
|
+
batch indices, `every < 1`, an unmatched/ambiguous `body`, or a scene
|
|
282
|
+
with no diffable bodies.
|
|
235
283
|
"""
|
|
236
|
-
|
|
284
|
+
_resolve_batches(model_data, batch_a, batch_b)
|
|
237
285
|
if every < 1:
|
|
238
286
|
raise ValueError(f"every must be >= 1; got {every}")
|
|
239
287
|
|
|
240
|
-
all_names =
|
|
241
|
-
seen_keys = {body_key(name) for name in all_names}
|
|
288
|
+
all_names, resolve = pose_resolver(model_data, states_data)
|
|
242
289
|
if not all_names:
|
|
243
290
|
raise ValueError("no bodies found in the scene's states to diff")
|
|
244
291
|
|
|
245
|
-
meta = _build_body_meta(model_data, all_names)
|
|
246
|
-
topo_order = _topo_sort_bodies(meta)
|
|
247
|
-
# Rigidly-attached children carry a constant localTransform and so never
|
|
248
|
-
# appear in the states at all -- they're only diffable now that poses are
|
|
249
|
-
# resolved through the parent chain.
|
|
250
|
-
for name in topo_order:
|
|
251
|
-
if meta[name]["localTransform"] is not None and body_key(name) not in seen_keys:
|
|
252
|
-
seen_keys.add(body_key(name))
|
|
253
|
-
all_names.append(name)
|
|
254
|
-
|
|
255
292
|
target_names = resolve_body(all_names, body)
|
|
256
293
|
|
|
257
294
|
# A grouped ("A+B") state entry shares one transform across its members, so
|
|
@@ -273,11 +310,10 @@ def compute_trajectory_diff(
|
|
|
273
310
|
for idx, state in enumerate(states_data):
|
|
274
311
|
if idx % every != 0:
|
|
275
312
|
continue
|
|
276
|
-
raw_by_name = bodies_by_name(state.get("bodies"))
|
|
277
313
|
# Resolved once per frame for the whole scene rather than per body:
|
|
278
314
|
# a child's world pose needs its ancestors' poses anyway.
|
|
279
|
-
rows_a =
|
|
280
|
-
rows_b =
|
|
315
|
+
rows_a = resolve(state, batch_a)
|
|
316
|
+
rows_b = resolve(state, batch_b)
|
|
281
317
|
|
|
282
318
|
for name in target_names:
|
|
283
319
|
key = body_key(name)
|
|
@@ -302,8 +338,8 @@ def compute_trajectory_diff(
|
|
|
302
338
|
frame_indices = out["frame_indices"]
|
|
303
339
|
summary = {
|
|
304
340
|
"frame_count": len(frame_indices),
|
|
305
|
-
"position_error":
|
|
306
|
-
"orientation_error_deg":
|
|
341
|
+
"position_error": _nan_aware_stats(out["position_error"]),
|
|
342
|
+
"orientation_error_deg": _nan_aware_stats(out["orientation_error_deg"]),
|
|
307
343
|
"first_frame_exceeding_pos_threshold": _first_exceeding(
|
|
308
344
|
frame_indices, out["position_error"], pos_threshold
|
|
309
345
|
),
|
|
@@ -323,7 +359,7 @@ def compute_trajectory_diff(
|
|
|
323
359
|
body_out["summary"] = summary
|
|
324
360
|
for axis in axes:
|
|
325
361
|
body_out[axis] = out[axis]
|
|
326
|
-
summary[axis] =
|
|
362
|
+
summary[axis] = _nan_aware_stats(out[axis])
|
|
327
363
|
bodies_out[body_label(name)] = body_out
|
|
328
364
|
|
|
329
365
|
return {
|
|
@@ -38,9 +38,32 @@ def _blob_byte_length(value: Any) -> int | None:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def _encoding_of(value: Any) -> str:
|
|
41
|
+
if value is None:
|
|
42
|
+
return "absent"
|
|
41
43
|
return "blob" if is_blob(value) else "plain"
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
def _field_shape_reason(value: Any, width: int, batch_size: int) -> str | None:
|
|
47
|
+
"""Why `columnarize_states` would reject this per-body field value (its
|
|
48
|
+
float count must be `batch_size * width`; a flat list only for a
|
|
49
|
+
single batch), or None if it's fine. Counts floats without decoding
|
|
50
|
+
plain lists into arrays."""
|
|
51
|
+
if is_blob(value):
|
|
52
|
+
count = len(blob_bytes(value)) // 4
|
|
53
|
+
elif isinstance(value, list):
|
|
54
|
+
if value and not isinstance(value[0], list):
|
|
55
|
+
if batch_size != 1:
|
|
56
|
+
return "is a flat list but the scene has more than one batch"
|
|
57
|
+
count = len(value)
|
|
58
|
+
else:
|
|
59
|
+
count = sum(len(row) if isinstance(row, list) else 1 for row in value)
|
|
60
|
+
else:
|
|
61
|
+
return f"is a {type(value).__name__}, not a list or blob"
|
|
62
|
+
if count != batch_size * width:
|
|
63
|
+
return f"has {count} floats; expected {batch_size * width}"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
44
67
|
def _summarize_terrain(terrain: dict) -> dict:
|
|
45
68
|
dims = terrain.get("dimensions") or {}
|
|
46
69
|
bounds = terrain.get("bounds") or {}
|
|
@@ -141,6 +164,12 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
|
|
|
141
164
|
except ValueError as e:
|
|
142
165
|
warnings.append(f"columnar states document is malformed: {e}")
|
|
143
166
|
states = []
|
|
167
|
+
elif not isinstance(states, list):
|
|
168
|
+
warnings.append(
|
|
169
|
+
"'states' is neither a per-frame array nor a columnar document "
|
|
170
|
+
f"(got {type(states).__name__})"
|
|
171
|
+
)
|
|
172
|
+
states = []
|
|
144
173
|
|
|
145
174
|
frame_count = len(states)
|
|
146
175
|
dt_from_model = model.get("dt") if model else None
|
|
@@ -221,6 +250,7 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
|
|
|
221
250
|
body_encodings_by_field: dict = {}
|
|
222
251
|
body_frames_present: dict = {}
|
|
223
252
|
body_contacts_frames: dict = {}
|
|
253
|
+
body_shape_reported: set = set()
|
|
224
254
|
|
|
225
255
|
for idx, state in enumerate(states):
|
|
226
256
|
bodies = state.get("bodies") or []
|
|
@@ -253,6 +283,16 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
|
|
|
253
283
|
for field in fields:
|
|
254
284
|
encodings = body_encodings_by_field[key].setdefault(field, set())
|
|
255
285
|
encodings.add(_encoding_of(body[field]))
|
|
286
|
+
# Same row-shape rule columnarize_states applies, checked
|
|
287
|
+
# cheaply (float counts only) so the verdict matches.
|
|
288
|
+
shape_reason = _field_shape_reason(
|
|
289
|
+
body[field], STATE_FIELD_WIDTHS[field], batch_size
|
|
290
|
+
)
|
|
291
|
+
if shape_reason and (key, field) not in body_shape_reported:
|
|
292
|
+
body_shape_reported.add((key, field))
|
|
293
|
+
reasons.append(
|
|
294
|
+
f"body '{name}' field '{field}' in frame {idx} {shape_reason}"
|
|
295
|
+
)
|
|
256
296
|
|
|
257
297
|
if "contacts" in body:
|
|
258
298
|
body_contacts_frames.setdefault(key, []).append(idx)
|
|
@@ -373,7 +413,7 @@ def summarize_scene(path: str | Path) -> dict:
|
|
|
373
413
|
warnings.append("'states' key missing from file")
|
|
374
414
|
|
|
375
415
|
size_bytes = path.stat().st_size
|
|
376
|
-
if size_bytes > _LARGE_FILE_BYTES:
|
|
416
|
+
if size_bytes > _LARGE_FILE_BYTES and not gzipped:
|
|
377
417
|
warnings.append(
|
|
378
418
|
f"file is large ({human_bytes(size_bytes)}); consider gzip compression"
|
|
379
419
|
)
|
|
@@ -55,9 +55,14 @@ class LiveViewer:
|
|
|
55
55
|
"(e.g. terrain might be missing)."
|
|
56
56
|
)
|
|
57
57
|
self.scene = scene
|
|
58
|
+
scene.reconcile_available_attributes()
|
|
58
59
|
self.server = SimViewServer(
|
|
59
60
|
data={"model": scene.model.to_json(), "states": []}, live=True
|
|
60
61
|
)
|
|
62
|
+
# Frames already in the scene are neither served nor buffered, but
|
|
63
|
+
# episode startIndex values index the full scene.states, so the live
|
|
64
|
+
# frame indices must start after them.
|
|
65
|
+
self.server.frames_pushed = len(scene.states)
|
|
61
66
|
|
|
62
67
|
self._threaded = ViewerHandle(
|
|
63
68
|
self.server.app,
|
|
@@ -101,8 +106,9 @@ class LiveViewer:
|
|
|
101
106
|
self.scene.add_state(time, body_states, scalar_values=scalar_values)
|
|
102
107
|
frame = self.scene.states[before]
|
|
103
108
|
|
|
104
|
-
self.server.
|
|
105
|
-
self.
|
|
109
|
+
index = self.server.frames_pushed
|
|
110
|
+
self.server.push_frame(frame)
|
|
111
|
+
self._enqueue((index, frame))
|
|
106
112
|
|
|
107
113
|
def mark_episode(self, label: str | None = None, start_index: int | None = None):
|
|
108
114
|
"""Mark the start of an episode at the current point in the stream.
|
|
@@ -146,7 +152,7 @@ class LiveViewer:
|
|
|
146
152
|
"""
|
|
147
153
|
return self._dropped_frames
|
|
148
154
|
|
|
149
|
-
def _enqueue(self, frame: dict) -> None:
|
|
155
|
+
def _enqueue(self, frame: tuple[int, dict]) -> None:
|
|
150
156
|
"""Queue one frame for the sender thread, decimating under backpressure.
|
|
151
157
|
|
|
152
158
|
When the queue is full (a slow or hung browser tab), the *oldest*
|
|
@@ -187,12 +193,12 @@ class LiveViewer:
|
|
|
187
193
|
happens, instead of in the caller's simulation loop.
|
|
188
194
|
"""
|
|
189
195
|
while True:
|
|
190
|
-
|
|
191
|
-
if
|
|
196
|
+
item = self._send_queue.get()
|
|
197
|
+
if item is _STOP_SENTINEL:
|
|
192
198
|
return
|
|
193
|
-
self._broadcast(
|
|
199
|
+
self._broadcast(*item)
|
|
194
200
|
|
|
195
|
-
def _broadcast(self, frame: dict) -> None:
|
|
201
|
+
def _broadcast(self, index: int, frame: dict) -> None:
|
|
196
202
|
loop = self.server.loop
|
|
197
203
|
if loop is None:
|
|
198
204
|
# Startup blocks until uvicorn's Server.started is set, which
|
|
@@ -205,10 +211,15 @@ class LiveViewer:
|
|
|
205
211
|
return
|
|
206
212
|
|
|
207
213
|
future = asyncio.run_coroutine_threadsafe(
|
|
208
|
-
self.server.broadcast_frame(frame), loop
|
|
214
|
+
self.server.broadcast_frame(frame, index), loop
|
|
209
215
|
)
|
|
210
216
|
try:
|
|
211
217
|
future.result(timeout=_BROADCAST_TIMEOUT)
|
|
218
|
+
except TimeoutError:
|
|
219
|
+
# Cancel it, or the stalled broadcast keeps running on the loop and
|
|
220
|
+
# interleaves with the next frame's (out-of-order delivery).
|
|
221
|
+
future.cancel()
|
|
222
|
+
logger.warning("Timed out broadcasting live state frame; skipped.")
|
|
212
223
|
except Exception:
|
|
213
224
|
logger.exception("Error broadcasting live state frame")
|
|
214
225
|
|