From cbf7ef6898ab3f7693cbf377d5180fd8ebcdb8be Mon Sep 17 00:00:00 2001 From: 01Dami23 Date: Fri, 7 Aug 2026 09:42:29 +0200 Subject: [PATCH 1/6] fix: make --py123d_path sufficient for datasets with global maps _resolve_map_file reads PY123D_DATA_ROOT from the environment rather than taking the root it was handed, so any dataset whose map is shared across logs (map_is_per_log=False: nuplan, nuscenes, nurec) fails with "Map API is required to convert scenario" unless the caller also exports that variable. Set it from --py123d_path so the documented CLI flag is enough on its own. --- src/bin_factory/loader/load.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bin_factory/loader/load.py b/src/bin_factory/loader/load.py index 64e57d7..2dde5a5 100644 --- a/src/bin_factory/loader/load.py +++ b/src/bin_factory/loader/load.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import pathlib from py123d import api as py123d_api @@ -21,6 +22,7 @@ def discover_scenes( map_only: bool = False, ) -> list: data_root = pathlib.Path(py123d_data_root) + os.environ["PY123D_DATA_ROOT"] = str(data_root) if map_only: return _discover_maps(data_root / "maps", datasets, num_scenes) From c394bffaf9748c9bd5ea34fdc4eb8a48e011d78d Mon Sep 17 00:00:00 2001 From: 01Dami23 Date: Fri, 7 Aug 2026 09:42:29 +0200 Subject: [PATCH 2/6] fix: keep signalised stop zones so uncovered lanes keep a control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process_traffic_controls skipped every TRAFFIC_LIGHT stop zone whenever the scenario already carried traffic lights, on the reasoning that a signalised junction should not emit a duplicate control. That deduplication is already done, per lane, by the covered_lanes filter two lines below, and the all-or-nothing guard defeats it: a stop zone spanning several lanes is dropped entirely even when only one of them has a light. test_static_traffic_light_keeps_only_uncovered_lanes asserts exactly that — a dynamic light on lane 1 and a stop zone controlling lanes 1 and 2 must yield two controls, [[1], [2]]. The test fails on this commit's parent and passes here; the suite goes from 51 failures to 50 with none introduced. The case that surfaced it: a dataset can record stop-line geometry and no light states, and then the guard drops the junction's only usable control. On a 100-scene NuRec set, yield controls fell to 23 of 177 against 73 without it. --- src/bin_factory/transforms/traffic_controls.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/bin_factory/transforms/traffic_controls.py b/src/bin_factory/transforms/traffic_controls.py index 408ffff..f10f8d7 100644 --- a/src/bin_factory/transforms/traffic_controls.py +++ b/src/bin_factory/transforms/traffic_controls.py @@ -55,9 +55,6 @@ def process_traffic_controls(scenario: schema.PufferScenario, extras: schema.Ext for element_data in extras.stop_zones: stop_zone_type = element_data.type - if stop_zone_type == puffer_types.TCType.TRAFFIC_LIGHT and scenario_length > 0: - continue # Skip stop zones if traffic lights are already defined, to avoid duplicates - controlled_lanes = [lid for lid in element_data.controlled_lanes if lid not in covered_lanes] if not controlled_lanes: continue From a16a709a3f8ec398672c104beb0c395400657bfe Mon Sep 17 00:00:00 2001 From: 01Dami23 Date: Fri, 7 Aug 2026 09:42:29 +0200 Subject: [PATCH 3/6] fix: route GENERIC_OBJECT to agents so the simulator can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GENERIC_OBJECT was mapped into the objects section, which the PufferDrive loader parses but never reads: nothing collides with it, and no observation includes it. Anything the source data could not classify therefore vanished from the simulation even though it physically blocks the road. This is not specific to one dataset — 6 of the 11 py123d taxonomies emit the label (nuPlan, nuScenes, KITTI-360, PandaSet, nuReasoning, PhysicalAI-AV) and it is invisible in all of them, so it belongs in the shared map rather than behind a per-dataset flag. Measured on 11 NuRec scenarios converted from the same Arrow store: before, 4 tracks landed in the objects section and the agent counts were {vehicle 441, pedestrian 53, cyclist 4}; after, the same 4 appear as AgentType.OTHER agents and the objects section is empty. BARRIER and TRAFFIC_CONE are still routed to objects and remain invisible for the same reason; other datasets may need the same treatment. --- src/bin_factory/loader/mapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin_factory/loader/mapping.py b/src/bin_factory/loader/mapping.py index 31cb831..dd22c88 100644 --- a/src/bin_factory/loader/mapping.py +++ b/src/bin_factory/loader/mapping.py @@ -22,6 +22,7 @@ detections.DefaultBoxDetectionLabel.TWO_WHEELER: puffer_types.AgentType.CYCLIST, detections.DefaultBoxDetectionLabel.ANIMAL: puffer_types.AgentType.OTHER, detections.DefaultBoxDetectionLabel.OTHER: puffer_types.AgentType.OTHER, + detections.DefaultBoxDetectionLabel.GENERIC_OBJECT: puffer_types.AgentType.OTHER, } OBJECT_TYPE_MAP = { @@ -29,7 +30,6 @@ detections.DefaultBoxDetectionLabel.TRAFFIC_CONE: puffer_types.ObjectType.TRAFFIC_CONE, detections.DefaultBoxDetectionLabel.TRAFFIC_LIGHT: puffer_types.ObjectType.TRAFFIC_LIGHT, detections.DefaultBoxDetectionLabel.BARRIER: puffer_types.ObjectType.BARRIER, - detections.DefaultBoxDetectionLabel.GENERIC_OBJECT: puffer_types.ObjectType.GENERIC_OBJECT, } LANE_TYPE_MAP = { From 288aaf2b6484a2eb2b16428e2ba7954aec0acd55 Mon Sep 17 00:00:00 2001 From: 01Dami23 Date: Fri, 7 Aug 2026 09:42:29 +0200 Subject: [PATCH 4/6] feat: optionally emit per-scenario conversion metadata Bins are recentred by the ego-trajectory centroid, and the positional bin format has nowhere to carry that offset, so a consumer moving states between PufferDrive and another simulator cannot get back to source coordinates. --emit_metadata (off by default, on in the nurec preset) writes one metadata.jsonl in the output directory, a row per converted scenario holding the centroid plus the bin filename, scenario id, dataset, length and dt. Workers return the row and the parent streams it, the same way failures.jsonl is already produced. Provenance only; the runtime never reads it. Also warn once per scenario about detection labels that neither mapping covers, so a silently dropped agent class shows up in the logs instead of as a missing obstacle. --- src/bin_factory/loader/extractor.py | 13 ++++++++++- src/bin_factory/main.py | 36 ++++++++++++++++++++++++++--- src/bin_factory/schema.py | 1 + 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/bin_factory/loader/extractor.py b/src/bin_factory/loader/extractor.py index 61879bc..b2f0e2e 100644 --- a/src/bin_factory/loader/extractor.py +++ b/src/bin_factory/loader/extractor.py @@ -75,6 +75,7 @@ def extract_scenario( all_objects, labels, tokens_to_object_id = _extract_objects(scene_api, centroid, ego_states) for obj in all_objects.values(): _fill_missing_velocities(obj, dt) + dropped_labels: dict[str, int] = {} for oid, obj in all_objects.items(): label = labels[oid] if label in mapping.AGENT_TYPE_MAP: @@ -83,6 +84,16 @@ def extract_scenario( elif label in mapping.OBJECT_TYPE_MAP: obj.type = mapping.OBJECT_TYPE_MAP[label] objects[oid] = obj + else: + dropped_labels[label.name] = dropped_labels.get(label.name, 0) + 1 + if dropped_labels: + log.warning( + "Scenario %s: dropped %d track(s) whose labels are in neither AGENT_TYPE_MAP " + "nor OBJECT_TYPE_MAP, so they are absent from the bin: %s", + metadata.id, + sum(dropped_labels.values()), + dropped_labels, + ) _extract_prediction_targets(scene_api, tokens_to_object_id, agents, metadata) @@ -99,7 +110,7 @@ def extract_scenario( objects=objects, metadata=metadata, ) - extras = schema.ExtractionExtras(traffic_lights=traffic_lights, stop_zones=stop_zones) + extras = schema.ExtractionExtras(traffic_lights=traffic_lights, stop_zones=stop_zones, centroid=centroid.tolist()) return scenario, extras diff --git a/src/bin_factory/main.py b/src/bin_factory/main.py index 81e1547..ad45ee1 100644 --- a/src/bin_factory/main.py +++ b/src/bin_factory/main.py @@ -1,4 +1,5 @@ import argparse +import contextlib import json import os import pathlib @@ -94,6 +95,13 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Reverse road-edge polyline order (Waymo convention) for nuplan/carla/opendrive", ) + parser.add_argument( + "--emit_metadata", + action="store_true", + help="Write metadata.jsonl in the output directory, a row per converted scenario " + "carrying the recentring centroid, for consumers that map bin coordinates back to " + "the source frame", + ) parser.add_argument( "--log_level", default="INFO", @@ -172,8 +180,8 @@ def _worker_fn(py123d_data: Any, output_dir: pathlib.Path, config: argparse.Name try: identity["scenario_id"] = _scenario_identity(py123d_data, config.scenario_id_field) bind(dataset=dataset, scenario=identity["scenario_id"]) - _convert_one(py123d_data, output_dir, config) - return {"ok": True, **identity, "error": ""} + metadata = _convert_one(py123d_data, output_dir, config) + return {"ok": True, **identity, "error": "", "metadata": metadata} except loader.ValidationError as ve: log.error("validation error: %s", ve) return {"ok": False, **identity, "error": str(ve)} @@ -184,7 +192,7 @@ def _worker_fn(py123d_data: Any, output_dir: pathlib.Path, config: argparse.Name unbind(tokens) -def _convert_one(py123d_data: Any, output_dir: pathlib.Path, config: argparse.Namespace) -> None: +def _convert_one(py123d_data: Any, output_dir: pathlib.Path, config: argparse.Namespace) -> dict | None: # 1. Load and convert 123D scenario to PufferDrive format scenario, extras = loader.extract_scenario(py123d_data, config.scenario_id_field) @@ -218,6 +226,20 @@ def _convert_one(py123d_data: Any, output_dir: pathlib.Path, config: argparse.Na temporary_path.write_bytes(binary_data) temporary_path.replace(output_path) + # 6. Per-scenario metadata, returned for the parent to collect: consumers (AlpaSim state + # sync, scene manifests) need it to match maps between formats and map bin coords back to + # the source frame after centering. + if not config.emit_metadata: + return None + return { + "bin_path": output_path.name, + "scenario_id": scenario.metadata.id, + "dataset": scenario.metadata.dataset, + "scenario_length": scenario.metadata.scenario_length, + "dt": scenario.metadata.dt, + "centroid": extras.centroid, + } + def _validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> tuple[argparse.Namespace, str]: for attr in ("datasets", "split_types", "split_names", "log_names", "scene_uuids"): @@ -320,6 +342,11 @@ def main() -> int: with ( failures_path.open("w", encoding="utf-8") as failure_handle, + ( + (output_dir / "metadata.jsonl").open("w", encoding="utf-8") + if args.emit_metadata + else contextlib.nullcontext() + ) as metadata_handle, tqdm.tqdm(total=len(scenes)) as pbar, joblib.Parallel( n_jobs=args.workers, @@ -335,6 +362,9 @@ def main() -> int: ): if result["ok"]: succeeded += 1 + if metadata_handle is not None: + metadata_handle.write(json.dumps(result["metadata"]) + "\n") + metadata_handle.flush() else: failed += 1 failure_handle.write(json.dumps(result) + "\n") diff --git a/src/bin_factory/schema.py b/src/bin_factory/schema.py index 2e14623..5e470e3 100644 --- a/src/bin_factory/schema.py +++ b/src/bin_factory/schema.py @@ -104,6 +104,7 @@ class StopZone: class ExtractionExtras: traffic_lights: dict = dataclasses.field(default_factory=dict) stop_zones: list = dataclasses.field(default_factory=list) + centroid: list | None = None @dataclasses.dataclass From 41a8bbc3ae587e1a7e2506c6bdde6e3b4a0bfaeb Mon Sep 17 00:00:00 2001 From: 01Dami23 Date: Fri, 7 Aug 2026 09:42:29 +0200 Subject: [PATCH 5/6] feat: add a nurec preset Pins the scenario id field and turns on --emit_metadata. This is the only dataset-specific change in the branch; everything else applies to any py123d dataset. Deliberately omits reverse_road_edges, unlike the nuplan, nuscenes, carla and opendrive presets: NuRec boundaries already come with the drivable side on the left. Measured against 8 CARLA reference bins (drivable side left in 92-100% of edges), NuRec's own winding is already left in 97%, so setting the flag would mirror every boundary the policy observes. --- src/bin_factory/presets.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bin_factory/presets.toml b/src/bin_factory/presets.toml index c449a0c..be6a240 100644 --- a/src/bin_factory/presets.toml +++ b/src/bin_factory/presets.toml @@ -33,6 +33,12 @@ datasets = ["opendrive"] reverse_road_edges = true scenario_id_field = "location" +[nurec] +datasets = ["nurec"] +scenario_id_field = "location" +emit_metadata = true +# No reverse_road_edges: NuRec boundaries already come with drivable-side-left. + [wod-motion] datasets = ["wod-motion"] invalid_agent_overlap = true From 35701964a8616edd9db08af5b2a6c220ca3278f5 Mon Sep 17 00:00:00 2001 From: 01Dami23 Date: Fri, 7 Aug 2026 16:46:39 +0200 Subject: [PATCH 6/6] fix: accept py123d 0.7 as well as 0.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact pin makes 123Drive unusable alongside any project that tracks a different py123d. A workspace holding both fails to resolve at all: Because 123drive==0.3.2 depends on py123d==0.6.0 and there is no version of py123d==0.6.0, we can conclude that all versions of 123drive cannot be used. 123Drive reads the Arrow store through py123d's API rather than depending on a particular writer: converting one fixed Arrow store against py123d 0.6.0 and 0.7.0 gives byte-identical bins across a 97-scenario set. Widen to >=0.6.0,<0.8 — both tested versions, and no claim about 0.8, which nobody has run. That keeps existing 0.6.0 callers working while letting a caller pin 0.7.0 themselves, instead of every such caller carrying a dependency override. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 849f8db..3b8280c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "joblib", "scipy", "shapely", - "py123d==0.6.0", + "py123d>=0.6.0,<0.8", ] [project.optional-dependencies]