diff --git a/experiments/perception/e10_fusion_runtime.py b/experiments/perception/e10_fusion_runtime.py index 14f7d4d..90655bd 100644 --- a/experiments/perception/e10_fusion_runtime.py +++ b/experiments/perception/e10_fusion_runtime.py @@ -13,7 +13,7 @@ import math import statistics from collections import defaultdict, deque from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any @@ -97,6 +97,9 @@ class TrackFusion: status: str cuboid: Cuboid | None source_indices: npt.NDArray[np.int64] + pre_ground_clustered_points: int = 0 + ground_rejected_points: int = 0 + support_ground_z_map: float | None = None geometry: str = "none" observed_cuboid: Cuboid | None = None completion_fraction: float | None = None @@ -466,6 +469,157 @@ def _completion_profile(profile: Mapping[str, Any]) -> dict[str, Any]: return dict(profile) +def _object_support_ground_filter_profile( + profile: Mapping[str, Any], +) -> dict[str, Any]: + groups = {"person", "bicycle", "motorcycle", "vehicle"} + minimum_height = profile.get("minimum_height_above_ground_m") + maximum_height = profile.get("maximum_height_above_ground_m") + if ( + profile.get("mode") != "local-ground-relative-object-support-v1" + or not isinstance(minimum_height, Mapping) + or set(minimum_height) != groups + or not isinstance(maximum_height, Mapping) + or set(maximum_height) != groups + ): + raise RuntimeError("object-support ground filter profile is invalid") + scalar_checks = ( + ("local_radius_m", 0.5, 10.0), + ("lower_percentile", 0.0, 30.0), + ("maximum_below_support_m", 0.1, 3.0), + ("maximum_above_support_m", 0.0, 0.5), + ("fallback_below_support_m", 0.0, 1.0), + ) + for name, lower, upper in scalar_checks: + try: + value = float(profile[name]) + except (KeyError, TypeError, ValueError) as exc: + raise RuntimeError(f"object-support ground filter field {name} is invalid") from exc + if not lower <= value <= upper: + raise RuntimeError(f"object-support ground filter field {name} is invalid") + for group in groups: + try: + lower = float(minimum_height[group]) + upper = float(maximum_height[group]) + except (KeyError, TypeError, ValueError) as exc: + raise RuntimeError( + f"object-support ground filter height for {group} is invalid" + ) from exc + if not 0.0 <= lower < upper <= 6.0: + raise RuntimeError(f"object-support ground filter height for {group} is invalid") + return dict(profile) + + +def _local_ground_z( + *, + all_points_map: FloatArray, + center_xy: FloatArray, + support_lower_z: float, + profile: Mapping[str, Any], +) -> float: + radius = float(profile["local_radius_m"]) + if all_points_map.size: + distances = np.linalg.norm(all_points_map[:, :2] - center_xy, axis=1) + candidates = all_points_map[distances <= radius, 2] + else: + candidates = np.empty(0, dtype=np.float64) + estimate = ( + float(np.percentile(candidates, float(profile["lower_percentile"]))) + if candidates.size + else math.nan + ) + if ( + not math.isfinite(estimate) + or estimate < support_lower_z - float(profile["maximum_below_support_m"]) + or estimate > support_lower_z + float(profile["maximum_above_support_m"]) + ): + estimate = support_lower_z - float(profile["fallback_below_support_m"]) + return estimate + + +def _filter_object_support_by_ground( + indices: npt.NDArray[np.int64], + points_map: FloatArray, + *, + group: str, + profile: Mapping[str, Any], +) -> tuple[npt.NDArray[np.int64], float | None, int]: + """Remove local ground only from one object's fitting support. + + `points_map` is never modified or reduced. The returned indices are a + per-object view used by range/cuboid fitting; mapping, terrain and + traversability consumers continue to receive the complete cloud. + """ + + if indices.size == 0: + return indices, None, 0 + support = points_map[indices] + support_lower_z = float(np.percentile(support[:, 2], 5.0)) + ground_z = _local_ground_z( + all_points_map=points_map, + center_xy=np.median(support[:, :2], axis=0), + support_lower_z=support_lower_z, + profile=profile, + ) + minimum = ground_z + float(profile["minimum_height_above_ground_m"][group]) + maximum = ground_z + float(profile["maximum_height_above_ground_m"][group]) + keep = (support[:, 2] >= minimum) & (support[:, 2] <= maximum) + filtered = indices[keep] + return filtered, ground_z, int(indices.size - filtered.size) + + +def _support_overlap_fraction( + one: npt.NDArray[np.int64], + two: npt.NDArray[np.int64], +) -> float: + if one.size == 0 or two.size == 0: + return 0.0 + shared = np.intersect1d(one, two, assume_unique=False).size + return float(shared / min(one.size, two.size)) + + +def _suppress_duplicate_support_fusions( + fusions: Sequence[TrackFusion], + *, + overlap_threshold: float, +) -> tuple[TrackFusion, ...]: + accepted: list[TrackFusion] = [] + suppressed: set[int] = set() + for item in sorted(fusions, key=lambda value: (-value.score, value.track_id)): + if item.cuboid is None: + continue + if any( + other.association_group == item.association_group + and _support_overlap_fraction(other.source_indices, item.source_indices) + >= overlap_threshold + for other in accepted + ): + suppressed.add(item.track_id) + else: + accepted.append(item) + if not suppressed: + return tuple(fusions) + return tuple( + replace( + item, + distance_smoothed_m=None, + status="rejected-duplicate-lidar-support", + cuboid=None, + source_indices=np.empty(0, dtype=np.int64), + geometry="none", + observed_cuboid=None, + completion_fraction=None, + ground_z_map=None, + orientation_source=None, + temporal_status=None, + support_coverage_fraction=None, + ) + if item.track_id in suppressed + else item + for item in fusions + ) + + class CuboidCompletionTracker: """Complete visible LiDAR surfaces into provenance-marked, smoothed cuboids. @@ -667,24 +821,12 @@ class CuboidCompletionTracker: center_xy: FloatArray, support_lower_z: float, ) -> float: - ground = self.profile["ground"] - radius = float(ground["local_radius_m"]) - if all_points_map.size: - distances = np.linalg.norm(all_points_map[:, :2] - center_xy, axis=1) - candidates = all_points_map[distances <= radius, 2] - else: - candidates = np.empty(0, dtype=np.float64) - if candidates.size: - estimate = float(np.percentile(candidates, float(ground["lower_percentile"]))) - else: - estimate = math.nan - if ( - not math.isfinite(estimate) - or estimate < support_lower_z - float(ground["maximum_below_support_m"]) - or estimate > support_lower_z + float(ground["maximum_above_support_m"]) - ): - estimate = support_lower_z - float(ground["fallback_below_support_m"]) - return estimate + return _local_ground_z( + all_points_map=all_points_map, + center_xy=center_xy, + support_lower_z=support_lower_z, + profile=self.profile["ground"], + ) @staticmethod def _amodal_axis_center(lower: float, upper: float, size: float, sensor: float) -> float: @@ -732,6 +874,17 @@ def fuse_tracks( session_seconds: float | None = None, ) -> tuple[TrackFusion, ...]: vehicle_labels = set(str(value) for value in association["vehicle_labels"]) + ground_filter = association.get("object_support_ground_filter") + if ground_filter is not None: + if not isinstance(ground_filter, Mapping): + raise RuntimeError("object-support ground filter profile is invalid") + ground_filter = _object_support_ground_filter_profile(ground_filter) + duplicate_overlap_value = association.get("support_duplicate_overlap_threshold") + duplicate_overlap_threshold = ( + None if duplicate_overlap_value is None else float(duplicate_overlap_value) + ) + if duplicate_overlap_threshold is not None and not 0.0 < duplicate_overlap_threshold <= 1.0: + raise RuntimeError("support duplicate overlap threshold is invalid") accepted_tracks: list[dict[str, Any]] = [] for track in sorted(tracks, key=lambda value: float(value["score"]), reverse=True): label = str(track["label"]) @@ -767,11 +920,21 @@ def fuse_tracks( allowed = np.asarray(association["semantic_ids"][group], dtype=np.uint8) compatible = candidates[np.isin(sampled_semantic[candidates], allowed)] clustered = _depth_cluster(compatible, depths, association) - selected = _spatial_cluster( + selected_before_ground = _spatial_cluster( source_indices[clustered], points_lidar, float(association["spatial_cluster_radius_m"][group]), ) + selected = selected_before_ground + support_ground_z = None + ground_rejected_points = 0 + if ground_filter is not None: + selected, support_ground_z, ground_rejected_points = _filter_object_support_by_ground( + selected_before_ground, + points_map, + group=group, + profile=ground_filter, + ) ranges = np.linalg.norm(points_lidar[selected], axis=1) p10 = None if ranges.size == 0 else float(np.percentile(ranges, 10)) median = None if ranges.size == 0 else float(np.median(ranges)) @@ -786,6 +949,12 @@ def fuse_tracks( support_coverage_fraction = None if compatible.size == 0: status = "rejected-no-semantic-lidar-support" + elif ( + selected_before_ground.size >= minimum + and selected.size < minimum + and ground_rejected_points > 0 + ): + status = "rejected-ground-only-or-insufficient-object-support" elif selected.size < minimum: status = f"rejected-fewer-than-{minimum}-clustered-points" else: @@ -861,6 +1030,9 @@ def fuse_tracks( status=status, cuboid=cuboid, source_indices=selected, + pre_ground_clustered_points=int(selected_before_ground.size), + ground_rejected_points=ground_rejected_points, + support_ground_z_map=support_ground_z, geometry=geometry, observed_cuboid=observed_cuboid, completion_fraction=completion_fraction, @@ -870,7 +1042,11 @@ def fuse_tracks( support_coverage_fraction=support_coverage_fraction, ) ) - return tuple(result) + if duplicate_overlap_threshold is None: + return tuple(result) + return _suppress_duplicate_support_fusions( + result, overlap_threshold=duplicate_overlap_threshold + ) def fusion_document(item: TrackFusion) -> dict[str, Any]: @@ -883,6 +1059,9 @@ def fusion_document(item: TrackFusion) -> dict[str, Any]: "candidate_projected_points": item.candidate_points, "semantic_compatible_points": item.semantic_points, "clustered_points": item.clustered_points, + "pre_ground_clustered_points": item.pre_ground_clustered_points, + "ground_rejected_points": item.ground_rejected_points, + "support_ground_z_map": item.support_ground_z_map, "distance_p10_m": item.distance_p10_m, "distance_median_m": item.distance_median_m, "distance_smoothed_m": item.distance_smoothed_m, diff --git a/experiments/perception/refuse_e19_ground_aware_result.py b/experiments/perception/refuse_e19_ground_aware_result.py new file mode 100644 index 0000000..3c17fb6 --- /dev/null +++ b/experiments/perception/refuse_e19_ground_aware_result.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 +"""Materialize LAB E19 from immutable E14 detections, masks, and LiDAR. + +The detector and semantic models are deliberately not rerun: LAB E19 changes +only calibrated LiDAR association, cuboid fitting, and presentation. The +parent result and LiDAR pack are validated first, then a new content-addressed +integrated-perception result is written without modifying either input. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import os +import platform +import shutil +import tempfile +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import numpy as np +from e10_fusion_runtime import ( + CuboidCompletionTracker, + LidarReplayPack, + WorldStateProjector, + _completion_profile, + _object_support_ground_filter_profile, + canonical_json, + clearance, + distance_history, + fuse_tracks, + fusion_document, + project_points, + sha256, +) + +from k1link.compute.integrated_perception import ( + FUSION_SCHEMA, + IDENTITY_SCHEMA, + REPORT_SCHEMA, + RESULT_SCHEMA, + SEMANTIC_SCHEMA, + WORLD_SCHEMA, + validate_integrated_perception_result, +) + +PIPELINE_ID = "fixed-e14-yolox-eomt-e19-ground-aware-refusion/v1" + + +def arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--job", type=Path, required=True) + parser.add_argument("--parent-result", type=Path, required=True) + parser.add_argument("--lidar-packs-root", type=Path, required=True) + parser.add_argument("--profile", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + return parser.parse_args() + + +def read_object(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RuntimeError(f"JSON root is not an object: {path}") + return value + + +def read_rows(path: Path) -> list[dict[str, Any]]: + rows = [] + with path.open(encoding="utf-8") as stream: + for line in stream: + value = json.loads(line) + if not isinstance(value, dict): + raise RuntimeError(f"JSONL row is not an object: {path}") + rows.append(value) + return rows + + +def write_json(path: Path, value: object) -> None: + path.write_bytes(canonical_json(value) + b"\n") + os.chmod(path, 0o600) + + +def write_row(stream: Any, value: object) -> None: + stream.write(canonical_json(value).decode() + "\n") + + +def artifact( + path: Path, + kind: str, + media_type: str, + schema_version: str | None = None, +) -> dict[str, Any]: + value = { + "kind": kind, + "path": path.name, + "media_type": media_type, + "byte_length": path.stat().st_size, + "sha256": sha256(path), + } + if schema_version is not None: + value["schema_version"] = schema_version + return value + + +def read_profile(path: Path) -> tuple[dict[str, Any], str]: + resolved = path.resolve(strict=True) + profile = read_object(resolved) + association = profile.get("association") + if ( + profile.get("schema_version") != "missioncore.e10-integrated-perception-profile/v1" + or profile.get("profile_id") != "lab-e19-ground-aware-cuboids-v1" + or profile.get("mode") != "full-session-qualification" + or not isinstance(association, dict) + or not isinstance(profile.get("cuboid_completion"), dict) + ): + raise RuntimeError("LAB E19 profile contract is invalid") + ground_filter = association.get("object_support_ground_filter") + if not isinstance(ground_filter, dict): + raise RuntimeError("LAB E19 ground filter is missing") + _object_support_ground_filter_profile(ground_filter) + _completion_profile(profile["cuboid_completion"]) + overlap = float(association.get("support_duplicate_overlap_threshold", 0.0)) + if not 0.0 < overlap <= 1.0: + raise RuntimeError("LAB E19 support overlap threshold is invalid") + return profile, sha256(resolved) + + +def color_for(group: str, track_id: int) -> tuple[int, int, int]: + seed = hashlib.sha256(f"e10:{group}:{track_id}".encode()).digest() + return tuple(64 + value % 176 for value in seed[:3]) + + +def materialize(args: argparse.Namespace) -> Path: + parent = validate_integrated_perception_result( + args.job, + args.parent_result, + args.lidar_packs_root, + ) + if not parent.accepted: + raise RuntimeError("LAB E19 parent result is not accepted") + profile, profile_sha256 = read_profile(args.profile) + parent_result = read_object(parent.result_root / "result.json") + parent_report = read_object(parent.report_path) + parent_identity = parent_result["identity"] + parent_configuration = parent_identity["configuration"] + semantic_rows = read_rows(parent.semantic_path) + parent_fusion_rows = read_rows(parent.fusion_path) + parent_world_rows = read_rows(parent.world_path) + if not ( + len(parent_fusion_rows) == len(parent_world_rows) == parent.frame_count + and profile["selection"]["required_frame_count"] == parent.frame_count + ): + raise RuntimeError("LAB E19 parent selection changed") + + identity = { + "schema_version": IDENTITY_SCHEMA, + "job_id": parent_identity["job_id"], + "input_sha256": parent_identity["input_sha256"], + "session_id": parent_identity["session_id"], + "source_id": parent_identity["source_id"], + "lidar_pack_id": parent_identity["lidar_pack_id"], + "selection": copy.deepcopy(parent_identity["selection"]), + "configuration": { + "pipeline": PIPELINE_ID, + "profile": profile, + "profile_sha256": profile_sha256, + "detector_profile_sha256": parent_configuration["detector_profile_sha256"], + "semantic_profile_sha256": parent_configuration["semantic_profile_sha256"], + "runner_sha256": sha256(Path(__file__).resolve(strict=True)), + "fusion_runtime_sha256": sha256( + Path(__file__).with_name("e10_fusion_runtime.py").resolve(strict=True) + ), + "orchestrator_sha256": sha256(Path(__file__).resolve(strict=True)), + "container_image": parent_configuration["container_image"], + "valid_fov": copy.deepcopy(parent_configuration["valid_fov"]), + "refusion": { + "mode": "immutable-parent-detections-semantics-lidar-v1", + "parent_result_id": parent.result_id, + "parent_result_json_sha256": sha256(parent.result_root / "result.json"), + "parent_fusion_sha256": sha256(parent.fusion_path), + "parent_arrays_sha256": sha256(parent.arrays_path), + "detector_rerun": False, + "semantic_rerun": False, + "full_point_cloud_mutated": False, + }, + }, + "models": copy.deepcopy(parent_identity["models"]), + } + identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest() + result_id = f"e10-integrated-perception-{identity_sha256}" + output_root = args.output_root.expanduser().absolute() + output_root.mkdir(mode=0o700, parents=True, exist_ok=True) + final = output_root / result_id + if final.exists(): + validate_integrated_perception_result(args.job, final, args.lidar_packs_root) + return final + + staging = Path(tempfile.mkdtemp(prefix=".e19-refusion-", dir=output_root)) + os.chmod(staging, 0o700) + lidar = LidarReplayPack(parent.pack_root, expected_job_id=parent.job.job_id) + final_created = False + try: + semantic_path = staging / "semantic-frames.jsonl" + shutil.copyfile(parent.semantic_path, semantic_path) + os.chmod(semantic_path, 0o600) + fusion_path = staging / "fusion-frames.jsonl" + world_path = staging / "world-state.jsonl" + gpu_path = staging / "gpu-telemetry.jsonl" + + with np.load(parent.arrays_path, allow_pickle=False) as parent_arrays: + frame_times_ns = parent_arrays["frame_times_ns"].copy() + semantic_frame_indices = parent_arrays["semantic_frame_indices"].copy() + semantic_masks = parent_arrays["semantic_masks"].copy() + if not np.array_equal( + semantic_frame_indices, + np.asarray([row["frame_index"] for row in semantic_rows], dtype=np.int64), + ): + raise RuntimeError("LAB E19 semantic mask binding changed") + semantic_by_source = { + int(row["source_frame_index"]): mask + for row, mask in zip(semantic_rows, semantic_masks, strict=True) + } + + history = distance_history(int(profile["association"]["distance_history_frames"])) + completion = CuboidCompletionTracker(profile["cuboid_completion"]) + projector = WorldStateProjector(float(profile["world_state"]["velocity_history_limit_s"])) + support_offsets = [0] + support_points: list[np.ndarray] = [] + support_colors: list[np.ndarray] = [] + box_offsets = [0] + box_centers: list[tuple[float, float, float]] = [] + box_half_sizes: list[tuple[float, float, float]] = [] + box_quaternions: list[tuple[float, float, float, float]] = [] + box_colors: list[tuple[int, int, int, int]] = [] + status_counts: Counter[str] = Counter() + fusion_state_counts: Counter[str] = Counter() + accepted_cuboids = 0 + old_accepted_cuboids = 0 + ground_rejected_points = 0 + tracks_with_ground_rejections = 0 + duplicate_support_rejections = 0 + fused_frames = 0 + + with ( + fusion_path.open("x", encoding="utf-8", newline="\n") as fusion_stream, + world_path.open("x", encoding="utf-8", newline="\n") as world_stream, + ): + for index, (parent_fusion, parent_world) in enumerate( + zip(parent_fusion_rows, parent_world_rows, strict=True) + ): + session_seconds = float(parent_fusion["session_seconds"]) + source_frame_index = int(parent_fusion["source_frame_index"]) + old_accepted_cuboids += sum( + str(item.get("cuboid_status", "")).startswith("accepted-") + for item in parent_fusion["objects"] + ) + lidar_frame = lidar.frame(index) + points_lidar = np.empty((0, 3), dtype=np.float64) + fusions = () + fusion_state = str(parent_fusion["fusion_state"]) + if fusion_state == "fused": + if lidar_frame is None: + raise RuntimeError(f"LAB E19 lost LiDAR frame {index}") + semantic_source = parent_fusion.get("semantic_source_frame_index") + semantic_map = semantic_by_source.get(int(semantic_source)) + if semantic_map is None: + raise RuntimeError(f"LAB E19 lost semantic source {semantic_source}") + points_map, position, quaternion = lidar_frame + pixels, depths, source_indices, points_lidar = project_points( + points_map, + position, + quaternion, + lidar.profile, + ) + fusions = fuse_tracks( + tracks=parent_fusion["objects"], + semantic_map=semantic_map, + pixels=pixels, + depths=depths, + source_indices=source_indices, + points_map=points_map, + points_lidar=points_lidar, + association=profile["association"], + distance_history=history, + completion_tracker=completion, + sensor_position_map=position, + session_seconds=session_seconds, + ) + fused_frames += 1 + fusion_state_counts[fusion_state] += 1 + accepted = [item for item in fusions if item.cuboid is not None] + accepted_cuboids += len(accepted) + for item in fusions: + status_counts[item.status] += 1 + ground_rejected_points += item.ground_rejected_points + tracks_with_ground_rejections += item.ground_rejected_points > 0 + duplicate_support_rejections += ( + item.status == "rejected-duplicate-lidar-support" + ) + + frame_support = [] + frame_support_colors = [] + for item in accepted: + if lidar_frame is None: + raise RuntimeError("LAB E19 accepted a cuboid without LiDAR") + color = color_for(item.association_group, item.track_id) + values = lidar_frame[0][item.source_indices].astype(np.float32) + frame_support.append(values) + frame_support_colors.append( + np.tile(np.asarray([color], dtype=np.uint8), (values.shape[0], 1)) + ) + box_centers.append(item.cuboid.center_map) + box_half_sizes.append(item.cuboid.half_size) + box_quaternions.append(item.cuboid.quaternion_xyzw) + box_colors.append((*color, 88)) + if frame_support: + support = np.concatenate(frame_support) + colors = np.concatenate(frame_support_colors) + support_points.append(support) + support_colors.append(colors) + support_offsets.append(support_offsets[-1] + support.shape[0]) + else: + support_offsets.append(support_offsets[-1]) + box_offsets.append(box_offsets[-1] + len(accepted)) + + clearance_state = clearance( + points_lidar, + profile["world_state"]["clearance"], + ) + world = projector.project( + frame_index=index, + source_frame_index=source_frame_index, + session_seconds=session_seconds, + fusion_state=fusion_state, + fusions=fusions, + points_lidar=points_lidar, + clearance_state=clearance_state, + delivery=parent_world["delivery"], + ) + write_row( + fusion_stream, + { + "schema_version": FUSION_SCHEMA, + "frame_index": index, + "source_frame_index": source_frame_index, + "session_seconds": session_seconds, + "fusion_state": fusion_state, + "semantic_status": parent_fusion["semantic_status"], + "semantic_source_frame_index": parent_fusion.get( + "semantic_source_frame_index" + ), + "objects": [fusion_document(item) for item in fusions], + }, + ) + write_row(world_stream, world) + fusion_stream.flush() + world_stream.flush() + os.fsync(fusion_stream.fileno()) + os.fsync(world_stream.fileno()) + os.chmod(fusion_path, 0o600) + os.chmod(world_path, 0o600) + + arrays_path = staging / "transient-perception.npz" + np.savez_compressed( + arrays_path, + frame_times_ns=frame_times_ns, + semantic_frame_indices=semantic_frame_indices, + semantic_masks=semantic_masks, + support_offsets=np.asarray(support_offsets, dtype=np.int64), + support_points=np.concatenate(support_points) + if support_points + else np.empty((0, 3), dtype=np.float32), + support_colors=np.concatenate(support_colors) + if support_colors + else np.empty((0, 3), dtype=np.uint8), + box_offsets=np.asarray(box_offsets, dtype=np.int64), + box_centers=np.asarray(box_centers, dtype=np.float32).reshape((-1, 3)), + box_half_sizes=np.asarray(box_half_sizes, dtype=np.float32).reshape((-1, 3)), + box_quaternions=np.asarray(box_quaternions, dtype=np.float32).reshape((-1, 4)), + box_colors=np.asarray(box_colors, dtype=np.uint8).reshape((-1, 4)), + ) + os.chmod(arrays_path, 0o600) + with gpu_path.open("x", encoding="utf-8", newline="\n") as gpu_stream: + write_row( + gpu_stream, + { + "schema_version": "missioncore.e19-refusion-telemetry/v1", + "gpu_used": False, + "parent_result_id": parent.result_id, + "reason": "detector and semantic inference outputs reused immutably", + }, + ) + gpu_stream.flush() + os.fsync(gpu_stream.fileno()) + os.chmod(gpu_path, 0o600) + + acceptance_profile = profile["acceptance"] + checks = { + "parent_result_accepted": parent.accepted, + "frame_accounting": len(parent_fusion_rows) == parent.frame_count, + "minimum_lidar_fused_frames": fused_frames + >= int(acceptance_profile["minimum_lidar_fused_frames"]), + "minimum_accepted_cuboids": accepted_cuboids + >= int(acceptance_profile["minimum_accepted_cuboids"]), + "ground_filter_exercised": ground_rejected_points > 0, + "duplicate_support_filter_exercised": duplicate_support_rejections > 0, + "full_point_cloud_preserved": True, + } + accepted = all(checks.values()) + created_at = datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + parent_metrics = parent_report.get("metrics", {}) + metrics = { + "source_span_seconds": parent_metrics.get("source_span_seconds"), + "detector": copy.deepcopy(parent_metrics.get("detector")), + "semantic": copy.deepcopy(parent_metrics.get("semantic")), + "fusion": { + "fused_frames": fused_frames, + "fusion_state_counts": dict(fusion_state_counts), + "accepted_cuboids": accepted_cuboids, + "parent_accepted_cuboids": old_accepted_cuboids, + "accepted_change_fraction": ( + accepted_cuboids / old_accepted_cuboids - 1.0 if old_accepted_cuboids else None + ), + "rejection_counts": dict(status_counts), + "ground_points_excluded_from_box_fitting": ground_rejected_points, + "track_observations_with_ground_exclusions": tracks_with_ground_rejections, + "duplicate_support_rejections": duplicate_support_rejections, + }, + "refusion": { + "gpu_used": False, + "detector_rerun": False, + "semantic_rerun": False, + "parent_result_id": parent.result_id, + }, + } + report = { + "schema_version": REPORT_SCHEMA, + "result_id": result_id, + "created_at_utc": created_at, + "state": "accepted" if accepted else "rejected", + "ground_truth": False, + "identity": identity, + "runtime": { + "hostname": platform.node(), + "python": platform.python_version(), + "numpy": np.__version__, + "execution": "cpu-only-refusion-of-validated-parent", + }, + "metrics": metrics, + "acceptance": { + "accepted": accepted, + "checks": checks, + "navigation_or_safety_accepted": False, + }, + "limitations": [ + "Detector and semantic inference are inherited from the validated E14 parent.", + "This is a recorded refusion result, not a physical live K1 gate.", + "The complete point cloud is an immutable input and is not modified.", + "Completed cuboids remain class-prior diagnostic geometry, not 3D ground truth.", + "Navigation and safety authority remain disabled.", + ], + } + report_path = staging / "run-report.json" + write_json(report_path, report) + artifacts = [ + artifact( + semantic_path, + "e10-semantic-frames", + "application/x-ndjson", + SEMANTIC_SCHEMA, + ), + artifact( + fusion_path, + "e10-fusion-frames", + "application/x-ndjson", + FUSION_SCHEMA, + ), + artifact( + world_path, + "e10-world-state", + "application/x-ndjson", + WORLD_SCHEMA, + ), + artifact(arrays_path, "e10-transient-perception", "application/x-npz"), + artifact(gpu_path, "worker-gpu-telemetry", "application/x-ndjson"), + artifact( + report_path, + "e10-run-report", + "application/json", + REPORT_SCHEMA, + ), + ] + result = { + "schema_version": RESULT_SCHEMA, + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": created_at, + "acceptance_state": report["state"], + "ground_truth": False, + "publication_scope": "recorded-integrated-realtime-qualification-only", + "frames_processed": parent.frame_count, + "artifacts": artifacts, + } + write_json(staging / "result.json", result) + os.rename(staging, final) + final_created = True + validate_integrated_perception_result(args.job, final, args.lidar_packs_root) + return final + except BaseException: + if staging.exists(): + shutil.rmtree(staging) + if final_created and final.exists(): + shutil.rmtree(final) + raise + finally: + lidar.close() + + +def main() -> int: + result = materialize(arguments()) + report = read_object(result / "run-report.json") + print( + json.dumps( + { + "result_root": str(result), + "result_id": result.name, + "accepted": report["acceptance"]["accepted"], + "fusion": report["metrics"]["fusion"], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/perception/worker/e19_ground_aware_cuboid_profile.json b/experiments/perception/worker/e19_ground_aware_cuboid_profile.json new file mode 100644 index 0000000..ca38e7a --- /dev/null +++ b/experiments/perception/worker/e19_ground_aware_cuboid_profile.json @@ -0,0 +1,172 @@ +{ + "schema_version": "missioncore.e10-integrated-perception-profile/v1", + "profile_id": "lab-e19-ground-aware-cuboids-v1", + "mode": "full-session-qualification", + "source": { + "source_id": "sensor.camera.right", + "resolution": [800, 600], + "calibration_slot": "camera_1", + "calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9" + }, + "selection": { + "required_frame_count": 4489, + "required_source_start_frame_index": 0, + "required_source_end_frame_index": 4488, + "minimum_source_span_seconds": 448.0 + }, + "replay": { + "speed": 1.0, + "detector_queue_capacity": 2, + "semantic_queue_capacity": 1, + "semantic_sample_every_frames": 5, + "semantic_ttl_ms": 750.0 + }, + "association": { + "box_inset": { + "bottom_fraction": 0.02, + "horizontal_fraction": 0.05, + "top_fraction": 0.04 + }, + "depth_cluster_gap_fraction": 0.06, + "depth_cluster_minimum_gap_m": 0.5, + "group_nms_iou_threshold": 0.5, + "support_duplicate_overlap_threshold": 0.6, + "object_support_ground_filter": { + "mode": "local-ground-relative-object-support-v1", + "local_radius_m": 2.5, + "lower_percentile": 8.0, + "maximum_below_support_m": 1.2, + "maximum_above_support_m": 0.15, + "fallback_below_support_m": 0.25, + "minimum_height_above_ground_m": { + "person": 0.08, + "bicycle": 0.08, + "motorcycle": 0.08, + "vehicle": 0.12 + }, + "maximum_height_above_ground_m": { + "person": 2.5, + "bicycle": 2.5, + "motorcycle": 2.5, + "vehicle": 4.5 + } + }, + "maximum_cuboid_span_m": 15.0, + "maximum_distance_innovation_fraction": 0.25, + "maximum_distance_innovation_m": 1.5, + "maximum_oriented_extent_m": { + "bicycle": [3.5, 2.0, 2.5], + "motorcycle": [3.5, 2.0, 2.5], + "person": [1.5, 1.5, 2.8], + "vehicle": [12.5, 4.0, 4.5] + }, + "minimum_cuboid_extent_m": 0.15, + "minimum_support_points": { + "bicycle": 4, + "motorcycle": 4, + "person": 4, + "vehicle": 8 + }, + "semantic_ids": { + "bicycle": [2], + "motorcycle": [3], + "person": [1], + "vehicle": [4, 5] + }, + "spatial_cluster_radius_m": { + "bicycle": 0.9, + "motorcycle": 0.9, + "person": 0.9, + "vehicle": 1.5 + }, + "vehicle_labels": ["car", "truck", "bus"], + "distance_history_frames": 5 + }, + "cuboid_completion": { + "mode": "class-prior-amodal-v1", + "failure_policy": "reject", + "classes": { + "person": { + "nominal_size_m": [0.55, 0.55, 1.72], + "minimum_size_m": [0.35, 0.35, 1.3], + "maximum_size_m": [1.2, 1.2, 2.3], + "support_padding_m": [0.12, 0.12, 0.12] + }, + "bicycle": { + "nominal_size_m": [1.8, 0.65, 1.5], + "minimum_size_m": [1.2, 0.4, 1.0], + "maximum_size_m": [2.5, 1.2, 2.2], + "support_padding_m": [0.18, 0.12, 0.12] + }, + "motorcycle": { + "nominal_size_m": [2.1, 0.8, 1.45], + "minimum_size_m": [1.4, 0.5, 1.0], + "maximum_size_m": [3.0, 1.4, 2.2], + "support_padding_m": [0.2, 0.14, 0.14] + }, + "car": { + "nominal_size_m": [4.5, 1.85, 1.55], + "minimum_size_m": [3.2, 1.45, 1.2], + "maximum_size_m": [5.8, 2.4, 2.3], + "support_padding_m": [0.25, 0.18, 0.15] + }, + "truck": { + "nominal_size_m": [7.0, 2.5, 3.0], + "minimum_size_m": [4.8, 1.8, 1.8], + "maximum_size_m": [12.5, 3.2, 4.2], + "support_padding_m": [0.35, 0.22, 0.2] + }, + "bus": { + "nominal_size_m": [10.5, 2.55, 3.2], + "minimum_size_m": [7.0, 2.1, 2.5], + "maximum_size_m": [13.5, 3.2, 4.2], + "support_padding_m": [0.4, 0.24, 0.2] + } + }, + "ground": { + "local_radius_m": 2.5, + "lower_percentile": 8.0, + "maximum_below_support_m": 1.2, + "maximum_above_support_m": 0.15, + "fallback_below_support_m": 0.25 + }, + "orientation": { + "minimum_anisotropy_ratio": 1.35, + "face_width_switch_fraction": 1.05 + }, + "temporal": { + "center_alpha": 0.4, + "size_alpha": 0.2, + "yaw_alpha": 0.25, + "maximum_center_innovation_m": 2.0, + "maximum_yaw_innovation_degrees": 55.0, + "maximum_idle_s": 1.0, + "confirmation_hits": 3 + }, + "minimum_support_coverage_fraction": 0.75 + }, + "world_state": { + "velocity_history_limit_s": 1.0, + "clearance": { + "sector_count": 72, + "minimum_range_m": 0.5, + "maximum_range_m": 30.0, + "ground_percentile": 5.0, + "minimum_height_above_ground_m": 0.2, + "maximum_height_above_ground_m": 3.0, + "front_half_angle_degrees": 15.0 + } + }, + "acceptance": { + "detector_minimum_effective_fps": 9.5, + "detector_maximum_drop_fraction": 0.005, + "maximum_p95_world_state_age_ms": 175.0, + "semantic_minimum_effective_fps": 1.8, + "semantic_maximum_drop_fraction": 0.05, + "semantic_maximum_p95_completion_age_ms": 400.0, + "minimum_fresh_semantic_coverage": 0.9, + "minimum_lidar_fused_frames": 3500, + "minimum_accepted_cuboids": 1500, + "require_zero_failures": true + } +} diff --git a/experiments/perception/worker/run_e10_integrated_perception.py b/experiments/perception/worker/run_e10_integrated_perception.py index 68d5b40..7a5b177 100644 --- a/experiments/perception/worker/run_e10_integrated_perception.py +++ b/experiments/perception/worker/run_e10_integrated_perception.py @@ -26,6 +26,7 @@ from e10_fusion_runtime import ( LidarReplayPack, WorldStateProjector, _completion_profile, + _object_support_ground_filter_profile, canonical_json, clearance, distance_history, @@ -160,6 +161,14 @@ def read_profile(path: Path) -> tuple[dict[str, Any], str]: if not isinstance(completion, dict): raise RuntimeError("LAB E13 cuboid completion contract is invalid") _completion_profile(completion) + ground_filter = association.get("object_support_ground_filter") + if ground_filter is not None: + if not isinstance(ground_filter, dict): + raise RuntimeError("object-support ground filter contract is invalid") + _object_support_ground_filter_profile(ground_filter) + duplicate_overlap = association.get("support_duplicate_overlap_threshold") + if duplicate_overlap is not None and not 0.0 < float(duplicate_overlap) <= 1.0: + raise RuntimeError("support duplicate overlap contract is invalid") return profile, sha256(resolved) diff --git a/src/k1link/device_plugins/xgrids_k1/rrd_export.py b/src/k1link/device_plugins/xgrids_k1/rrd_export.py index 7dadef1..17dbe6f 100644 --- a/src/k1link/device_plugins/xgrids_k1/rrd_export.py +++ b/src/k1link/device_plugins/xgrids_k1/rrd_export.py @@ -8,7 +8,7 @@ from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from pathlib import Path -from typing import Literal, TypedDict +from typing import Any, Literal, TypedDict from uuid import UUID, uuid4 import numpy as np @@ -534,6 +534,19 @@ def _recorded_blueprint( ), ).visualizer() point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID + accumulated_time_ranges = ( + rrb.VisibleTimeRanges(time_ranges) if time_ranges is not None else None + ) + point_overrides: list[Any] = [ + rrb.EntityBehavior(visible=settings.show_points), + point_visualizer, + ] + trajectory_overrides: list[Any] = [ + rrb.EntityBehavior(visible=settings.show_trajectory), + ] + if accumulated_time_ranges is not None: + point_overrides.append(accumulated_time_ranges) + trajectory_overrides.append(accumulated_time_ranges) spatial_view = rrb.Spatial3DView( origin="/world", name="Пространственная сцена", @@ -548,21 +561,17 @@ def _recorded_blueprint( # therefore hide/reveal already-recorded entities without # rewriting the data RRD. Keep the point visualizer alongside it # so size/color overrides remain active for the same entity. - "/world/points": [ - rrb.EntityBehavior(visible=settings.show_points), - point_visualizer, - ], - "/world/trajectory": rrb.EntityBehavior( - visible=settings.show_trajectory, - ), + "/world/points": point_overrides, + "/world/trajectory": trajectory_overrides, # Dynamic perception must not inherit the mapping view's # historical accumulation window. It is rendered latest-at in # the dedicated perception view below. "/world/perception": rrb.EntityBehavior(visible=False), }, - # A positive window accumulates historical frames. With no - # window, latest-at deliberately keeps one current LiDAR frame. - time_ranges=time_ranges, + # Clear any prior view-level accumulation and scope the operator's + # history window to mapping entities above. Dynamic perception then + # remains latest-at even when both layers share this world view. + time_ranges=rrb.VisibleTimeRanges([]), ) spatial_view.id = RECORDED_SPATIAL_VIEW_ID camera_view = rrb.Spatial2DView( @@ -599,9 +608,7 @@ def _recorded_blueprint( name="Маршрут и время", ) metrics_view.id = RECORDED_METRICS_VIEW_ID - active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[ - active_view - ] + active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[active_view] root_container = rrb.Tabs( spatial_view, camera_view, diff --git a/src/k1link/viewer/recorded.py b/src/k1link/viewer/recorded.py index 4e57c9b..d9361bb 100644 --- a/src/k1link/viewer/recorded.py +++ b/src/k1link/viewer/recorded.py @@ -21,15 +21,11 @@ RECORDED_SPATIAL_RESET_ROOT_CONTAINER_ID = UUID("27d95ad7-1e72-46ca-b854-d02cd99 RECORDED_PERCEPTION_ROOT_CONTAINER_ID = UUID("70ea9fd5-bbbb-4b23-8ae8-8098af92b997") RECORDED_PERCEPTION_RESET_ROOT_CONTAINER_ID = UUID("d2196c6e-99da-4402-857c-4daea0dd3ae0") RECORDED_PERCEPTION_3D_ROOT_CONTAINER_ID = UUID("81051015-9808-413b-90a4-1cfaacacbc4f") -RECORDED_PERCEPTION_3D_RESET_ROOT_CONTAINER_ID = UUID( - "3de10822-0274-4a58-8d45-f35d86625303" -) +RECORDED_PERCEPTION_3D_RESET_ROOT_CONTAINER_ID = UUID("3de10822-0274-4a58-8d45-f35d86625303") RECORDED_METRICS_ROOT_CONTAINER_ID = UUID("374710a2-1d77-4349-bea4-8719e7aa1f09") RECORDED_METRICS_RESET_ROOT_CONTAINER_ID = UUID("1e8ac565-bbd6-4554-9213-dad564e534e3") RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303") -RECORDED_PERCEPTION_POINTS_VISUALIZER_ID = UUID( - "ab8db306-6981-49c5-b417-94fe2f01dbf0" -) +RECORDED_PERCEPTION_POINTS_VISUALIZER_ID = UUID("ab8db306-6981-49c5-b417-94fe2f01dbf0") RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513") RECORDED_CAMERA_RESET_VIEW_ID = UUID("d0618e0c-c889-4222-a643-60a4a882935c") RECORDED_PERCEPTION_3D_VIEW_ID = UUID("0496bd2e-2b4d-4a4f-87b8-3ce4f9f7e114") @@ -65,25 +61,33 @@ def recorded_blueprint( end=rr.TimeRangeBoundary.cursor_relative(), ) ] - latest_time_ranges = rrb.VisibleTimeRanges([]) + accumulated_time_ranges = ( + rrb.VisibleTimeRanges(time_ranges) if time_ranges is not None else None + ) point_visualizer = rr.Points3D.from_fields( radii=rr.Radius.ui_points(settings.point_size), colors=( - [_parse_hex_color(settings.custom_color)] - if settings.palette == "custom" - else None + [_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None ), ).visualizer() point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID perception_point_visualizer = rr.Points3D.from_fields( radii=rr.Radius.ui_points(settings.point_size), colors=( - [_parse_hex_color(settings.custom_color)] - if settings.palette == "custom" - else None + [_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None ), ).visualizer() perception_point_visualizer.id = RECORDED_PERCEPTION_POINTS_VISUALIZER_ID + point_overrides: list[Any] = [ + rrb.EntityBehavior(visible=settings.show_points), + point_visualizer, + ] + trajectory_overrides: list[Any] = [ + rrb.EntityBehavior(visible=settings.show_trajectory), + ] + if accumulated_time_ranges is not None: + point_overrides.append(accumulated_time_ranges) + trajectory_overrides.append(accumulated_time_ranges) spatial_view = rrb.Spatial3DView( origin="/world", name="Мир · LiDAR и объекты" if unified_perception else "Пространственная сцена", @@ -94,24 +98,19 @@ def recorded_blueprint( stroke_width=0.75, ), overrides={ - "/world/points": [ - rrb.EntityBehavior(visible=settings.show_points), - point_visualizer, - ], - "/world/trajectory": rrb.EntityBehavior( - visible=settings.show_trajectory, - ), - # Perception is hidden in the mapping-only presentation. Unified - # perception below adds explicit per-entity latest-at overrides, - # allowing the native cloud to retain this view's accumulation. + # Accumulation belongs to mapping data only. Dynamic perception + # inherits the view's latest-at query and can never turn into an + # object-history trail when the operator widens the cloud window. + "/world/points": point_overrides, + "/world/trajectory": trajectory_overrides, "/world/perception": rrb.EntityBehavior(visible=False), }, - time_ranges=time_ranges, + # Log an explicit empty range set so a previous accumulated blueprint + # for this stable view id is cleared instead of surviving in Rerun. + time_ranges=rrb.VisibleTimeRanges([]), ) spatial_view.id = ( - RECORDED_SPATIAL_RESET_VIEW_ID - if view_reset_generation - else RECORDED_SPATIAL_VIEW_ID + RECORDED_SPATIAL_RESET_VIEW_ID if view_reset_generation else RECORDED_SPATIAL_VIEW_ID ) camera_view = rrb.Spatial2DView( origin="/perception/camera", @@ -128,9 +127,7 @@ def recorded_blueprint( }, ) camera_view.id = ( - RECORDED_CAMERA_RESET_VIEW_ID - if view_reset_generation - else RECORDED_CAMERA_VIEW_ID + RECORDED_CAMERA_RESET_VIEW_ID if view_reset_generation else RECORDED_CAMERA_VIEW_ID ) perception_3d_view = rrb.Spatial3DView( # Cuboids are expressed in the same calibrated world frame as the @@ -181,31 +178,18 @@ def recorded_blueprint( # semantic points and cuboids independently. spatial_view.visualizer_overrides["/world/perception"] = [ rrb.EntityBehavior(visible=True), - latest_time_ranges, ] - spatial_view.visualizer_overrides[ - "/world/perception/lidar" - ] = [ + spatial_view.visualizer_overrides["/world/perception/lidar"] = [ rrb.EntityBehavior(visible=False), - latest_time_ranges, ] - spatial_view.visualizer_overrides[ - "/world/perception/support" - ] = [ + spatial_view.visualizer_overrides["/world/perception/support"] = [ rrb.EntityBehavior(visible=False), - latest_time_ranges, ] - spatial_view.visualizer_overrides[ - "/world/perception/semantic_points" - ] = [ + spatial_view.visualizer_overrides["/world/perception/semantic_points"] = [ rrb.EntityBehavior(visible=show_segmentation), - latest_time_ranges, ] - spatial_view.visualizer_overrides[ - "/world/perception/boxes3d" - ] = [ + spatial_view.visualizer_overrides["/world/perception/boxes3d"] = [ rrb.EntityBehavior(visible=show_cuboids_3d), - latest_time_ranges, ] root_container = rrb.Horizontal( camera_view, @@ -222,9 +206,7 @@ def recorded_blueprint( # Keep operator video and 3D cuboids as direct root views. Rerun's nested # Tabs preserve their own active child and cannot be switched reliably by # a live blueprint channel after the operator has visited another child. - active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[ - active_view - ] + active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[active_view] root_container = rrb.Tabs( spatial_view, camera_view, diff --git a/tests/test_e10_integrated_perception.py b/tests/test_e10_integrated_perception.py index 316f5ba..95b7c5a 100644 --- a/tests/test_e10_integrated_perception.py +++ b/tests/test_e10_integrated_perception.py @@ -120,6 +120,22 @@ def test_e13_profile_pins_provenance_marked_amodal_completion() -> None: assert profile["cuboid_completion"]["temporal"]["confirmation_hits"] == 3 +def test_e19_profile_adds_ground_aware_support_without_mutating_e14() -> None: + _fusion, runner = _worker_modules() + root = Path(__file__).resolve().parents[1] / "experiments" / "perception" / "worker" + e14, _e14_digest = runner.read_profile(root / "e14_full_session_amodal_profile.json") + e19, _e19_digest = runner.read_profile(root / "e19_ground_aware_cuboid_profile.json") + + assert "object_support_ground_filter" not in e14["association"] + assert "support_duplicate_overlap_threshold" not in e14["association"] + assert e19["profile_id"] == "lab-e19-ground-aware-cuboids-v1" + assert ( + e19["association"]["object_support_ground_filter"]["mode"] + == "local-ground-relative-object-support-v1" + ) + assert e19["association"]["support_duplicate_overlap_threshold"] == 0.6 + + def test_e14_profile_combines_full_session_and_amodal_gates() -> None: _fusion, runner = _worker_modules() profile_path = ( @@ -278,6 +294,84 @@ def test_e13_temporal_filter_reduces_cuboid_center_jitter() -> None: assert outputs[-1].temporal_status == "confirmed" +def test_e19_ground_filter_preserves_cloud_and_excludes_ground_from_box_support() -> None: + fusion, runner = _worker_modules() + profile, _digest = runner.read_profile( + Path(__file__).resolve().parents[1] + / "experiments" + / "perception" + / "worker" + / "e19_ground_aware_cuboid_profile.json" + ) + ground = np.asarray( + [[x, y, 0.0] for x in (9.5, 10.0, 10.5) for y in (-0.6, 0.0, 0.6)], + dtype=np.float64, + ) + vehicle = np.asarray( + [[x, y, z] for x in (9.8, 10.2) for y in (-0.4, 0.4) for z in (0.3, 0.9, 1.4)], + dtype=np.float64, + ) + cloud = np.concatenate((ground, vehicle)) + unchanged = cloud.copy() + indices = np.arange(cloud.shape[0], dtype=np.int64) + + filtered, ground_z, rejected = fusion._filter_object_support_by_ground( + indices, + cloud, + group="vehicle", + profile=profile["association"]["object_support_ground_filter"], + ) + + assert ground_z == 0.0 + assert rejected == ground.shape[0] + assert filtered.tolist() == list(range(ground.shape[0], cloud.shape[0])) + np.testing.assert_array_equal(cloud, unchanged) + + +def test_e19_duplicate_tracks_cannot_publish_the_same_lidar_support_twice() -> None: + fusion, _runner = _worker_modules() + cuboid = fusion.Cuboid( + center_map=(10.0, 0.0, 0.8), + half_size=(2.25, 0.925, 0.775), + quaternion_xyzw=(0.0, 0.0, 0.0, 1.0), + ) + + def item(track_id: int, score: float, indices: list[int]) -> object: + source = np.asarray(indices, dtype=np.int64) + return fusion.TrackFusion( + track_id=track_id, + label="car", + association_group="vehicle", + score=score, + bbox_xyxy=(100.0, 100.0, 200.0, 200.0), + candidate_points=source.size, + semantic_points=source.size, + clustered_points=source.size, + distance_p10_m=9.5, + distance_median_m=10.0, + distance_smoothed_m=10.0, + status="accepted-class-prior-amodal-v1", + cuboid=cuboid, + source_indices=source, + ) + + result = fusion._suppress_duplicate_support_fusions( + [ + item(10, 0.91, [1, 2, 3, 4, 5]), + item(11, 0.72, [1, 2, 3, 4]), + item(12, 0.80, [20, 21, 22, 23]), + ], + overlap_threshold=0.6, + ) + by_track = {value.track_id: value for value in result} + + assert by_track[10].cuboid is not None + assert by_track[11].cuboid is None + assert by_track[11].status == "rejected-duplicate-lidar-support" + assert by_track[11].source_indices.size == 0 + assert by_track[12].cuboid is not None + + def test_e10_semantic_binding_is_explicit_about_freshness() -> None: _fusion, runner = _worker_modules() assert runner.semantic_binding(None, 2.0, 750.0) == ("unavailable", None) diff --git a/tests/test_rrd_export.py b/tests/test_rrd_export.py index 5023ad9..29d42ec 100644 --- a/tests/test_rrd_export.py +++ b/tests/test_rrd_export.py @@ -237,7 +237,15 @@ def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> No visible_ranges = spatial_view.properties["VisibleTimeRanges"] line_grid = spatial_view.properties["LineGrid3D"] - assert visible_ranges.ranges.as_arrow_array().to_pylist() == [ + assert visible_ranges.ranges.as_arrow_array().to_pylist() == [] + assert line_grid.visible.as_arrow_array().to_pylist() == [False] + point_behavior, point_visualizer, point_time_ranges = spatial_view.visualizer_overrides[ + "/world/points" + ] + trajectory_behavior, trajectory_time_ranges = spatial_view.visualizer_overrides[ + "/world/trajectory" + ] + expected_accumulation = [ { "timeline": SESSION_TIMELINE, "range": { @@ -246,9 +254,8 @@ def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> No }, } ] - assert line_grid.visible.as_arrow_array().to_pylist() == [False] - point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"] - trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"] + assert point_time_ranges.ranges.as_arrow_array().to_pylist() == expected_accumulation + assert trajectory_time_ranges.ranges.as_arrow_array().to_pylist() == expected_accumulation perception_behavior = spatial_view.visualizer_overrides["/world/perception"] assert point_behavior.visible.as_arrow_array().to_pylist() == [False] assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True] @@ -277,9 +284,9 @@ def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> No blueprint = _recorded_blueprint(RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)) spatial_view = blueprint.root_container.contents[0] - assert "VisibleTimeRanges" not in spatial_view.properties + assert spatial_view.properties["VisibleTimeRanges"].ranges.as_arrow_array().to_pylist() == [] point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"] - trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"] + (trajectory_behavior,) = spatial_view.visualizer_overrides["/world/trajectory"] assert point_behavior.visible.as_arrow_array().to_pylist() == [True] assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True] point_components = {str(batch.component_descriptor()) for batch in point_visualizer.overrides} @@ -309,18 +316,32 @@ def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None: assert first.root_container.contents[3].id == RECORDED_METRICS_VIEW_ID assert second.root_container.contents[3].id == RECORDED_METRICS_VIEW_ID - first_point_behavior, first_point_visualizer = first_view.visualizer_overrides["/world/points"] - second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[ - "/world/points" + first_point_behavior, first_point_visualizer, first_point_time_ranges = ( + first_view.visualizer_overrides["/world/points"] + ) + second_point_behavior, second_point_visualizer, second_point_time_ranges = ( + second_view.visualizer_overrides["/world/points"] + ) + first_trajectory, first_trajectory_time_ranges = first_view.visualizer_overrides[ + "/world/trajectory" + ] + second_trajectory, second_trajectory_time_ranges = second_view.visualizer_overrides[ + "/world/trajectory" ] - first_trajectory = first_view.visualizer_overrides["/world/trajectory"] - second_trajectory = second_view.visualizer_overrides["/world/trajectory"] assert first_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID assert second_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID assert first_point_behavior.visible.as_arrow_array().to_pylist() == [False] assert second_point_behavior.visible.as_arrow_array().to_pylist() == [True] assert first_trajectory.visible.as_arrow_array().to_pylist() == [True] assert second_trajectory.visible.as_arrow_array().to_pylist() == [False] + assert ( + first_point_time_ranges.ranges.as_arrow_array().to_pylist() + == first_trajectory_time_ranges.ranges.as_arrow_array().to_pylist() + ) + assert ( + second_point_time_ranges.ranges.as_arrow_array().to_pylist() + == second_trajectory_time_ranges.ranges.as_arrow_array().to_pylist() + ) payload = recorded_blueprint_rrd( RerunSceneSettings(show_points=False, show_trajectory=False), @@ -373,17 +394,14 @@ def test_viewer_blueprint_reset_is_bounded_and_recreates_render_views() -> None: cuboid_view = cuboids.root_container.contents[2] assert cuboid_view.origin == "/world" assert "VisibleTimeRanges" not in cuboid_view.properties - cuboid_points, cuboid_point_visualizer = cuboid_view.visualizer_overrides[ - "/world/points" - ] + cuboid_points, cuboid_point_visualizer = cuboid_view.visualizer_overrides["/world/points"] cuboid_perception = cuboid_view.visualizer_overrides["/world/perception"] - cuboid_diagnostic_lidar = cuboid_view.visualizer_overrides[ - "/world/perception/lidar" - ] + cuboid_diagnostic_lidar = cuboid_view.visualizer_overrides["/world/perception/lidar"] assert cuboid_points.visible.as_arrow_array().to_pylist() == [True] - assert cuboid_point_visualizer.id != initial.root_container.contents[0].visualizer_overrides[ - "/world/points" - ][1].id + assert ( + cuboid_point_visualizer.id + != initial.root_container.contents[0].visualizer_overrides["/world/points"][1].id + ) assert cuboid_perception.visible.as_arrow_array().to_pylist() == [True] assert cuboid_diagnostic_lidar.visible.as_arrow_array().to_pylist() == [False] perception_behavior = initial.root_container.contents[0].visualizer_overrides[ @@ -415,18 +433,16 @@ def test_viewer_blueprint_unifies_original_video_and_independent_ai_layers() -> assert camera_view.visualizer_overrides[ "/perception/camera/segmentation" ].visible.as_arrow_array().to_pylist() == [True] - semantic_behavior, semantic_time_ranges = spatial_view.visualizer_overrides[ - "/world/perception/semantic_points" - ] - cuboid_behavior, cuboid_time_ranges = spatial_view.visualizer_overrides[ - "/world/perception/boxes3d" - ] + (semantic_behavior,) = spatial_view.visualizer_overrides["/world/perception/semantic_points"] + (cuboid_behavior,) = spatial_view.visualizer_overrides["/world/perception/boxes3d"] assert semantic_behavior.visible.as_arrow_array().to_pylist() == [True] assert cuboid_behavior.visible.as_arrow_array().to_pylist() == [False] - assert semantic_time_ranges.ranges.as_arrow_array().to_pylist() == [] - assert cuboid_time_ranges.ranges.as_arrow_array().to_pylist() == [] visible_ranges = spatial_view.properties["VisibleTimeRanges"] - assert visible_ranges.ranges.as_arrow_array().to_pylist() == [ + assert visible_ranges.ranges.as_arrow_array().to_pylist() == [] + _point_behavior, _point_visualizer, point_time_ranges = spatial_view.visualizer_overrides[ + "/world/points" + ] + assert point_time_ranges.ranges.as_arrow_array().to_pylist() == [ { "timeline": SESSION_TIMELINE, "range": {"start": -12_000_000_000, "end": 0},