huyluongngoc commited on
Commit
3624d0b
·
1 Parent(s): 73a2d91

feat(RCLane): switch to C++ for runtime modeling

Browse files
.gitignore CHANGED
@@ -19,6 +19,14 @@ checkpoints/
19
  *.pt
20
  *.onnx
21
  *.ckpt
 
 
 
 
 
 
 
 
22
 
23
  # python
24
  __pycache__/
 
19
  *.pt
20
  *.onnx
21
  *.ckpt
22
+ *.engine
23
+ *.profile
24
+ *.timing
25
+ exports/
26
+ cpp/build*/
27
+
28
+ # local videos / visualization outputs
29
+ *.mp4
30
 
31
  # python
32
  __pycache__/
README.md CHANGED
@@ -84,8 +84,32 @@ python eval_checkpoints.py --data-root data/dataset \
84
  # Export a checkpoint and verify its outputs with ONNX Runtime
85
  python export_onnx.py --checkpoint checkpoints/rclane_b0_e19.pth \
86
  --output exports/rclane_b0_e19.onnx --check-runtime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  ```
88
 
 
 
 
 
 
 
89
  For CUDA inference, use the CUDA 13 ONNX Runtime build pinned in
90
  `requirements.txt`. Disable TF32 when exact lane decisions matter:
91
 
 
84
  # Export a checkpoint and verify its outputs with ONNX Runtime
85
  python export_onnx.py --checkpoint checkpoints/rclane_b0_e19.pth \
86
  --output exports/rclane_b0_e19.onnx --check-runtime
87
+
88
+ # Build/cache a device-specific TensorRT FP16 engine and compare it to CUDA FP32
89
+ python build_tensorrt_engine.py --model exports/rclane_b0_e19.onnx \
90
+ --cache-dir exports/trt_cache
91
+
92
+ # Benchmark the sequential production core (preprocess + engine + 1024-seed
93
+ # decode + raw-model BEV) without visualization/video-I/O
94
+ python benchmark_realtime.py --model exports/rclane_b0_e19.onnx \
95
+ --video raw_Town04_Opt_20260714_093110.mp4 --provider tensorrt \
96
+ --trt-cache-dir exports/trt_cache --cpu-threads 8 --max-seeds 1024 \
97
+ --max-frames 300 --report runs/realtime_benchmark_1024seeds.json
98
+
99
+ # Render raw decoded lanes in BEV and export one cubic per detected lane.
100
+ # No lane is synthesized or forced parallel; cubics are clipped to camera FOV.
101
+ python test_video_bev_onnx.py --model exports/rclane_b0_e19.onnx \
102
+ --video raw_Town04_Opt_20260714_093110.mp4 --provider tensorrt \
103
+ --trt-cache-dir exports/trt_cache --decode-cpu-threads 8 \
104
+ --decode-max-seeds 1024 --output runs/video_bev_e19.mp4
105
  ```
106
 
107
+ TensorRT cache files are tied to the TensorRT version and GPU compute
108
+ capability. Rebuild the cache on a different deployment GPU. The real-time
109
+ benchmark deliberately reports camera/video acquisition and visualization
110
+ separately: its core latency is the latency relevant to the ADAS algorithm,
111
+ whereas rendering and MP4 encoding are offline diagnostics.
112
+
113
  For CUDA inference, use the CUDA 13 ONNX Runtime build pinned in
114
  `requirements.txt`. Disable TF32 when exact lane decisions matter:
115
 
benchmark_realtime.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark the optimized RCLane -> decode -> BEV core pipeline.
2
+
3
+ Rendering, video writing and source-frame acquisition are reported separately
4
+ from the sequential per-frame ADAS latency. Each frame completes inference,
5
+ decode and raw-model BEV projection before the next frame is processed.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+ from types import SimpleNamespace
14
+
15
+ import cv2
16
+ import numba
17
+ import numpy as np
18
+
19
+ from bev import BevRange, CameraCalibration
20
+ from dataset import normalize_image_numpy
21
+ from decode import decode, warmup_decode_backend
22
+ from test_video_bev_onnx import clip_lane_results_to_funnel, lane_to_record
23
+ from test_video_onnx import (
24
+ MODEL_HEIGHT,
25
+ MODEL_WIDTH,
26
+ OUTPUT_NAMES,
27
+ create_session,
28
+ softmax_foreground,
29
+ timing_summary,
30
+ )
31
+
32
+
33
+ def parse_args():
34
+ parser = argparse.ArgumentParser(description=__doc__)
35
+ parser.add_argument("--model", required=True)
36
+ parser.add_argument("--video", required=True)
37
+ parser.add_argument("--provider", choices=("tensorrt", "cuda"),
38
+ default="tensorrt")
39
+ parser.add_argument("--trt-cache-dir", default="exports/trt_cache")
40
+ parser.add_argument("--start-frame", type=int, default=0)
41
+ parser.add_argument("--max-frames", type=int, default=300)
42
+ parser.add_argument("--cpu-threads", type=int, default=8)
43
+ parser.add_argument("--max-seeds", type=int, default=1024)
44
+ parser.add_argument("--report", default="runs/realtime_benchmark.json")
45
+ return parser.parse_args()
46
+
47
+
48
+ def projection_args():
49
+ return SimpleNamespace(funnel_margin=0.10)
50
+
51
+
52
+ def postprocess(outputs, calibration, bev_range, config, max_seeds):
53
+ started = time.perf_counter()
54
+ stage = time.perf_counter()
55
+ seg_prob = softmax_foreground(outputs[0])[0]
56
+ softmax_ms = (time.perf_counter() - stage) * 1000.0
57
+ stage = time.perf_counter()
58
+ lanes = decode(
59
+ seg_prob, outputs[1][0], outputs[2][0], outputs[3][0], outputs[4][0],
60
+ max_seeds=max_seeds,
61
+ nms_max_lanes=128,
62
+ max_output_lanes=4,
63
+ crawl_backend="numba",
64
+ point_nms_backend="numba",
65
+ )
66
+ decode_ms = (time.perf_counter() - stage) * 1000.0
67
+ stage = time.perf_counter()
68
+ lane_results = [
69
+ lane_to_record(lane, calibration, bev_range, 0.5) for lane in lanes
70
+ ]
71
+ funnel = clip_lane_results_to_funnel(lane_results, config)
72
+ bev_ms = (time.perf_counter() - stage) * 1000.0
73
+ return {
74
+ "softmax_ms": softmax_ms,
75
+ "decode_ms": decode_ms,
76
+ "bev_ms": bev_ms,
77
+ "postprocess_ms": (time.perf_counter() - started) * 1000.0,
78
+ "lane_count": len(lanes),
79
+ "funnel_clipped_lanes": len(funnel["clipped_lanes"]),
80
+ "funnel_rejected_lanes": len(funnel["rejected_lanes"]),
81
+ }
82
+
83
+
84
+ def load_frames(path, start_frame, max_frames):
85
+ capture = cv2.VideoCapture(str(path))
86
+ if not capture.isOpened():
87
+ raise RuntimeError(f"cannot open video: {path}")
88
+ capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
89
+ frames = []
90
+ read_timings = []
91
+ for _ in range(max_frames):
92
+ started = time.perf_counter()
93
+ ok, frame = capture.read()
94
+ read_timings.append((time.perf_counter() - started) * 1000.0)
95
+ if not ok:
96
+ break
97
+ frames.append(frame)
98
+ capture.release()
99
+ if not frames:
100
+ raise RuntimeError("video produced no benchmark frames")
101
+ return frames, read_timings[:len(frames)]
102
+
103
+
104
+ def summarize_results(results):
105
+ return {
106
+ name: timing_summary([result[name] for result in results])
107
+ for name in ("softmax_ms", "decode_ms", "bev_ms", "postprocess_ms")
108
+ }
109
+
110
+
111
+ def main():
112
+ args = parse_args()
113
+ if args.start_frame < 0 or args.max_frames <= 0:
114
+ raise ValueError("start-frame must be >=0 and max-frames positive")
115
+ if args.cpu_threads <= 0 or args.cpu_threads > numba.config.NUMBA_NUM_THREADS:
116
+ raise ValueError(
117
+ f"cpu-threads must be in [1, {numba.config.NUMBA_NUM_THREADS}]"
118
+ )
119
+ if args.max_seeds <= 0:
120
+ raise ValueError("max-seeds must be positive")
121
+ model_path = Path(args.model).expanduser().resolve()
122
+ video_path = Path(args.video).expanduser().resolve()
123
+ report_path = Path(args.report).expanduser().resolve()
124
+ report_path.parent.mkdir(parents=True, exist_ok=True)
125
+
126
+ cv2.setNumThreads(1)
127
+ numba.set_num_threads(args.cpu_threads)
128
+ warmup_decode_backend("numba")
129
+ session = create_session(
130
+ model_path, args.provider, False, args.trt_cache_dir
131
+ )
132
+ warmup = np.zeros((1, 3, MODEL_HEIGHT, MODEL_WIDTH), np.float32)
133
+ for _ in range(10):
134
+ session.run(list(OUTPUT_NAMES), {"images": warmup})
135
+ frames, read_timings = load_frames(
136
+ video_path, args.start_frame, args.max_frames
137
+ )
138
+ calibration = CameraCalibration()
139
+ bev_range = BevRange()
140
+ config = projection_args()
141
+
142
+ sequential_results = []
143
+ preprocess_timings = []
144
+ inference_timings = []
145
+ total_timings = []
146
+ for frame in frames:
147
+ total_started = time.perf_counter()
148
+ stage = time.perf_counter()
149
+ images = normalize_image_numpy(frame, MODEL_WIDTH, MODEL_HEIGHT)
150
+ preprocess_timings.append((time.perf_counter() - stage) * 1000.0)
151
+ stage = time.perf_counter()
152
+ outputs = session.run(list(OUTPUT_NAMES), {"images": images})
153
+ inference_timings.append((time.perf_counter() - stage) * 1000.0)
154
+ sequential_results.append(postprocess(
155
+ outputs, calibration, bev_range, config, args.max_seeds
156
+ ))
157
+ total_timings.append((time.perf_counter() - total_started) * 1000.0)
158
+
159
+ report = {
160
+ "model": str(model_path),
161
+ "video": str(video_path),
162
+ "provider": session.get_providers()[0],
163
+ "frames": len(frames),
164
+ "start_frame": args.start_frame,
165
+ "cpu_threads": args.cpu_threads,
166
+ "max_seeds": args.max_seeds,
167
+ "rendering_included": False,
168
+ "source_read_ms": timing_summary(read_timings),
169
+ "sequential": {
170
+ "preprocess_ms": timing_summary(preprocess_timings),
171
+ "inference_ms": timing_summary(inference_timings),
172
+ **summarize_results(sequential_results),
173
+ "core_pipeline_ms": timing_summary(total_timings),
174
+ "fps_from_median_latency": 1000.0 / np.median(total_timings),
175
+ },
176
+ "lane_count": {
177
+ "mean": float(np.mean([
178
+ result["lane_count"] for result in sequential_results
179
+ ])),
180
+ "min": int(min(
181
+ result["lane_count"] for result in sequential_results
182
+ )),
183
+ "max": int(max(
184
+ result["lane_count"] for result in sequential_results
185
+ )),
186
+ },
187
+ "bev_projection": {
188
+ "mode": "raw_model_projection",
189
+ "parallel_assumption": False,
190
+ "synthetic_lanes": False,
191
+ "funnel_clipped_lane_fits": int(sum(
192
+ result["funnel_clipped_lanes"]
193
+ for result in sequential_results
194
+ )),
195
+ "funnel_rejected_lane_fits": int(sum(
196
+ result["funnel_rejected_lanes"]
197
+ for result in sequential_results
198
+ )),
199
+ },
200
+ }
201
+ temporary = report_path.with_suffix(report_path.suffix + ".tmp")
202
+ with temporary.open("w") as handle:
203
+ json.dump(report, handle, indent=2)
204
+ handle.write("\n")
205
+ os.replace(temporary, report_path)
206
+ print(f"report: {report_path}")
207
+ print(
208
+ "sequential median={:.3f}ms ({:.2f} FPS)".format(
209
+ report["sequential"]["core_pipeline_ms"]["median_ms"],
210
+ report["sequential"]["fps_from_median_latency"],
211
+ )
212
+ )
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
bev.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ego-centric inverse-perspective mapping and cubic lane fitting.
2
+
3
+ The public coordinate convention is ADAS-style:
4
+
5
+ X: forward from the ego origin (metres)
6
+ Y: left of ego (metres)
7
+
8
+ Z is used only internally to intersect an image ray with the local road plane
9
+ ``Z = 0``. The returned BEV lane points and cubic models are strictly 2-D.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from functools import lru_cache
14
+
15
+ import numpy as np
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CameraCalibration:
20
+ width: int = 1920
21
+ height: int = 1080
22
+ horizontal_fov_deg: float = 30.0
23
+ fx: float = 3582.768775
24
+ fy: float = 3582.768775
25
+ cx: float = 960.0
26
+ cy: float = 540.0
27
+ camera_to_vehicle: tuple = (
28
+ (0.997564, 0.0, 0.069756, 1.0),
29
+ (0.0, 1.0, 0.0, 0.0),
30
+ (-0.069756, 0.0, 0.997564, 1.8),
31
+ (0.0, 0.0, 0.0, 1.0),
32
+ )
33
+
34
+ @property
35
+ def intrinsic(self):
36
+ return np.array(
37
+ ((self.fx, 0.0, self.cx),
38
+ (0.0, self.fy, self.cy),
39
+ (0.0, 0.0, 1.0)),
40
+ dtype=np.float64,
41
+ )
42
+
43
+ @property
44
+ def camera_to_vehicle_matrix(self):
45
+ return np.asarray(self.camera_to_vehicle, dtype=np.float64)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class BevRange:
50
+ x_min: float = 0.0
51
+ x_max: float = 300.0
52
+ y_min: float = -85.0
53
+ y_max: float = 85.0
54
+
55
+
56
+ @lru_cache(maxsize=8)
57
+ def _projection_constants(calibration):
58
+ optical_to_ue = np.array(
59
+ ((0.0, 0.0, 1.0),
60
+ (1.0, 0.0, 0.0),
61
+ (0.0, -1.0, 0.0)),
62
+ dtype=np.float64,
63
+ )
64
+ mount = calibration.camera_to_vehicle_matrix
65
+ return (
66
+ np.linalg.inv(calibration.intrinsic),
67
+ optical_to_ue,
68
+ mount[:3, 3].copy(),
69
+ mount[:3, :3].copy(),
70
+ )
71
+
72
+
73
+ def pixels_to_ground(pixels, calibration=CameraCalibration(),
74
+ bev_range=BevRange()):
75
+ """Project raw-image pixels onto the local ground plane.
76
+
77
+ Args:
78
+ pixels: ``(N, 2)`` raw-image coordinates ``(u, v)``.
79
+ calibration: pinhole intrinsics and fixed camera-to-vehicle mount.
80
+ bev_range: accepted metric ROI.
81
+
82
+ Returns:
83
+ ``(points_xy, valid_mask)``. ``points_xy`` contains only valid points in
84
+ ADAS coordinates ``(X forward, Y left)``. ``valid_mask`` indexes the
85
+ input array and is useful for carrying per-point scores through IPM.
86
+ """
87
+ pixels = np.asarray(pixels, dtype=np.float64)
88
+ if pixels.size == 0:
89
+ return np.empty((0, 2), dtype=np.float64), np.zeros(0, dtype=bool)
90
+ if pixels.ndim != 2 or pixels.shape[1] != 2:
91
+ raise ValueError("pixels must have shape (N, 2)")
92
+
93
+ finite = np.isfinite(pixels).all(axis=1)
94
+ homogeneous = np.column_stack((pixels, np.ones(len(pixels))))
95
+ inverse_intrinsic, optical_to_ue, origin_vehicle, rotation = (
96
+ _projection_constants(calibration)
97
+ )
98
+ rays_optical = (inverse_intrinsic @ homogeneous.T).T
99
+
100
+ # CARLA/UE camera axes are X forward, Y right, Z up. OpenCV optical axes
101
+ # are x right, y down, z forward: optical -> UE = (z, x, -y).
102
+ rays_camera_ue = (optical_to_ue @ rays_optical.T).T
103
+ rays_vehicle = (rotation @ rays_camera_ue.T).T
104
+ dz = rays_vehicle[:, 2]
105
+ with np.errstate(divide="ignore", invalid="ignore"):
106
+ scale = -origin_vehicle[2] / dz
107
+ ground_vehicle = origin_vehicle + scale[:, None] * rays_vehicle
108
+
109
+ # CARLA Y points right; public BEV Y points left.
110
+ x_forward = ground_vehicle[:, 0]
111
+ y_left = -ground_vehicle[:, 1]
112
+ valid = (
113
+ finite
114
+ & np.isfinite(ground_vehicle).all(axis=1)
115
+ & (dz < -1e-8)
116
+ & (scale > 0.0)
117
+ & (x_forward >= bev_range.x_min)
118
+ & (x_forward <= bev_range.x_max)
119
+ & (y_left >= bev_range.y_min)
120
+ & (y_left <= bev_range.y_max)
121
+ )
122
+ return np.column_stack((x_forward[valid], y_left[valid])), valid
123
+
124
+
125
+ def ground_to_pixels(points_xy, calibration=CameraCalibration()):
126
+ """Project ADAS ground points to raw pixels (used for calibration tests)."""
127
+ points_xy = np.asarray(points_xy, dtype=np.float64)
128
+ if points_xy.ndim != 2 or points_xy.shape[1] != 2:
129
+ raise ValueError("points_xy must have shape (N, 2)")
130
+ # ADAS Y-left -> CARLA Y-right.
131
+ points_vehicle = np.column_stack(
132
+ (points_xy[:, 0], -points_xy[:, 1], np.zeros(len(points_xy)))
133
+ )
134
+ mount = calibration.camera_to_vehicle_matrix
135
+ rotation_vehicle_to_camera = mount[:3, :3].T
136
+ points_camera_ue = (
137
+ rotation_vehicle_to_camera
138
+ @ (points_vehicle - mount[:3, 3]).T
139
+ ).T
140
+ points_optical = np.column_stack(
141
+ (points_camera_ue[:, 1], -points_camera_ue[:, 2], points_camera_ue[:, 0])
142
+ )
143
+ projected = (calibration.intrinsic @ points_optical.T).T
144
+ with np.errstate(divide="ignore", invalid="ignore"):
145
+ return projected[:, :2] / projected[:, 2:3]
146
+
147
+
148
+ def model_lane_to_ground(lane, model_width=800, model_height=320,
149
+ calibration=CameraCalibration(),
150
+ bev_range=BevRange()):
151
+ """Convert one decoded RCLane polyline into metric BEV points + scores."""
152
+ if len(lane.points) == 0:
153
+ return np.empty((0, 2)), np.empty(0)
154
+ lane_points = np.asarray(lane.points, dtype=np.float64)
155
+ raw_pixels = lane_points[:, :2].copy()
156
+ raw_pixels[:, 0] *= calibration.width / float(model_width)
157
+ raw_pixels[:, 1] *= calibration.height / float(model_height)
158
+ ground, valid = pixels_to_ground(raw_pixels, calibration, bev_range)
159
+ return ground, lane_points[valid, 2]
160
+
161
+
162
+ def _weighted_lstsq(design, targets, weights):
163
+ root_weight = np.sqrt(np.maximum(weights, 1e-8))
164
+ return np.linalg.lstsq(
165
+ design * root_weight[:, None], targets * root_weight, rcond=None
166
+ )[0]
167
+
168
+
169
+ def fit_cubic_lane(points_xy, point_scores=None, min_points=6,
170
+ huber_delta_m=0.30, iterations=5):
171
+ """Robustly fit ``Y(X) = c0 + c1 X + c2 X^2 + c3 X^3``.
172
+
173
+ X is normalized internally for numerical stability out to 300 metres. The
174
+ returned coefficients are converted back to metric X and ordered
175
+ ``[c0, c1, c2, c3]``.
176
+ """
177
+ points = np.asarray(points_xy, dtype=np.float64)
178
+ if points.ndim != 2 or points.shape[1] != 2:
179
+ raise ValueError("points_xy must have shape (N, 2)")
180
+ finite = np.isfinite(points).all(axis=1)
181
+ points = points[finite]
182
+ if len(points) < min_points:
183
+ return None
184
+ order = np.argsort(points[:, 0])
185
+ x = points[order, 0]
186
+ y = points[order, 1]
187
+ if np.ptp(x) < 1.0:
188
+ return None
189
+
190
+ if point_scores is None:
191
+ base_weights = np.ones(len(x), dtype=np.float64)
192
+ else:
193
+ scores = np.asarray(point_scores, dtype=np.float64)[finite][order]
194
+ base_weights = np.clip(scores, 0.05, 1.0)
195
+
196
+ center = float((x.min() + x.max()) * 0.5)
197
+ scale = float(max((x.max() - x.min()) * 0.5, 1.0))
198
+ z = (x - center) / scale
199
+ design = np.column_stack((np.ones(len(z)), z, z ** 2, z ** 3))
200
+ weights = base_weights.copy()
201
+ coefficients_normalized = _weighted_lstsq(design, y, weights)
202
+ for _ in range(iterations):
203
+ residual = y - design @ coefficients_normalized
204
+ robust_weight = np.minimum(
205
+ 1.0, huber_delta_m / np.maximum(np.abs(residual), 1e-8)
206
+ )
207
+ weights = base_weights * robust_weight
208
+ coefficients_normalized = _weighted_lstsq(design, y, weights)
209
+
210
+ residual = y - design @ coefficients_normalized
211
+ inliers = np.abs(residual) <= max(huber_delta_m, 2.5 * np.median(np.abs(residual)))
212
+ if np.count_nonzero(inliers) >= min_points:
213
+ coefficients_normalized = _weighted_lstsq(
214
+ design[inliers], y[inliers], base_weights[inliers]
215
+ )
216
+ else:
217
+ inliers = np.ones(len(x), dtype=bool)
218
+
219
+ # Compose p((X - center) / scale) and return ascending metric coefficients.
220
+ normalized_polynomial = np.polynomial.Polynomial(coefficients_normalized)
221
+ metric_argument = np.polynomial.Polynomial((-center / scale, 1.0 / scale))
222
+ metric_polynomial = normalized_polynomial(metric_argument)
223
+ coefficients = np.zeros(4, dtype=np.float64)
224
+ coefficients[:len(metric_polynomial.coef)] = metric_polynomial.coef
225
+
226
+ fitted = np.polynomial.polynomial.polyval(x[inliers], coefficients)
227
+ rmse = float(np.sqrt(np.mean((y[inliers] - fitted) ** 2)))
228
+ return {
229
+ "coefficients": coefficients,
230
+ "x_min": float(x[inliers].min()),
231
+ "x_max": float(x[inliers].max()),
232
+ "rmse": rmse,
233
+ "point_count": int(len(x)),
234
+ "inlier_count": int(np.count_nonzero(inliers)),
235
+ "inlier_ratio": float(np.mean(inliers)),
236
+ }
237
+
238
+
239
+ def evaluate_cubic(coefficients, x):
240
+ return np.polynomial.polynomial.polyval(
241
+ np.asarray(x, dtype=np.float64), np.asarray(coefficients, dtype=np.float64)
242
+ )
243
+
244
+
245
+ def _copy_fit(fit):
246
+ copied = dict(fit)
247
+ copied["coefficients"] = np.asarray(
248
+ fit["coefficients"], dtype=np.float64
249
+ ).copy()
250
+ return copied
251
+
252
+
253
+ def clip_cubic_fit_to_funnel(fit, camera_x_m=1.0,
254
+ horizontal_fov_deg=30.0, margin_m=0.10,
255
+ sample_step_m=0.5, minimum_span_m=1.0):
256
+ """Restrict a cubic's declared domain to the visible camera funnel.
257
+
258
+ The polynomial coefficients are left unchanged. Only the longest
259
+ contiguous, physically visible X interval is exported, which makes the
260
+ ``x_domain_m`` contract explicit and prevents a renderer from drawing an
261
+ otherwise valid cubic after it leaves the camera footprint.
262
+ """
263
+ copied = _copy_fit(fit)
264
+ x_min = float(fit["x_min"])
265
+ x_max = float(fit["x_max"])
266
+ report = {
267
+ "original_x_domain_m": [x_min, x_max],
268
+ "x_domain_m": None,
269
+ "clipped": False,
270
+ "valid": False,
271
+ }
272
+ if x_max - x_min < minimum_span_m:
273
+ return None, report
274
+ count = max(3, int(np.ceil((x_max - x_min) / sample_step_m)) + 1)
275
+ x = np.linspace(x_min, x_max, count)
276
+ y = evaluate_cubic(fit["coefficients"], x)
277
+ half_width = np.maximum(0.0, x - camera_x_m) * np.tan(
278
+ np.deg2rad(horizontal_fov_deg * 0.5)
279
+ )
280
+ valid = (
281
+ np.isfinite(y)
282
+ & (x >= camera_x_m)
283
+ & (np.abs(y) <= half_width + margin_m)
284
+ )
285
+
286
+ runs = []
287
+ start = None
288
+ for index, value in enumerate(valid):
289
+ if value and start is None:
290
+ start = index
291
+ if start is not None and (not value or index == len(valid) - 1):
292
+ end = index if value and index == len(valid) - 1 else index - 1
293
+ if x[end] - x[start] >= minimum_span_m:
294
+ runs.append((start, end))
295
+ start = None
296
+ if not runs:
297
+ return None, report
298
+ start, end = max(runs, key=lambda run: x[run[1]] - x[run[0]])
299
+ copied["x_min"] = float(x[start])
300
+ copied["x_max"] = float(x[end])
301
+ report.update({
302
+ "x_domain_m": [copied["x_min"], copied["x_max"]],
303
+ "clipped": bool(
304
+ copied["x_min"] > x_min + 1e-6
305
+ or copied["x_max"] < x_max - 1e-6
306
+ ),
307
+ "valid": True,
308
+ })
309
+ return copied, report
310
+
311
+
312
+ def _self_test():
313
+ calibration = CameraCalibration()
314
+ bev_range = BevRange()
315
+ ground = np.array(
316
+ ((5.0, 0.0), (20.0, 3.5), (50.0, -3.5), (150.0, 2.0),
317
+ (299.0, 0.0)),
318
+ dtype=np.float64,
319
+ )
320
+ pixels = ground_to_pixels(ground, calibration)
321
+ reconstructed, valid = pixels_to_ground(pixels, calibration, bev_range)
322
+ assert valid.all()
323
+ assert np.allclose(reconstructed, ground, atol=1e-5), (
324
+ reconstructed, ground
325
+ )
326
+
327
+ x = np.linspace(5.0, 180.0, 80)
328
+ truth = np.array((1.8, 1.5e-2, -1.2e-4, 3.0e-7))
329
+ y = evaluate_cubic(truth, x)
330
+ y[20] += 4.0
331
+ fitted = fit_cubic_lane(np.column_stack((x, y)))
332
+ assert fitted is not None
333
+ prediction = evaluate_cubic(fitted["coefficients"], x)
334
+ clean = np.ones(len(x), dtype=bool)
335
+ clean[20] = False
336
+ clean_rmse = np.sqrt(np.mean(
337
+ (prediction[clean] - evaluate_cubic(truth, x[clean])) ** 2
338
+ ))
339
+ assert clean_rmse < 0.02
340
+
341
+ runaway_fit = {
342
+ "coefficients": np.array((-5.0, 0.4, -0.013, 1.15e-4)),
343
+ "x_min": 10.0, "x_max": 130.0, "rmse": 0.1,
344
+ "point_count": 50, "inlier_count": 50, "inlier_ratio": 1.0,
345
+ }
346
+ source_coefficients = runaway_fit["coefficients"].copy()
347
+ clipped, funnel_report = clip_cubic_fit_to_funnel(runaway_fit)
348
+ assert clipped is not None and funnel_report["clipped"]
349
+ assert clipped["x_max"] < runaway_fit["x_max"]
350
+ assert np.array_equal(clipped["coefficients"], source_coefficients)
351
+ check_x = np.linspace(clipped["x_min"], clipped["x_max"], 300)
352
+ check_y = evaluate_cubic(clipped["coefficients"], check_x)
353
+ funnel_half_width = (check_x - 1.0) * np.tan(np.deg2rad(15.0))
354
+ assert np.all(np.abs(check_y) <= funnel_half_width + 0.11)
355
+ print("OK -- pixel/ground projection round trip")
356
+ print("OK -- robust metric cubic lane fit")
357
+ print("OK -- raw cubic funnel clipping preserves coefficients")
358
+
359
+
360
+ if __name__ == "__main__":
361
+ _self_test()
build_tensorrt_engine.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build/cache the RCLane TensorRT FP16 engine and verify its outputs.
2
+
3
+ TensorRT is used through ONNX Runtime's TensorRT Execution Provider so the
4
+ cached ``.engine`` remains consumable by the same runtime pipeline. The script
5
+ refuses silent provider fallback and records numerical/performance comparisons
6
+ against CUDA FP32.
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ from test_video_onnx import OUTPUT_NAMES, create_session, timing_summary
18
+
19
+
20
+ def parse_args():
21
+ parser = argparse.ArgumentParser(description=__doc__)
22
+ parser.add_argument("--model", required=True)
23
+ parser.add_argument("--cache-dir", default="exports/trt_cache")
24
+ parser.add_argument("--report", default=None)
25
+ parser.add_argument("--warmup", type=int, default=10)
26
+ parser.add_argument("--iterations", type=int, default=100)
27
+ parser.add_argument("--seed", type=int, default=0)
28
+ return parser.parse_args()
29
+
30
+
31
+ def benchmark(session, images, warmup, iterations):
32
+ for _ in range(warmup):
33
+ session.run(list(OUTPUT_NAMES), {"images": images})
34
+ timings = []
35
+ outputs = None
36
+ for _ in range(iterations):
37
+ started = time.perf_counter()
38
+ outputs = session.run(list(OUTPUT_NAMES), {"images": images})
39
+ timings.append((time.perf_counter() - started) * 1000.0)
40
+ return outputs, timing_summary(timings)
41
+
42
+
43
+ def main():
44
+ args = parse_args()
45
+ if args.warmup < 0 or args.iterations <= 0:
46
+ raise ValueError("warmup must be non-negative and iterations positive")
47
+ model_path = Path(args.model).expanduser().resolve()
48
+ cache_dir = Path(args.cache_dir).expanduser().resolve()
49
+ report_path = (
50
+ Path(args.report).expanduser().resolve()
51
+ if args.report else cache_dir / "build_report.json"
52
+ )
53
+ if not model_path.is_file():
54
+ raise FileNotFoundError(model_path)
55
+ cache_dir.mkdir(parents=True, exist_ok=True)
56
+ report_path.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ rng = np.random.default_rng(args.seed)
59
+ images = rng.normal(size=(1, 3, 320, 800)).astype(np.float32)
60
+
61
+ build_started = time.perf_counter()
62
+ trt_session = create_session(
63
+ model_path, "tensorrt", allow_tf32=False, trt_cache_dir=cache_dir
64
+ )
65
+ build_seconds = time.perf_counter() - build_started
66
+ if trt_session.get_providers()[0] != "TensorrtExecutionProvider":
67
+ raise RuntimeError("TensorRT provider silently fell back")
68
+ cuda_session = create_session(model_path, "cuda", allow_tf32=False)
69
+
70
+ trt_outputs, trt_timing = benchmark(
71
+ trt_session, images, args.warmup, args.iterations
72
+ )
73
+ cuda_outputs, cuda_timing = benchmark(
74
+ cuda_session, images, args.warmup, args.iterations
75
+ )
76
+ comparisons = {}
77
+ for name, trt_output, cuda_output in zip(
78
+ OUTPUT_NAMES, trt_outputs, cuda_outputs
79
+ ):
80
+ difference = trt_output.astype(np.float64) - cuda_output.astype(
81
+ np.float64
82
+ )
83
+ comparisons[name] = {
84
+ "shape": list(trt_output.shape),
85
+ "max_abs": float(np.max(np.abs(difference))),
86
+ "mean_abs": float(np.mean(np.abs(difference))),
87
+ "rmse": float(np.sqrt(np.mean(difference ** 2))),
88
+ }
89
+
90
+ cache_files = []
91
+ for path in sorted(cache_dir.iterdir()):
92
+ if path.is_file():
93
+ cache_files.append({
94
+ "path": str(path),
95
+ "size_bytes": path.stat().st_size,
96
+ })
97
+ engines = [
98
+ item for item in cache_files if item["path"].endswith(".engine")
99
+ ]
100
+ if not engines:
101
+ raise RuntimeError(f"TensorRT did not create an engine in {cache_dir}")
102
+
103
+ report = {
104
+ "model": str(model_path),
105
+ "provider": trt_session.get_providers()[0],
106
+ "precision": "fp16",
107
+ "input_shape": list(images.shape),
108
+ "build_or_cache_load_seconds": build_seconds,
109
+ "tensorrt_timing": trt_timing,
110
+ "cuda_fp32_timing": cuda_timing,
111
+ "speedup_from_median": (
112
+ cuda_timing["median_ms"] / trt_timing["median_ms"]
113
+ ),
114
+ "output_comparison_to_cuda_fp32": comparisons,
115
+ "cache_files": cache_files,
116
+ }
117
+ temporary = report_path.with_suffix(report_path.suffix + ".tmp")
118
+ with temporary.open("w") as handle:
119
+ json.dump(report, handle, indent=2)
120
+ handle.write("\n")
121
+ os.replace(temporary, report_path)
122
+ print(f"TensorRT engine OK: {engines[0]['path']}")
123
+ print(f"report: {report_path}")
124
+ print(
125
+ "median inference: TensorRT={:.3f}ms CUDA={:.3f}ms speedup={:.2f}x".format(
126
+ trt_timing["median_ms"], cuda_timing["median_ms"],
127
+ report["speedup_from_median"],
128
+ )
129
+ )
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
cpp/CMakeLists.txt ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cmake_minimum_required(VERSION 3.20)
2
+ project(rclane_runtime LANGUAGES CXX)
3
+
4
+ set(CMAKE_CXX_STANDARD 17)
5
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
6
+ set(CMAKE_CXX_EXTENSIONS OFF)
7
+
8
+ if(NOT CMAKE_BUILD_TYPE)
9
+ set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
10
+ endif()
11
+
12
+ find_path(TENSORRT_INCLUDE_DIR NvInferRuntime.h
13
+ HINTS "$ENV{TENSORRT_ROOT}/include"
14
+ PATHS /usr/include /usr/include/aarch64-linux-gnu /usr/local/TensorRT/include
15
+ )
16
+ find_library(TENSORRT_LIBRARY NAMES nvinfer libnvinfer.so.10
17
+ HINTS "$ENV{TENSORRT_ROOT}/lib" "$ENV{TENSORRT_ROOT}/lib64"
18
+ PATHS /usr/lib /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu
19
+ )
20
+ find_path(CUDA_INCLUDE_DIR cuda_runtime_api.h
21
+ HINTS "$ENV{CUDA_HOME}/include" "$ENV{CUDA_PATH}/include"
22
+ PATHS /usr/local/cuda/include /usr/include
23
+ )
24
+ find_library(CUDART_LIBRARY NAMES cudart libcudart.so.13 libcudart.so.12
25
+ HINTS "$ENV{CUDA_HOME}/lib64" "$ENV{CUDA_PATH}/lib64"
26
+ PATHS /usr/local/cuda/lib64 /usr/lib/x86_64-linux-gnu
27
+ /usr/lib/aarch64-linux-gnu
28
+ )
29
+
30
+ foreach(required_path
31
+ TENSORRT_INCLUDE_DIR TENSORRT_LIBRARY CUDA_INCLUDE_DIR CUDART_LIBRARY)
32
+ if(NOT ${required_path})
33
+ message(FATAL_ERROR
34
+ "${required_path} was not found; pass -D${required_path}=..."
35
+ )
36
+ endif()
37
+ endforeach()
38
+
39
+ find_package(OpenMP REQUIRED)
40
+
41
+ add_executable(rclane_runtime
42
+ src/bev.cpp
43
+ src/decoder.cpp
44
+ src/main.cpp
45
+ src/preprocess.cpp
46
+ src/tensorrt_runner.cpp
47
+ )
48
+ target_include_directories(rclane_runtime PRIVATE
49
+ include
50
+ "${TENSORRT_INCLUDE_DIR}"
51
+ "${CUDA_INCLUDE_DIR}"
52
+ )
53
+ target_link_libraries(rclane_runtime PRIVATE
54
+ "${TENSORRT_LIBRARY}"
55
+ "${CUDART_LIBRARY}"
56
+ OpenMP::OpenMP_CXX
57
+ dl
58
+ )
59
+ target_compile_options(rclane_runtime PRIVATE
60
+ -O3 -march=native -Wall -Wextra -Wpedantic
61
+ )
62
+
63
+ get_filename_component(TENSORRT_LIBRARY_DIR "${TENSORRT_LIBRARY}" DIRECTORY)
64
+ get_filename_component(CUDART_LIBRARY_DIR "${CUDART_LIBRARY}" DIRECTORY)
65
+ set_target_properties(rclane_runtime PROPERTIES
66
+ BUILD_RPATH "${TENSORRT_LIBRARY_DIR};${CUDART_LIBRARY_DIR}"
67
+ )
cpp/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RCLane native C++ runtime
2
+
3
+ This directory is the sequential deployment runtime. One frame completes
4
+ preprocessing, TensorRT inference, 1024-seed decode and raw-model BEV export
5
+ before the next frame begins. There is no inter-frame overlap. Rendering,
6
+ source-video decoding and video writing are outside the measured core latency.
7
+
8
+ The BEV output is a one-to-one projection of decoded model lanes. It does not
9
+ force curves to be parallel and does not synthesize missing lanes. The funnel
10
+ guard may only shorten a cubic's valid X domain; it never changes coefficients.
11
+
12
+ The build intentionally consumes TensorRT/CUDA SDK headers and shared libraries
13
+ from the target machine; serialized TensorRT engines must be rebuilt per GPU.
14
+
15
+ ```bash
16
+ cmake -S cpp -B cpp/build -G "Unix Makefiles" \
17
+ -DTENSORRT_INCLUDE_DIR=/path/to/TensorRT/include \
18
+ -DTENSORRT_LIBRARY=/path/to/libnvinfer.so \
19
+ -DCUDA_INCLUDE_DIR=/path/to/cuda/include \
20
+ -DCUDART_LIBRARY=/path/to/libcudart.so
21
+ cmake --build cpp/build -j8
22
+ ```
23
+
24
+ TensorRT and CUDA are auto-discovered in standard x86/Jetson locations. The
25
+ explicit `-D` paths above are useful for Python-wheel installations.
26
+
27
+ ## One-frame parity harness
28
+
29
+ The runtime accepts either a preprocessed float32 NCHW tensor or a raw
30
+ 1920x1080 BGR frame. It can dump maps, decoded image lanes and metric BEV
31
+ cubics for comparison with Python:
32
+
33
+ ```bash
34
+ cpp/build/rclane_runtime \
35
+ --engine exports/trt_cache/model.engine \
36
+ --input-bgr /tmp/frame.bgr \
37
+ --dump-prefix /tmp/cpp \
38
+ --lanes-json /tmp/cpp_lanes.json \
39
+ --bev-json /tmp/cpp_bev.json \
40
+ --threads 8
41
+ ```
42
+
43
+ ## Sequential full-video benchmark
44
+
45
+ Pipe decoded BGR frames from FFmpeg. The reported `core_pipeline` contains
46
+ only preprocess + TensorRT (including transfers) + decode + BEV/cubic/funnel.
47
+
48
+ ```bash
49
+ ffmpeg -loglevel error -i raw_Town04_Opt_20260714_093110.mp4 \
50
+ -f rawvideo -pix_fmt bgr24 - | \
51
+ cpp/build/rclane_runtime \
52
+ --engine exports/trt_cache/model.engine \
53
+ --raw-bgr-stdin --source-width 1920 --source-height 1080 \
54
+ --threads 8 --warmup 10 --timing-warmup 5 \
55
+ --frames-jsonl runs/cpp_final_lanes.jsonl \
56
+ --report runs/cpp_benchmark_final_full.json
57
+ ```
58
+
59
+ Render those saved C++ results later without rerunning inference or affecting
60
+ the measured pipeline latency:
61
+
62
+ ```bash
63
+ python cpp/render_cpp_results.py \
64
+ --video raw_Town04_Opt_20260714_093110.mp4 \
65
+ --results runs/cpp_final_lanes.jsonl \
66
+ --benchmark-report runs/cpp_benchmark_final_full.json \
67
+ --output runs/cpp_final_render_h264.mp4
68
+ ```
69
+
70
+ On the development RTX 3050/i5-13420H machine, the complete 1768-frame final
71
+ video measured 13.39 ms median and 14.15 ms p95 core latency (74.67 FPS median)
72
+ with 1024 seeds and 8 CPU threads. TensorRT engines are GPU-architecture
73
+ specific and must be rebuilt on the deployment target.
cpp/THIRD_PARTY_NOTICES.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party notices
2
+
3
+ The native runtime links against NVIDIA TensorRT and the CUDA Runtime supplied
4
+ by the deployment environment. Those libraries are not distributed in this
5
+ repository and remain subject to NVIDIA's respective licenses.
6
+
7
+ Small compatibility routines were independently ported from these upstream
8
+ implementations so C++ reproduces the established Python pipeline:
9
+
10
+ - NumPy `npysort/aquicksort`, used to reproduce the tie ordering of
11
+ `numpy.argsort`: NumPy is distributed under the BSD 3-Clause license.
12
+ Source: <https://github.com/numpy/numpy/blob/v1.26.4/numpy/core/src/npysort/quicksort.cpp>
13
+ - OpenCV fixed-point bilinear resize arithmetic, used to reproduce
14
+ `cv2.dnn.blobFromImage`: OpenCV 4.11 is distributed under the Apache License
15
+ 2.0. Source:
16
+ <https://github.com/opencv/opencv/blob/4.11.0/modules/imgproc/src/resize.cpp>
17
+
18
+ The full upstream license texts and copyright notices are available at the
19
+ linked projects.
cpp/include/bev.hpp ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include "decoder.hpp"
4
+
5
+ #include <array>
6
+ #include <cstddef>
7
+ #include <string>
8
+ #include <vector>
9
+
10
+ namespace rclane {
11
+
12
+ struct GroundPoint {
13
+ double x{};
14
+ double y{};
15
+ double score{};
16
+ };
17
+
18
+ struct CubicFit {
19
+ std::array<double, 4> coefficients{};
20
+ double x_min{};
21
+ double x_max{};
22
+ double rmse{};
23
+ std::size_t point_count{};
24
+ std::size_t inlier_count{};
25
+ bool valid{};
26
+ };
27
+
28
+ struct BevLane {
29
+ int lane_id{};
30
+ std::string role;
31
+ double score{};
32
+ std::vector<GroundPoint> points;
33
+ CubicFit fit;
34
+ bool fit_accepted{};
35
+ bool funnel_clipped{};
36
+ };
37
+
38
+ struct BevConfig {
39
+ double x_min{0.0};
40
+ double x_max{300.0};
41
+ double y_min{-85.0};
42
+ double y_max{85.0};
43
+ double maximum_rmse{0.5};
44
+ double funnel_margin{0.10};
45
+ };
46
+
47
+ std::vector<BevLane> project_lanes_to_bev(
48
+ const std::vector<Lane>& lanes,
49
+ const BevConfig& config = {}
50
+ );
51
+
52
+ void write_bev_json(
53
+ const std::string& path,
54
+ const std::vector<BevLane>& lanes
55
+ );
56
+
57
+ } // namespace rclane
cpp/include/decoder.hpp ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include "tensorrt_runner.hpp"
4
+
5
+ #include <cstddef>
6
+ #include <string>
7
+ #include <unordered_map>
8
+ #include <vector>
9
+
10
+ namespace rclane {
11
+
12
+ struct LanePoint {
13
+ float x{};
14
+ float y{};
15
+ float score{};
16
+ };
17
+
18
+ struct Lane {
19
+ int width{};
20
+ int height{};
21
+ std::vector<LanePoint> points;
22
+ double score_sum{};
23
+ int lane_id{};
24
+ std::string role;
25
+ bool ego_boundary{};
26
+ int lateral_rank{};
27
+
28
+ double score() const;
29
+ };
30
+
31
+ struct DecoderConfig {
32
+ float step_length{10.0F};
33
+ float segmentation_threshold{0.5F};
34
+ float seed_threshold{0.5F};
35
+ int seed_min_distance{2};
36
+ float score_threshold{0.10F};
37
+ float iou_threshold{0.5F};
38
+ int max_seeds{1024};
39
+ int nms_max_lanes{128};
40
+ float nms_scale{0.25F};
41
+ int lane_width{15};
42
+ int max_output_lanes{4};
43
+ float ego_x{400.0F};
44
+ float ego_min_score_ratio{0.5F};
45
+ int threads{8};
46
+ };
47
+
48
+ struct DecodeStatistics {
49
+ std::size_t foreground_pixels{};
50
+ std::size_t seeds{};
51
+ std::size_t crawled_candidates{};
52
+ std::size_t nms_candidates{};
53
+ std::size_t nms_survivors{};
54
+ };
55
+
56
+ std::vector<float> softmax_foreground(const Tensor& segmentation_logits);
57
+
58
+ std::vector<Lane> decode(
59
+ const std::vector<float>& segmentation_probability,
60
+ const Tensor& up_arrow,
61
+ const Tensor& down_arrow,
62
+ const Tensor& up_bound,
63
+ const Tensor& down_bound,
64
+ const DecoderConfig& config = {},
65
+ DecodeStatistics* statistics = nullptr
66
+ );
67
+
68
+ std::vector<Lane> decode_outputs(
69
+ const std::unordered_map<std::string, Tensor>& outputs,
70
+ const DecoderConfig& config = {},
71
+ DecodeStatistics* statistics = nullptr
72
+ );
73
+
74
+ void write_lanes_json(
75
+ const std::string& path,
76
+ const std::vector<Lane>& lanes,
77
+ const DecodeStatistics* statistics = nullptr
78
+ );
79
+
80
+ } // namespace rclane
cpp/include/preprocess.hpp ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include <cstdint>
4
+ #include <vector>
5
+
6
+ namespace rclane {
7
+
8
+ void normalize_bgr_to_nchw(
9
+ const std::uint8_t* bgr,
10
+ int source_width,
11
+ int source_height,
12
+ std::vector<float>& destination
13
+ );
14
+
15
+ } // namespace rclane
cpp/include/tensorrt_runner.hpp ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include <cstddef>
4
+ #include <cstdint>
5
+ #include <memory>
6
+ #include <string>
7
+ #include <unordered_map>
8
+ #include <vector>
9
+
10
+ namespace rclane {
11
+
12
+ struct Tensor {
13
+ std::vector<std::int64_t> shape;
14
+ std::vector<float> values;
15
+ };
16
+
17
+ struct InferenceTiming {
18
+ double mean_ms{};
19
+ double median_ms{};
20
+ double p95_ms{};
21
+ double min_ms{};
22
+ double max_ms{};
23
+ };
24
+
25
+ class TensorRTRunner {
26
+ public:
27
+ explicit TensorRTRunner(const std::string& engine_path);
28
+ ~TensorRTRunner();
29
+
30
+ TensorRTRunner(const TensorRTRunner&) = delete;
31
+ TensorRTRunner& operator=(const TensorRTRunner&) = delete;
32
+ TensorRTRunner(TensorRTRunner&&) noexcept;
33
+ TensorRTRunner& operator=(TensorRTRunner&&) noexcept;
34
+
35
+ const std::vector<std::int64_t>& input_shape() const;
36
+ std::size_t input_elements() const;
37
+ std::unordered_map<std::string, Tensor> infer(const float* input);
38
+ const std::unordered_map<std::string, Tensor>& infer_reuse(
39
+ const float* input
40
+ );
41
+ InferenceTiming benchmark(
42
+ const float* input, int warmup, int iterations
43
+ );
44
+
45
+ private:
46
+ class Impl;
47
+ std::unique_ptr<Impl> impl_;
48
+ };
49
+
50
+ } // namespace rclane
cpp/render_cpp_results.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render native C++ decoded lanes in a separate, untimed video pass."""
2
+
3
+ import argparse
4
+ from collections import deque
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import cv2
12
+ import imageio_ffmpeg
13
+ import numpy as np
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16
+
17
+ from decode import Lane
18
+ from bev import BevRange, CameraCalibration
19
+ from test_video_bev_onnx import draw_bev, make_composite
20
+ from test_video_onnx import draw_predictions
21
+
22
+
23
+ def parse_args():
24
+ parser = argparse.ArgumentParser(description=__doc__)
25
+ parser.add_argument("--video", required=True)
26
+ parser.add_argument("--results", required=True)
27
+ parser.add_argument("--output", required=True)
28
+ parser.add_argument("--benchmark-report", default=None)
29
+ return parser.parse_args()
30
+
31
+
32
+ def load_lane(payload):
33
+ lane = Lane(800, 320)
34
+ lane.points = np.asarray(payload["points"], dtype=np.float32)
35
+ lane._score_sum = float(payload["score"]) * len(lane.points)
36
+ lane.lane_id = int(payload["lane_id"])
37
+ lane.lane_role = payload["role"]
38
+ lane.is_ego_boundary = lane.lane_role in ("ego_left", "ego_right")
39
+ lane.lateral_rank = None
40
+ return lane
41
+
42
+
43
+ def load_bev_result(payload):
44
+ fit_payload = payload["fit"]
45
+ fit = None
46
+ if payload["fit_accepted"] and fit_payload is not None:
47
+ fit = {
48
+ "coefficients": np.asarray(
49
+ fit_payload["coefficients"], dtype=np.float64
50
+ ),
51
+ "x_min": float(fit_payload["x_min"]),
52
+ "x_max": float(fit_payload["x_max"]),
53
+ "rmse": float(fit_payload["rmse"]),
54
+ "point_count": int(fit_payload["point_count"]),
55
+ "inlier_count": int(fit_payload["inlier_count"]),
56
+ }
57
+ lane_id = int(payload["lane_id"])
58
+ record = {
59
+ "lane_id": f"P{lane_id}",
60
+ "lane_index": lane_id,
61
+ "role": payload["role"],
62
+ "score": float(payload["score"]),
63
+ "valid_fit": fit is not None,
64
+ }
65
+ if fit_payload is not None:
66
+ record["rmse_m"] = float(fit_payload["rmse"])
67
+ points = np.asarray(payload["points"], dtype=np.float64)
68
+ return {
69
+ "record": record,
70
+ "points": points[:, :2] if points.size else np.empty((0, 2)),
71
+ "fit": fit,
72
+ }
73
+
74
+
75
+ def funnel_report(bev_payloads):
76
+ return {
77
+ "mode": "raw_model_projection",
78
+ "parallel_assumption": False,
79
+ "synthetic_lanes": False,
80
+ "clipped_lanes": [
81
+ f"P{item['lane_id']}" for item in bev_payloads
82
+ if item["funnel_clipped"]
83
+ ],
84
+ "rejected_lanes": [
85
+ f"P{item['lane_id']}" for item in bev_payloads
86
+ if item["fit"] is not None and not item["fit_accepted"]
87
+ ],
88
+ }
89
+
90
+
91
+ def main():
92
+ args = parse_args()
93
+ video_path = Path(args.video).expanduser().resolve()
94
+ results_path = Path(args.results).expanduser().resolve()
95
+ output_path = Path(args.output).expanduser().resolve()
96
+ output_path.parent.mkdir(parents=True, exist_ok=True)
97
+ calibration = CameraCalibration()
98
+ bev_range = BevRange()
99
+
100
+ capture = cv2.VideoCapture(str(video_path))
101
+ if not capture.isOpened():
102
+ raise RuntimeError(f"cannot open video: {video_path}")
103
+ fps = float(capture.get(cv2.CAP_PROP_FPS))
104
+ width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
105
+ height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
106
+ temporary = output_path.with_name(output_path.stem + ".mp4v.mp4")
107
+ writer = cv2.VideoWriter(
108
+ str(temporary), cv2.VideoWriter_fourcc(*"mp4v"), fps,
109
+ (width, height),
110
+ )
111
+ if not writer.isOpened():
112
+ capture.release()
113
+ raise RuntimeError(f"cannot create temporary video: {temporary}")
114
+
115
+ rendered = 0
116
+ rolling_core_ms = deque(maxlen=20)
117
+ try:
118
+ with results_path.open() as results:
119
+ for line in results:
120
+ payload = json.loads(line)
121
+ ok, frame = capture.read()
122
+ if not ok:
123
+ raise RuntimeError(
124
+ f"video ended before result frame {payload['frame_index']}"
125
+ )
126
+ lanes = [load_lane(item) for item in payload["lanes"]]
127
+ bev_payloads = payload["bev_lanes"]
128
+ lane_results = [
129
+ load_bev_result(item) for item in bev_payloads
130
+ ]
131
+ guard = funnel_report(bev_payloads)
132
+ timing = payload["timing"]
133
+ rolling_core_ms.append(float(timing["core_ms"]))
134
+ rolling_fps = 1000.0 / max(
135
+ float(np.mean(rolling_core_ms)), 1e-9
136
+ )
137
+ draw_predictions(frame, lanes)
138
+ bev_canvas = draw_bev(
139
+ lane_results, bev_range, calibration, guard
140
+ )
141
+ composite = make_composite(
142
+ frame, bev_canvas, int(payload["frame_index"]),
143
+ sum(item["fit"] is not None for item in lane_results),
144
+ float(timing["core_ms"]), guard,
145
+ result_generation_fps=rolling_fps,
146
+ )
147
+ detail = (
148
+ f"C++ current={timing['core_ms']:.1f}ms | "
149
+ f"infer={timing['inference_ms']:.1f} "
150
+ f"decode={timing['decode_ms']:.1f} "
151
+ f"BEV-result={timing['bev_result_ms']:.2f}ms | "
152
+ "draw/encode excluded"
153
+ )
154
+ cv2.putText(
155
+ composite, detail, (664, 101),
156
+ cv2.FONT_HERSHEY_SIMPLEX, 0.50, (0, 0, 0), 4,
157
+ cv2.LINE_AA,
158
+ )
159
+ cv2.putText(
160
+ composite, detail, (664, 101),
161
+ cv2.FONT_HERSHEY_SIMPLEX, 0.50, (255, 255, 255), 1,
162
+ cv2.LINE_AA,
163
+ )
164
+ writer.write(composite)
165
+ rendered += 1
166
+ if rendered % 200 == 0:
167
+ print(f"rendered {rendered} frames", flush=True)
168
+ finally:
169
+ capture.release()
170
+ writer.release()
171
+
172
+ encoded = output_path.with_name(output_path.stem + ".h264.tmp.mp4")
173
+ subprocess.run(
174
+ [
175
+ imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-loglevel", "error",
176
+ "-i", str(temporary), "-c:v", "libx264", "-preset", "fast",
177
+ "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart",
178
+ str(encoded),
179
+ ],
180
+ check=True,
181
+ )
182
+ os.replace(encoded, output_path)
183
+ temporary.unlink(missing_ok=True)
184
+ print(f"rendered video: {output_path} ({rendered} frames)")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
cpp/src/bev.cpp ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "bev.hpp"
2
+
3
+ #include <algorithm>
4
+ #include <array>
5
+ #include <cmath>
6
+ #include <fstream>
7
+ #include <iomanip>
8
+ #include <limits>
9
+ #include <stdexcept>
10
+ #include <vector>
11
+
12
+ namespace rclane {
13
+ namespace {
14
+
15
+ constexpr double kFx = 3582.768775;
16
+ constexpr double kFy = 3582.768775;
17
+ constexpr double kCx = 960.0;
18
+ constexpr double kCy = 540.0;
19
+ constexpr double kRawWidth = 1920.0;
20
+ constexpr double kRawHeight = 1080.0;
21
+ constexpr double kModelWidth = 800.0;
22
+ constexpr double kModelHeight = 320.0;
23
+ constexpr double kCameraX = 1.0;
24
+ constexpr double kCameraZ = 1.8;
25
+ constexpr double kCosPitch = 0.997564;
26
+ constexpr double kSinDown = 0.069756;
27
+ constexpr double kPi = 3.14159265358979323846;
28
+
29
+ bool solve_four_by_four(
30
+ std::array<std::array<double, 4>, 4> matrix,
31
+ std::array<double, 4> target,
32
+ std::array<double, 4>& solution
33
+ ) {
34
+ for (int column = 0; column < 4; ++column) {
35
+ int pivot = column;
36
+ for (int row = column + 1; row < 4; ++row) {
37
+ if (std::abs(matrix[row][column]) > std::abs(matrix[pivot][column])) {
38
+ pivot = row;
39
+ }
40
+ }
41
+ if (std::abs(matrix[pivot][column]) < 1e-12) {
42
+ return false;
43
+ }
44
+ std::swap(matrix[pivot], matrix[column]);
45
+ std::swap(target[pivot], target[column]);
46
+ const double divisor = matrix[column][column];
47
+ for (int index = column; index < 4; ++index) {
48
+ matrix[column][index] /= divisor;
49
+ }
50
+ target[column] /= divisor;
51
+ for (int row = 0; row < 4; ++row) {
52
+ if (row == column) {
53
+ continue;
54
+ }
55
+ const double factor = matrix[row][column];
56
+ for (int index = column; index < 4; ++index) {
57
+ matrix[row][index] -= factor * matrix[column][index];
58
+ }
59
+ target[row] -= factor * target[column];
60
+ }
61
+ }
62
+ solution = target;
63
+ return true;
64
+ }
65
+
66
+ bool weighted_fit(
67
+ const std::vector<double>& z,
68
+ const std::vector<double>& y,
69
+ const std::vector<double>& weights,
70
+ const std::vector<std::uint8_t>* include,
71
+ std::array<double, 4>& coefficients
72
+ ) {
73
+ std::array<std::array<double, 4>, 4> normal{};
74
+ std::array<double, 4> target{};
75
+ for (std::size_t index = 0; index < z.size(); ++index) {
76
+ if (include != nullptr && (*include)[index] == 0U) {
77
+ continue;
78
+ }
79
+ const std::array<double, 4> row{1.0, z[index], z[index] * z[index],
80
+ z[index] * z[index] * z[index]};
81
+ for (int i = 0; i < 4; ++i) {
82
+ target[i] += weights[index] * row[i] * y[index];
83
+ for (int j = 0; j < 4; ++j) {
84
+ normal[i][j] += weights[index] * row[i] * row[j];
85
+ }
86
+ }
87
+ }
88
+ return solve_four_by_four(normal, target, coefficients);
89
+ }
90
+
91
+ double evaluate(const std::array<double, 4>& c, double x) {
92
+ return ((c[3] * x + c[2]) * x + c[1]) * x + c[0];
93
+ }
94
+
95
+ std::vector<GroundPoint> project_lane(
96
+ const Lane& lane, const BevConfig& config
97
+ ) {
98
+ std::vector<GroundPoint> ground;
99
+ ground.reserve(lane.points.size());
100
+ for (const auto& point : lane.points) {
101
+ const double u = point.x * (kRawWidth / kModelWidth);
102
+ const double v = point.y * (kRawHeight / kModelHeight);
103
+ const double ray_right = (u - kCx) / kFx;
104
+ const double ray_down = (v - kCy) / kFy;
105
+ // OpenCV optical -> UE camera is (forward,right,up)=(1,x,-y),
106
+ // followed by the fixed camera-to-vehicle pitch rotation.
107
+ const double ray_x = kCosPitch - kSinDown * ray_down;
108
+ const double ray_y = ray_right;
109
+ const double ray_z = -kSinDown - kCosPitch * ray_down;
110
+ if (!(ray_z < -1e-8)) {
111
+ continue;
112
+ }
113
+ const double scale = -kCameraZ / ray_z;
114
+ if (!(scale > 0.0) || !std::isfinite(scale)) {
115
+ continue;
116
+ }
117
+ const double x = kCameraX + scale * ray_x;
118
+ const double y_left = -scale * ray_y;
119
+ if (std::isfinite(x) && std::isfinite(y_left)
120
+ && x >= config.x_min && x <= config.x_max
121
+ && y_left >= config.y_min && y_left <= config.y_max) {
122
+ ground.push_back({x, y_left, point.score});
123
+ }
124
+ }
125
+ return ground;
126
+ }
127
+
128
+ CubicFit fit_cubic(const std::vector<GroundPoint>& input) {
129
+ CubicFit fit;
130
+ fit.point_count = input.size();
131
+ if (input.size() < 6) {
132
+ return fit;
133
+ }
134
+ std::vector<GroundPoint> points = input;
135
+ std::sort(points.begin(), points.end(), [](const GroundPoint& lhs,
136
+ const GroundPoint& rhs) {
137
+ return lhs.x < rhs.x;
138
+ });
139
+ const double minimum_x = points.front().x;
140
+ const double maximum_x = points.back().x;
141
+ if (maximum_x - minimum_x < 1.0) {
142
+ return fit;
143
+ }
144
+ const double center = (minimum_x + maximum_x) * 0.5;
145
+ const double scale = std::max((maximum_x - minimum_x) * 0.5, 1.0);
146
+ std::vector<double> z(points.size());
147
+ std::vector<double> y(points.size());
148
+ std::vector<double> base(points.size());
149
+ for (std::size_t index = 0; index < points.size(); ++index) {
150
+ z[index] = (points[index].x - center) / scale;
151
+ y[index] = points[index].y;
152
+ base[index] = std::clamp(points[index].score, 0.05, 1.0);
153
+ }
154
+ std::vector<double> weights = base;
155
+ std::array<double, 4> normalized{};
156
+ if (!weighted_fit(z, y, weights, nullptr, normalized)) {
157
+ return fit;
158
+ }
159
+ for (int iteration = 0; iteration < 3; ++iteration) {
160
+ for (std::size_t index = 0; index < points.size(); ++index) {
161
+ const double residual = y[index] - evaluate(normalized, z[index]);
162
+ const double robust = std::min(
163
+ 1.0, 0.30 / std::max(std::abs(residual), 1e-8)
164
+ );
165
+ weights[index] = base[index] * robust;
166
+ }
167
+ if (!weighted_fit(z, y, weights, nullptr, normalized)) {
168
+ return fit;
169
+ }
170
+ }
171
+ std::vector<double> absolute_residual(points.size());
172
+ for (std::size_t index = 0; index < points.size(); ++index) {
173
+ absolute_residual[index] = std::abs(
174
+ y[index] - evaluate(normalized, z[index])
175
+ );
176
+ }
177
+ std::vector<double> sorted_residual = absolute_residual;
178
+ std::sort(sorted_residual.begin(), sorted_residual.end());
179
+ const std::size_t middle = sorted_residual.size() / 2;
180
+ const double median_residual = (sorted_residual.size() & 1U) != 0U
181
+ ? sorted_residual[middle]
182
+ : (sorted_residual[middle - 1] + sorted_residual[middle]) * 0.5;
183
+ const double threshold = std::max(0.30, 2.5 * median_residual);
184
+ std::vector<std::uint8_t> inliers(points.size(), 0U);
185
+ std::size_t inlier_count = 0;
186
+ for (std::size_t index = 0; index < points.size(); ++index) {
187
+ inliers[index] = static_cast<std::uint8_t>(
188
+ absolute_residual[index] <= threshold
189
+ );
190
+ inlier_count += inliers[index];
191
+ }
192
+ if (inlier_count >= 6) {
193
+ if (!weighted_fit(z, y, base, &inliers, normalized)) {
194
+ return fit;
195
+ }
196
+ } else {
197
+ std::fill(inliers.begin(), inliers.end(), 1U);
198
+ inlier_count = points.size();
199
+ }
200
+
201
+ const double inverse_scale = 1.0 / scale;
202
+ const double argument_constant = -center * inverse_scale;
203
+ const double argument_linear = inverse_scale;
204
+ fit.coefficients[0] = normalized[0]
205
+ + normalized[1] * argument_constant
206
+ + normalized[2] * argument_constant * argument_constant
207
+ + normalized[3] * argument_constant * argument_constant * argument_constant;
208
+ fit.coefficients[1] = normalized[1] * argument_linear
209
+ + 2.0 * normalized[2] * argument_constant * argument_linear
210
+ + 3.0 * normalized[3] * argument_constant * argument_constant * argument_linear;
211
+ fit.coefficients[2] = normalized[2] * argument_linear * argument_linear
212
+ + 3.0 * normalized[3] * argument_constant * argument_linear * argument_linear;
213
+ fit.coefficients[3] = normalized[3] * argument_linear * argument_linear * argument_linear;
214
+
215
+ fit.x_min = std::numeric_limits<double>::infinity();
216
+ fit.x_max = -std::numeric_limits<double>::infinity();
217
+ double square_error = 0.0;
218
+ for (std::size_t index = 0; index < points.size(); ++index) {
219
+ if (inliers[index] == 0U) {
220
+ continue;
221
+ }
222
+ fit.x_min = std::min(fit.x_min, points[index].x);
223
+ fit.x_max = std::max(fit.x_max, points[index].x);
224
+ const double residual = points[index].y
225
+ - evaluate(fit.coefficients, points[index].x);
226
+ square_error += residual * residual;
227
+ }
228
+ fit.inlier_count = inlier_count;
229
+ fit.rmse = std::sqrt(square_error / static_cast<double>(inlier_count));
230
+ fit.valid = true;
231
+ return fit;
232
+ }
233
+
234
+ bool clip_to_funnel(CubicFit& fit, double margin, bool& clipped) {
235
+ clipped = false;
236
+ if (!fit.valid || fit.x_max - fit.x_min < 1.0) {
237
+ return false;
238
+ }
239
+ const int count = std::max(
240
+ 3, static_cast<int>(std::ceil((fit.x_max - fit.x_min) / 0.5)) + 1
241
+ );
242
+ std::vector<double> x(static_cast<std::size_t>(count));
243
+ std::vector<std::uint8_t> valid(static_cast<std::size_t>(count), 0U);
244
+ const double tangent = std::tan(15.0 * kPi / 180.0);
245
+ for (int index = 0; index < count; ++index) {
246
+ x[static_cast<std::size_t>(index)] = fit.x_min
247
+ + (fit.x_max - fit.x_min) * static_cast<double>(index)
248
+ / static_cast<double>(count - 1);
249
+ const double y = evaluate(fit.coefficients, x[static_cast<std::size_t>(index)]);
250
+ const double half_width = std::max(
251
+ 0.0, x[static_cast<std::size_t>(index)] - kCameraX
252
+ ) * tangent;
253
+ valid[static_cast<std::size_t>(index)] = static_cast<std::uint8_t>(
254
+ std::isfinite(y)
255
+ && x[static_cast<std::size_t>(index)] >= kCameraX
256
+ && std::abs(y) <= half_width + margin
257
+ );
258
+ }
259
+ int best_start = -1;
260
+ int best_end = -1;
261
+ int start = -1;
262
+ for (int index = 0; index < count; ++index) {
263
+ if (valid[static_cast<std::size_t>(index)] != 0U && start < 0) {
264
+ start = index;
265
+ }
266
+ if (start >= 0 && (valid[static_cast<std::size_t>(index)] == 0U
267
+ || index == count - 1)) {
268
+ const int end = valid[static_cast<std::size_t>(index)] != 0U
269
+ && index == count - 1 ? index : index - 1;
270
+ if (x[static_cast<std::size_t>(end)] - x[static_cast<std::size_t>(start)] >= 1.0
271
+ && (best_start < 0
272
+ || x[static_cast<std::size_t>(end)] - x[static_cast<std::size_t>(start)]
273
+ > x[static_cast<std::size_t>(best_end)] - x[static_cast<std::size_t>(best_start)])) {
274
+ best_start = start;
275
+ best_end = end;
276
+ }
277
+ start = -1;
278
+ }
279
+ }
280
+ if (best_start < 0) {
281
+ return false;
282
+ }
283
+ const double original_min = fit.x_min;
284
+ const double original_max = fit.x_max;
285
+ fit.x_min = x[static_cast<std::size_t>(best_start)];
286
+ fit.x_max = x[static_cast<std::size_t>(best_end)];
287
+ clipped = fit.x_min > original_min + 1e-6
288
+ || fit.x_max < original_max - 1e-6;
289
+ return true;
290
+ }
291
+
292
+ } // namespace
293
+
294
+ std::vector<BevLane> project_lanes_to_bev(
295
+ const std::vector<Lane>& lanes,
296
+ const BevConfig& config
297
+ ) {
298
+ std::vector<BevLane> result;
299
+ result.reserve(lanes.size());
300
+ for (const Lane& lane : lanes) {
301
+ BevLane output;
302
+ output.lane_id = lane.lane_id;
303
+ output.role = lane.role;
304
+ output.score = lane.score();
305
+ output.points = project_lane(lane, config);
306
+ output.fit = fit_cubic(output.points);
307
+ output.fit_accepted = output.fit.valid
308
+ && output.fit.rmse <= config.maximum_rmse;
309
+ if (output.fit_accepted) {
310
+ output.fit_accepted = clip_to_funnel(
311
+ output.fit, config.funnel_margin, output.funnel_clipped
312
+ );
313
+ }
314
+ result.push_back(std::move(output));
315
+ }
316
+ return result;
317
+ }
318
+
319
+ void write_bev_json(
320
+ const std::string& path,
321
+ const std::vector<BevLane>& lanes
322
+ ) {
323
+ std::ofstream stream(path);
324
+ if (!stream) {
325
+ throw std::runtime_error("cannot write BEV JSON: " + path);
326
+ }
327
+ stream << std::setprecision(12) << "{\n \"mode\": \"raw_model_projection\",\n"
328
+ << " \"parallel_assumption\": false,\n"
329
+ << " \"synthetic_lanes\": false,\n \"lanes\": [\n";
330
+ for (std::size_t index = 0; index < lanes.size(); ++index) {
331
+ const BevLane& lane = lanes[index];
332
+ stream << " {\"lane_id\": " << lane.lane_id
333
+ << ", \"role\": \"" << lane.role
334
+ << "\", \"score\": " << lane.score
335
+ << ", \"projected_point_count\": " << lane.points.size()
336
+ << ", \"valid_fit\": " << (lane.fit_accepted ? "true" : "false");
337
+ if (lane.fit.valid) {
338
+ stream << ", \"coefficients_c0_to_c3\": ["
339
+ << lane.fit.coefficients[0] << ',' << lane.fit.coefficients[1]
340
+ << ',' << lane.fit.coefficients[2] << ',' << lane.fit.coefficients[3]
341
+ << "], \"x_domain_m\": [" << lane.fit.x_min << ',' << lane.fit.x_max
342
+ << "], \"rmse_m\": " << lane.fit.rmse
343
+ << ", \"inlier_count\": " << lane.fit.inlier_count
344
+ << ", \"funnel_clipped\": "
345
+ << (lane.funnel_clipped ? "true" : "false");
346
+ }
347
+ stream << '}' << (index + 1 == lanes.size() ? "\n" : ",\n");
348
+ }
349
+ stream << " ]\n}\n";
350
+ }
351
+
352
+ } // namespace rclane
cpp/src/decoder.cpp ADDED
@@ -0,0 +1,796 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "decoder.hpp"
2
+
3
+ #include <algorithm>
4
+ #include <cmath>
5
+ #include <cstdint>
6
+ #include <fstream>
7
+ #include <iomanip>
8
+ #include <limits>
9
+ #include <numeric>
10
+ #include <stdexcept>
11
+ #include <unordered_map>
12
+ #include <utility>
13
+
14
+ #include <omp.h>
15
+
16
+ namespace rclane {
17
+ namespace {
18
+
19
+ constexpr int kMapWidth = 800;
20
+ constexpr int kMapHeight = 320;
21
+
22
+ struct Seed {
23
+ int x{};
24
+ int y{};
25
+ float probability{};
26
+ };
27
+
28
+ std::vector<std::size_t> numpy_float_argquicksort(
29
+ const std::vector<Seed>& candidates
30
+ ) {
31
+ // Partition structure follows NumPy's BSD-licensed npysort aquicksort;
32
+ // see cpp/THIRD_PARTY_NOTICES.md.
33
+ // Match NumPy 1.26's default np.argsort quicksort, including its
34
+ // deterministic (but unstable) ordering of equal saturated probabilities.
35
+ // This matters because the segmentation map contains long probability=1
36
+ // plateaus and greedy point-NMS consumes candidates in argsort order.
37
+ std::vector<std::size_t> order(candidates.size());
38
+ std::iota(order.begin(), order.end(), std::size_t{0});
39
+ if (order.size() <= 1) {
40
+ return order;
41
+ }
42
+ const auto key = [&candidates](std::size_t index) {
43
+ return -candidates[index].probability;
44
+ };
45
+ const auto less = [&key](std::size_t lhs, std::size_t rhs) {
46
+ return key(lhs) < key(rhs);
47
+ };
48
+ struct Partition {
49
+ std::ptrdiff_t left{};
50
+ std::ptrdiff_t right{};
51
+ int depth{};
52
+ };
53
+ std::vector<Partition> stack;
54
+ stack.reserve(128);
55
+ std::ptrdiff_t left = 0;
56
+ std::ptrdiff_t right = static_cast<std::ptrdiff_t>(order.size() - 1);
57
+ int most_significant_bit = 0;
58
+ for (std::size_t size = order.size(); size > 1; size >>= 1U) {
59
+ ++most_significant_bit;
60
+ }
61
+ int depth = most_significant_bit * 2;
62
+ for (;;) {
63
+ if (depth < 0) {
64
+ // NumPy switches to arg-heapsort here. This branch is not reached
65
+ // by the map sizes/distributions used by RCLane; retain a safe
66
+ // deterministic fallback for adversarial inputs.
67
+ std::sort(
68
+ order.begin() + left, order.begin() + right + 1, less
69
+ );
70
+ goto pop_partition;
71
+ }
72
+ while (right - left > 15) {
73
+ const std::ptrdiff_t middle = left + ((right - left) >> 1);
74
+ if (less(order[static_cast<std::size_t>(middle)],
75
+ order[static_cast<std::size_t>(left)])) {
76
+ std::swap(order[static_cast<std::size_t>(middle)],
77
+ order[static_cast<std::size_t>(left)]);
78
+ }
79
+ if (less(order[static_cast<std::size_t>(right)],
80
+ order[static_cast<std::size_t>(middle)])) {
81
+ std::swap(order[static_cast<std::size_t>(right)],
82
+ order[static_cast<std::size_t>(middle)]);
83
+ }
84
+ if (less(order[static_cast<std::size_t>(middle)],
85
+ order[static_cast<std::size_t>(left)])) {
86
+ std::swap(order[static_cast<std::size_t>(middle)],
87
+ order[static_cast<std::size_t>(left)]);
88
+ }
89
+ const float pivot = key(order[static_cast<std::size_t>(middle)]);
90
+ std::ptrdiff_t i = left;
91
+ std::ptrdiff_t j = right - 1;
92
+ std::swap(order[static_cast<std::size_t>(middle)],
93
+ order[static_cast<std::size_t>(j)]);
94
+ for (;;) {
95
+ do {
96
+ ++i;
97
+ } while (key(order[static_cast<std::size_t>(i)]) < pivot);
98
+ do {
99
+ --j;
100
+ } while (pivot < key(order[static_cast<std::size_t>(j)]));
101
+ if (i >= j) {
102
+ break;
103
+ }
104
+ std::swap(order[static_cast<std::size_t>(i)],
105
+ order[static_cast<std::size_t>(j)]);
106
+ }
107
+ std::swap(order[static_cast<std::size_t>(i)],
108
+ order[static_cast<std::size_t>(right - 1)]);
109
+ --depth;
110
+ if (i - left < right - i) {
111
+ stack.push_back({i + 1, right, depth});
112
+ right = i - 1;
113
+ } else {
114
+ stack.push_back({left, i - 1, depth});
115
+ left = i + 1;
116
+ }
117
+ }
118
+ for (std::ptrdiff_t i = left + 1; i <= right; ++i) {
119
+ const std::size_t value = order[static_cast<std::size_t>(i)];
120
+ std::ptrdiff_t position = i;
121
+ std::ptrdiff_t previous = i - 1;
122
+ while (position > left
123
+ && key(value) < key(order[static_cast<std::size_t>(previous)])) {
124
+ order[static_cast<std::size_t>(position--)]
125
+ = order[static_cast<std::size_t>(previous--)];
126
+ }
127
+ order[static_cast<std::size_t>(position)] = value;
128
+ }
129
+ pop_partition:
130
+ if (stack.empty()) {
131
+ break;
132
+ }
133
+ const Partition next = stack.back();
134
+ stack.pop_back();
135
+ left = next.left;
136
+ right = next.right;
137
+ depth = next.depth;
138
+ }
139
+ return order;
140
+ }
141
+
142
+ float median(std::vector<float> values) {
143
+ if (values.empty()) {
144
+ return 0.0F;
145
+ }
146
+ const std::size_t middle = values.size() / 2;
147
+ std::nth_element(values.begin(), values.begin() + middle, values.end());
148
+ const float upper = values[middle];
149
+ if ((values.size() & 1U) != 0U) {
150
+ return upper;
151
+ }
152
+ const float lower = *std::max_element(
153
+ values.begin(), values.begin() + middle
154
+ );
155
+ return (lower + upper) * 0.5F;
156
+ }
157
+
158
+ double quantile(std::vector<float> values, double fraction) {
159
+ if (values.empty()) {
160
+ return std::numeric_limits<double>::quiet_NaN();
161
+ }
162
+ std::sort(values.begin(), values.end());
163
+ const double position = fraction * static_cast<double>(values.size() - 1);
164
+ const auto lower = static_cast<std::size_t>(std::floor(position));
165
+ const auto upper = static_cast<std::size_t>(std::ceil(position));
166
+ const double blend = position - static_cast<double>(lower);
167
+ return static_cast<double>(values[lower]) * (1.0 - blend)
168
+ + static_cast<double>(values[upper]) * blend;
169
+ }
170
+
171
+ std::vector<Seed> select_seeds(
172
+ const std::vector<float>& probability,
173
+ const DecoderConfig& config,
174
+ DecodeStatistics* statistics
175
+ ) {
176
+ std::vector<Seed> candidates;
177
+ candidates.reserve(probability.size() / 20);
178
+ for (int y = 0; y < kMapHeight; ++y) {
179
+ for (int x = 0; x < kMapWidth; ++x) {
180
+ const float value = probability[static_cast<std::size_t>(
181
+ y * kMapWidth + x
182
+ )];
183
+ if (value > config.seed_threshold) {
184
+ candidates.push_back({x, y, value});
185
+ }
186
+ }
187
+ }
188
+ if (statistics != nullptr) {
189
+ statistics->foreground_pixels = candidates.size();
190
+ }
191
+ const auto order = numpy_float_argquicksort(candidates);
192
+
193
+ std::vector<std::uint8_t> taken(
194
+ static_cast<std::size_t>(kMapWidth * kMapHeight), 0
195
+ );
196
+ std::vector<Seed> selected;
197
+ selected.reserve(static_cast<std::size_t>(config.max_seeds));
198
+ for (const std::size_t candidate_index : order) {
199
+ const Seed& candidate = candidates[candidate_index];
200
+ const std::size_t position = static_cast<std::size_t>(
201
+ candidate.y * kMapWidth + candidate.x
202
+ );
203
+ if (taken[position] != 0U) {
204
+ continue;
205
+ }
206
+ selected.push_back(candidate);
207
+ const int y0 = std::max(0, candidate.y - config.seed_min_distance);
208
+ const int y1 = std::min(
209
+ kMapHeight - 1, candidate.y + config.seed_min_distance
210
+ );
211
+ const int x0 = std::max(0, candidate.x - config.seed_min_distance);
212
+ const int x1 = std::min(
213
+ kMapWidth - 1, candidate.x + config.seed_min_distance
214
+ );
215
+ for (int y = y0; y <= y1; ++y) {
216
+ for (int x = x0; x <= x1; ++x) {
217
+ taken[static_cast<std::size_t>(y * kMapWidth + x)] = 1U;
218
+ }
219
+ }
220
+ if (static_cast<int>(selected.size()) >= config.max_seeds) {
221
+ break;
222
+ }
223
+ }
224
+ if (statistics != nullptr) {
225
+ statistics->seeds = selected.size();
226
+ }
227
+ return selected;
228
+ }
229
+
230
+ std::vector<LanePoint> crawl(
231
+ const Seed& seed,
232
+ const std::vector<float>& probability,
233
+ const std::vector<float>& arrow,
234
+ const std::vector<float>& bound,
235
+ const DecoderConfig& config
236
+ ) {
237
+ std::vector<LanePoint> points;
238
+ points.reserve(48);
239
+ int cx = seed.x;
240
+ int cy = seed.y;
241
+ double remain_square_sum = 0.0;
242
+ int remain_count = 0;
243
+ const std::size_t channel_elements = static_cast<std::size_t>(
244
+ kMapWidth * kMapHeight
245
+ );
246
+ for (int index = 0; index < kMapHeight; ++index) {
247
+ const std::size_t current = static_cast<std::size_t>(
248
+ cy * kMapWidth + cx
249
+ );
250
+ if (probability[current] > config.segmentation_threshold) {
251
+ const double remain = static_cast<double>(bound[current]) * 100.0
252
+ / static_cast<double>(config.step_length)
253
+ + static_cast<double>(index);
254
+ remain_square_sum += remain * remain;
255
+ ++remain_count;
256
+ }
257
+ const float dx = arrow[current];
258
+ const float dy = arrow[channel_elements + current];
259
+ const float norm = std::sqrt(dx * dx + dy * dy);
260
+ if (norm == 0.0F || !std::isfinite(norm)) {
261
+ break;
262
+ }
263
+ cx = static_cast<int>(std::floor(
264
+ static_cast<float>(cx) + dx / norm * config.step_length
265
+ ));
266
+ cy = static_cast<int>(std::floor(
267
+ static_cast<float>(cy) + dy / norm * config.step_length
268
+ ));
269
+ if (cx < 0 || cx >= kMapWidth || cy < 0 || cy >= kMapHeight) {
270
+ break;
271
+ }
272
+ const float score = probability[static_cast<std::size_t>(
273
+ cy * kMapWidth + cx
274
+ )];
275
+ points.push_back({static_cast<float>(cx), static_cast<float>(cy), score});
276
+ const double remaining = remain_count > 0
277
+ ? std::sqrt(remain_square_sum / static_cast<double>(remain_count))
278
+ : 1.0;
279
+ if (score > config.segmentation_threshold) {
280
+ continue;
281
+ }
282
+ if (static_cast<double>(index) > remaining * 0.75) {
283
+ break;
284
+ }
285
+ }
286
+ return points;
287
+ }
288
+
289
+ double reference_x(const Lane& lane) {
290
+ if (lane.points.empty()) {
291
+ return std::numeric_limits<double>::infinity();
292
+ }
293
+ if (lane.points.size() < 2) {
294
+ return lane.points.front().x;
295
+ }
296
+ std::vector<float> y_values;
297
+ y_values.reserve(lane.points.size());
298
+ for (const auto& point : lane.points) {
299
+ if (std::isfinite(point.x) && std::isfinite(point.y)) {
300
+ y_values.push_back(point.y);
301
+ }
302
+ }
303
+ if (y_values.size() < 2) {
304
+ return lane.points.front().x;
305
+ }
306
+ const double cutoff = quantile(y_values, 0.6);
307
+ double sum_x = 0.0;
308
+ double sum_y = 0.0;
309
+ double min_y = std::numeric_limits<double>::infinity();
310
+ double max_y = -std::numeric_limits<double>::infinity();
311
+ std::vector<const LanePoint*> lower;
312
+ for (const auto& point : lane.points) {
313
+ if (std::isfinite(point.x) && std::isfinite(point.y)
314
+ && static_cast<double>(point.y) >= cutoff) {
315
+ lower.push_back(&point);
316
+ sum_x += point.x;
317
+ sum_y += point.y;
318
+ min_y = std::min(min_y, static_cast<double>(point.y));
319
+ max_y = std::max(max_y, static_cast<double>(point.y));
320
+ }
321
+ }
322
+ const auto bottom_x = [&lane]() {
323
+ return static_cast<double>(std::max_element(
324
+ lane.points.begin(), lane.points.end(),
325
+ [](const LanePoint& lhs, const LanePoint& rhs) {
326
+ return lhs.y < rhs.y;
327
+ }
328
+ )->x);
329
+ };
330
+ if (lower.size() < 2 || max_y - min_y < 1.0) {
331
+ return bottom_x();
332
+ }
333
+ const double mean_x = sum_x / static_cast<double>(lower.size());
334
+ const double mean_y = sum_y / static_cast<double>(lower.size());
335
+ double numerator = 0.0;
336
+ double denominator = 0.0;
337
+ for (const auto* point : lower) {
338
+ const double centered_y = static_cast<double>(point->y) - mean_y;
339
+ numerator += centered_y * (static_cast<double>(point->x) - mean_x);
340
+ denominator += centered_y * centered_y;
341
+ }
342
+ if (denominator <= 1e-6) {
343
+ return bottom_x();
344
+ }
345
+ return mean_x + numerator / denominator
346
+ * (static_cast<double>(lane.height - 1) - mean_y);
347
+ }
348
+
349
+ std::vector<int> preselect_candidates(
350
+ const std::vector<Lane>& lanes,
351
+ const std::vector<int>& score_order,
352
+ int max_lanes
353
+ ) {
354
+ if (static_cast<int>(score_order.size()) <= max_lanes) {
355
+ return score_order;
356
+ }
357
+ struct Bucket {
358
+ int key{};
359
+ std::vector<int> indices;
360
+ };
361
+ std::vector<Bucket> buckets;
362
+ for (const int index : score_order) {
363
+ const Lane& lane = lanes[static_cast<std::size_t>(index)];
364
+ std::vector<float> y;
365
+ y.reserve(lane.points.size());
366
+ for (const auto& point : lane.points) {
367
+ y.push_back(point.y);
368
+ }
369
+ const float median_y = median(std::move(y));
370
+ std::vector<float> lower_x;
371
+ for (const auto& point : lane.points) {
372
+ if (point.y >= median_y) {
373
+ lower_x.push_back(point.x);
374
+ }
375
+ }
376
+ const int key = static_cast<int>(std::floor(median(lower_x) / 16.0F));
377
+ auto found = std::find_if(
378
+ buckets.begin(), buckets.end(),
379
+ [key](const Bucket& bucket) { return bucket.key == key; }
380
+ );
381
+ if (found == buckets.end()) {
382
+ buckets.push_back({key, {index}});
383
+ } else {
384
+ found->indices.push_back(index);
385
+ }
386
+ }
387
+ std::vector<int> selected;
388
+ selected.reserve(static_cast<std::size_t>(max_lanes));
389
+ for (std::size_t rank = 0; static_cast<int>(selected.size()) < max_lanes;
390
+ ++rank) {
391
+ bool progressed = false;
392
+ for (const auto& bucket : buckets) {
393
+ if (rank < bucket.indices.size()) {
394
+ selected.push_back(bucket.indices[rank]);
395
+ progressed = true;
396
+ if (static_cast<int>(selected.size()) == max_lanes) {
397
+ break;
398
+ }
399
+ }
400
+ }
401
+ if (!progressed) {
402
+ break;
403
+ }
404
+ }
405
+ std::stable_sort(selected.begin(), selected.end(), [&lanes](int lhs, int rhs) {
406
+ return lanes[static_cast<std::size_t>(lhs)].score()
407
+ > lanes[static_cast<std::size_t>(rhs)].score();
408
+ });
409
+ return selected;
410
+ }
411
+
412
+ void draw_disk(
413
+ std::vector<std::uint8_t>& mask, int width, int height,
414
+ int cx, int cy, int radius
415
+ ) {
416
+ for (int y = std::max(0, cy - radius); y <= std::min(height - 1, cy + radius); ++y) {
417
+ for (int x = std::max(0, cx - radius); x <= std::min(width - 1, cx + radius); ++x) {
418
+ const int dx = x - cx;
419
+ const int dy = y - cy;
420
+ if (dx * dx + dy * dy <= radius * radius) {
421
+ mask[static_cast<std::size_t>(y * width + x)] = 1U;
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ std::vector<std::uint64_t> rasterize(
428
+ const Lane& lane, float scale, int lane_width, int width, int height
429
+ ) {
430
+ std::vector<std::uint8_t> pixels(
431
+ static_cast<std::size_t>(width * height), 0U
432
+ );
433
+ if (lane.points.size() < 2) {
434
+ return std::vector<std::uint64_t>(
435
+ (pixels.size() + 63U) / 64U, 0U
436
+ );
437
+ }
438
+ const int thickness = std::max(1, static_cast<int>(std::lround(
439
+ static_cast<double>(lane_width) * scale
440
+ )));
441
+ const int radius = std::max(1, thickness / 2);
442
+ for (std::size_t index = 1; index < lane.points.size(); ++index) {
443
+ int x0 = std::clamp(static_cast<int>(lane.points[index - 1].x * scale), 0, width - 1);
444
+ int y0 = std::clamp(static_cast<int>(lane.points[index - 1].y * scale), 0, height - 1);
445
+ const int x1 = std::clamp(static_cast<int>(lane.points[index].x * scale), 0, width - 1);
446
+ const int y1 = std::clamp(static_cast<int>(lane.points[index].y * scale), 0, height - 1);
447
+ const int dx = std::abs(x1 - x0);
448
+ const int sx = x0 < x1 ? 1 : -1;
449
+ const int dy = -std::abs(y1 - y0);
450
+ const int sy = y0 < y1 ? 1 : -1;
451
+ int error = dx + dy;
452
+ for (;;) {
453
+ draw_disk(pixels, width, height, x0, y0, radius);
454
+ if (x0 == x1 && y0 == y1) {
455
+ break;
456
+ }
457
+ const int twice = 2 * error;
458
+ if (twice >= dy) {
459
+ error += dy;
460
+ x0 += sx;
461
+ }
462
+ if (twice <= dx) {
463
+ error += dx;
464
+ y0 += sy;
465
+ }
466
+ }
467
+ }
468
+ std::vector<std::uint64_t> mask((pixels.size() + 63U) / 64U, 0U);
469
+ for (std::size_t index = 0; index < pixels.size(); ++index) {
470
+ if (pixels[index] != 0U) {
471
+ mask[index / 64U] |= std::uint64_t{1} << (index % 64U);
472
+ }
473
+ }
474
+ return mask;
475
+ }
476
+
477
+ std::vector<Lane> nms(
478
+ std::vector<Lane> lanes,
479
+ const DecoderConfig& config,
480
+ DecodeStatistics* statistics
481
+ ) {
482
+ std::vector<int> order(lanes.size());
483
+ std::iota(order.begin(), order.end(), 0);
484
+ std::stable_sort(order.begin(), order.end(), [&lanes](int lhs, int rhs) {
485
+ return lanes[static_cast<std::size_t>(lhs)].score()
486
+ > lanes[static_cast<std::size_t>(rhs)].score();
487
+ });
488
+ order = preselect_candidates(lanes, order, config.nms_max_lanes);
489
+ if (statistics != nullptr) {
490
+ statistics->nms_candidates = order.size();
491
+ }
492
+ const int width = std::max(1, static_cast<int>(std::lround(
493
+ static_cast<double>(kMapWidth) * config.nms_scale
494
+ )));
495
+ const int height = std::max(1, static_cast<int>(std::lround(
496
+ static_cast<double>(kMapHeight) * config.nms_scale
497
+ )));
498
+ std::vector<std::vector<std::uint64_t>> masks;
499
+ std::vector<int> areas;
500
+ masks.reserve(order.size());
501
+ areas.reserve(order.size());
502
+ for (const int index : order) {
503
+ masks.push_back(rasterize(
504
+ lanes[static_cast<std::size_t>(index)], config.nms_scale,
505
+ config.lane_width, width, height
506
+ ));
507
+ int area = 0;
508
+ for (const std::uint64_t word : masks.back()) {
509
+ area += __builtin_popcountll(word);
510
+ }
511
+ areas.push_back(area);
512
+ }
513
+ std::vector<std::uint8_t> suppressed(order.size(), 0U);
514
+ std::vector<Lane> kept;
515
+ for (std::size_t i = 0; i < order.size(); ++i) {
516
+ if (suppressed[i] != 0U) {
517
+ continue;
518
+ }
519
+ kept.push_back(std::move(lanes[static_cast<std::size_t>(order[i])])) ;
520
+ for (std::size_t j = i + 1; j < order.size(); ++j) {
521
+ if (suppressed[j] != 0U) {
522
+ continue;
523
+ }
524
+ int intersection = 0;
525
+ for (std::size_t word = 0; word < masks[i].size(); ++word) {
526
+ intersection += __builtin_popcountll(
527
+ masks[i][word] & masks[j][word]
528
+ );
529
+ }
530
+ const int union_area = areas[i] + areas[j] - intersection;
531
+ if (union_area > 0
532
+ && static_cast<double>(intersection) / union_area
533
+ >= config.iou_threshold) {
534
+ suppressed[j] = 1U;
535
+ }
536
+ }
537
+ }
538
+ if (statistics != nullptr) {
539
+ statistics->nms_survivors = kept.size();
540
+ }
541
+ return kept;
542
+ }
543
+
544
+ void assign_roles(std::vector<Lane>& lanes, double ego_x) {
545
+ std::vector<Lane*> left;
546
+ std::vector<Lane*> right;
547
+ std::unordered_map<const Lane*, double> references;
548
+ for (auto& lane : lanes) {
549
+ lane.lane_id = std::numeric_limits<int>::min();
550
+ lane.role.clear();
551
+ lane.ego_boundary = false;
552
+ lane.lateral_rank = 0;
553
+ references[&lane] = reference_x(lane);
554
+ (references[&lane] < ego_x ? left : right).push_back(&lane);
555
+ }
556
+ const auto near_ego = [&references, ego_x](const Lane* lhs, const Lane* rhs) {
557
+ const double dl = std::abs(references[lhs] - ego_x);
558
+ const double dr = std::abs(references[rhs] - ego_x);
559
+ return dl != dr ? dl < dr : lhs->score() > rhs->score();
560
+ };
561
+ std::sort(left.begin(), left.end(), near_ego);
562
+ std::sort(right.begin(), right.end(), near_ego);
563
+ for (std::size_t index = 0; index < left.size(); ++index) {
564
+ const int rank = static_cast<int>(index + 1);
565
+ left[index]->lane_id = 2 - rank;
566
+ left[index]->lateral_rank = -rank;
567
+ left[index]->ego_boundary = rank == 1;
568
+ left[index]->role = rank == 1 ? "ego_left" : "left_" + std::to_string(rank);
569
+ }
570
+ for (std::size_t index = 0; index < right.size(); ++index) {
571
+ const int rank = static_cast<int>(index + 1);
572
+ right[index]->lane_id = 1 + rank;
573
+ right[index]->lateral_rank = rank;
574
+ right[index]->ego_boundary = rank == 1;
575
+ right[index]->role = rank == 1 ? "ego_right" : "right_" + std::to_string(rank);
576
+ }
577
+ std::sort(lanes.begin(), lanes.end(), [](const Lane& lhs, const Lane& rhs) {
578
+ return lhs.lane_id < rhs.lane_id;
579
+ });
580
+ }
581
+
582
+ std::vector<Lane> select_ego_lanes(
583
+ std::vector<Lane> lanes, const DecoderConfig& config
584
+ ) {
585
+ if (lanes.empty()) {
586
+ return {};
587
+ }
588
+ std::vector<int> pool(lanes.size());
589
+ std::iota(pool.begin(), pool.end(), 0);
590
+ std::vector<double> references(lanes.size());
591
+ for (std::size_t index = 0; index < lanes.size(); ++index) {
592
+ references[index] = reference_x(lanes[index]);
593
+ }
594
+ if (static_cast<int>(lanes.size()) > config.max_output_lanes) {
595
+ const double best = std::max_element(
596
+ lanes.begin(), lanes.end(), [](const Lane& lhs, const Lane& rhs) {
597
+ return lhs.score() < rhs.score();
598
+ }
599
+ )->score();
600
+ std::vector<int> reliable;
601
+ for (const int index : pool) {
602
+ if (lanes[static_cast<std::size_t>(index)].score()
603
+ >= best * config.ego_min_score_ratio) {
604
+ reliable.push_back(index);
605
+ }
606
+ }
607
+ if (static_cast<int>(reliable.size()) >= config.max_output_lanes) {
608
+ pool = std::move(reliable);
609
+ }
610
+ }
611
+ const auto proximity = [&lanes, &references, &config](int lhs, int rhs) {
612
+ const double dl = std::abs(references[static_cast<std::size_t>(lhs)] - config.ego_x);
613
+ const double dr = std::abs(references[static_cast<std::size_t>(rhs)] - config.ego_x);
614
+ return dl != dr ? dl < dr
615
+ : lanes[static_cast<std::size_t>(lhs)].score()
616
+ > lanes[static_cast<std::size_t>(rhs)].score();
617
+ };
618
+ std::vector<int> left;
619
+ std::vector<int> right;
620
+ for (const int index : pool) {
621
+ (references[static_cast<std::size_t>(index)] < config.ego_x
622
+ ? left : right).push_back(index);
623
+ }
624
+ std::sort(left.begin(), left.end(), proximity);
625
+ std::sort(right.begin(), right.end(), proximity);
626
+ std::vector<int> selected;
627
+ if (config.max_output_lanes == 4) {
628
+ selected.insert(selected.end(), left.begin(), left.begin() + std::min<std::size_t>(2, left.size()));
629
+ selected.insert(selected.end(), right.begin(), right.begin() + std::min<std::size_t>(2, right.size()));
630
+ } else {
631
+ std::sort(pool.begin(), pool.end(), proximity);
632
+ pool.resize(std::min<std::size_t>(pool.size(), static_cast<std::size_t>(config.max_output_lanes)));
633
+ selected = std::move(pool);
634
+ }
635
+ std::vector<Lane> result;
636
+ result.reserve(selected.size());
637
+ for (const int index : selected) {
638
+ result.push_back(std::move(lanes[static_cast<std::size_t>(index)]));
639
+ }
640
+ assign_roles(result, config.ego_x);
641
+ return result;
642
+ }
643
+
644
+ const Tensor& require_output(
645
+ const std::unordered_map<std::string, Tensor>& outputs,
646
+ const std::string& name
647
+ ) {
648
+ const auto found = outputs.find(name);
649
+ if (found == outputs.end()) {
650
+ throw std::runtime_error("missing TensorRT output: " + name);
651
+ }
652
+ return found->second;
653
+ }
654
+
655
+ } // namespace
656
+
657
+ double Lane::score() const {
658
+ return points.empty() ? 0.0
659
+ : score_sum / static_cast<double>(points.size());
660
+ }
661
+
662
+ std::vector<float> softmax_foreground(const Tensor& logits) {
663
+ const std::size_t plane = static_cast<std::size_t>(kMapWidth * kMapHeight);
664
+ if (logits.values.size() != plane * 2) {
665
+ throw std::invalid_argument("seg_map must have shape (1,2,320,800)");
666
+ }
667
+ std::vector<float> probability(plane);
668
+ #pragma omp parallel for schedule(static)
669
+ for (std::int64_t index = 0; index < static_cast<std::int64_t>(plane); ++index) {
670
+ const float difference = logits.values[static_cast<std::size_t>(index)]
671
+ - logits.values[plane + static_cast<std::size_t>(index)];
672
+ probability[static_cast<std::size_t>(index)]
673
+ = 1.0F / (1.0F + std::exp(difference));
674
+ }
675
+ return probability;
676
+ }
677
+
678
+ std::vector<Lane> decode(
679
+ const std::vector<float>& probability,
680
+ const Tensor& up_arrow,
681
+ const Tensor& down_arrow,
682
+ const Tensor& up_bound,
683
+ const Tensor& down_bound,
684
+ const DecoderConfig& config,
685
+ DecodeStatistics* statistics
686
+ ) {
687
+ const std::size_t plane = static_cast<std::size_t>(kMapWidth * kMapHeight);
688
+ if (probability.size() != plane
689
+ || up_arrow.values.size() != plane * 2
690
+ || down_arrow.values.size() != plane * 2
691
+ || up_bound.values.size() != plane * 2
692
+ || down_bound.values.size() != plane * 2) {
693
+ throw std::invalid_argument("decoder map shape mismatch");
694
+ }
695
+ if (statistics != nullptr) {
696
+ *statistics = {};
697
+ }
698
+ omp_set_num_threads(config.threads);
699
+ const auto seeds = select_seeds(probability, config, statistics);
700
+ std::vector<std::vector<LanePoint>> up(seeds.size());
701
+ std::vector<std::vector<LanePoint>> down(seeds.size());
702
+ #pragma omp parallel for schedule(static)
703
+ for (std::int64_t index = 0; index < static_cast<std::int64_t>(seeds.size()); ++index) {
704
+ up[static_cast<std::size_t>(index)] = crawl(
705
+ seeds[static_cast<std::size_t>(index)], probability,
706
+ up_arrow.values, up_bound.values, config
707
+ );
708
+ down[static_cast<std::size_t>(index)] = crawl(
709
+ seeds[static_cast<std::size_t>(index)], probability,
710
+ down_arrow.values, down_bound.values, config
711
+ );
712
+ }
713
+ std::vector<Lane> candidates;
714
+ candidates.reserve(seeds.size());
715
+ for (std::size_t index = 0; index < seeds.size(); ++index) {
716
+ const std::size_t count = up[index].size() + down[index].size();
717
+ if (count <= 1) {
718
+ continue;
719
+ }
720
+ Lane lane;
721
+ lane.width = kMapWidth;
722
+ lane.height = kMapHeight;
723
+ lane.points.reserve(count);
724
+ for (auto point = up[index].rbegin(); point != up[index].rend(); ++point) {
725
+ lane.points.push_back(*point);
726
+ lane.score_sum += point->score;
727
+ }
728
+ for (const auto& point : down[index]) {
729
+ lane.points.push_back(point);
730
+ lane.score_sum += point.score;
731
+ }
732
+ if (lane.score() >= config.score_threshold) {
733
+ candidates.push_back(std::move(lane));
734
+ }
735
+ }
736
+ if (statistics != nullptr) {
737
+ statistics->crawled_candidates = candidates.size();
738
+ }
739
+ auto kept = nms(std::move(candidates), config, statistics);
740
+ return select_ego_lanes(std::move(kept), config);
741
+ }
742
+
743
+ std::vector<Lane> decode_outputs(
744
+ const std::unordered_map<std::string, Tensor>& outputs,
745
+ const DecoderConfig& config,
746
+ DecodeStatistics* statistics
747
+ ) {
748
+ const auto probability = softmax_foreground(require_output(outputs, "seg_map"));
749
+ return decode(
750
+ probability,
751
+ require_output(outputs, "up_arrow"),
752
+ require_output(outputs, "down_arrow"),
753
+ require_output(outputs, "up_bound"),
754
+ require_output(outputs, "down_bound"),
755
+ config,
756
+ statistics
757
+ );
758
+ }
759
+
760
+ void write_lanes_json(
761
+ const std::string& path,
762
+ const std::vector<Lane>& lanes,
763
+ const DecodeStatistics* statistics
764
+ ) {
765
+ std::ofstream stream(path);
766
+ if (!stream) {
767
+ throw std::runtime_error("cannot write lanes JSON: " + path);
768
+ }
769
+ stream << std::setprecision(9) << "{\n";
770
+ if (statistics != nullptr) {
771
+ stream << " \"statistics\": {\"foreground_pixels\": "
772
+ << statistics->foreground_pixels << ", \"seeds\": "
773
+ << statistics->seeds << ", \"crawled_candidates\": "
774
+ << statistics->crawled_candidates << ", \"nms_candidates\": "
775
+ << statistics->nms_candidates << ", \"nms_survivors\": "
776
+ << statistics->nms_survivors << "},\n";
777
+ }
778
+ stream << " \"lanes\": [\n";
779
+ for (std::size_t lane_index = 0; lane_index < lanes.size(); ++lane_index) {
780
+ const Lane& lane = lanes[lane_index];
781
+ stream << " {\"lane_id\": " << lane.lane_id
782
+ << ", \"role\": \"" << lane.role
783
+ << "\", \"score\": " << lane.score() << ", \"points\": [";
784
+ for (std::size_t point_index = 0; point_index < lane.points.size(); ++point_index) {
785
+ const auto& point = lane.points[point_index];
786
+ if (point_index != 0) {
787
+ stream << ',';
788
+ }
789
+ stream << '[' << point.x << ',' << point.y << ',' << point.score << ']';
790
+ }
791
+ stream << "]}" << (lane_index + 1 == lanes.size() ? "\n" : ",\n");
792
+ }
793
+ stream << " ]\n}\n";
794
+ }
795
+
796
+ } // namespace rclane
cpp/src/main.cpp ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "tensorrt_runner.hpp"
2
+ #include "decoder.hpp"
3
+ #include "bev.hpp"
4
+ #include "preprocess.hpp"
5
+
6
+ #include <fstream>
7
+ #include <algorithm>
8
+ #include <chrono>
9
+ #include <cmath>
10
+ #include <iomanip>
11
+ #include <iostream>
12
+ #include <numeric>
13
+ #include <stdexcept>
14
+ #include <string>
15
+ #include <unordered_map>
16
+ #include <vector>
17
+
18
+ namespace {
19
+
20
+ struct Arguments {
21
+ std::string engine;
22
+ std::string input;
23
+ std::string input_bgr;
24
+ std::string dump_prefix;
25
+ std::string lanes_json;
26
+ std::string bev_json;
27
+ std::string report;
28
+ std::string frames_jsonl;
29
+ bool raw_bgr_stdin{false};
30
+ int source_width{1920};
31
+ int source_height{1080};
32
+ int max_frames{0};
33
+ int timing_warmup{5};
34
+ int warmup{10};
35
+ int iterations{100};
36
+ int threads{8};
37
+ };
38
+
39
+ Arguments parse_arguments(int argc, char** argv) {
40
+ Arguments args;
41
+ for (int index = 1; index < argc; ++index) {
42
+ const std::string option = argv[index];
43
+ const auto value = [&]() -> std::string {
44
+ if (++index >= argc) {
45
+ throw std::invalid_argument("missing value for " + option);
46
+ }
47
+ return argv[index];
48
+ };
49
+ if (option == "--engine") {
50
+ args.engine = value();
51
+ } else if (option == "--input-nchw") {
52
+ args.input = value();
53
+ } else if (option == "--input-bgr") {
54
+ args.input_bgr = value();
55
+ } else if (option == "--raw-bgr-stdin") {
56
+ args.raw_bgr_stdin = true;
57
+ } else if (option == "--dump-prefix") {
58
+ args.dump_prefix = value();
59
+ } else if (option == "--lanes-json") {
60
+ args.lanes_json = value();
61
+ } else if (option == "--bev-json") {
62
+ args.bev_json = value();
63
+ } else if (option == "--report") {
64
+ args.report = value();
65
+ } else if (option == "--frames-jsonl") {
66
+ args.frames_jsonl = value();
67
+ } else if (option == "--source-width") {
68
+ args.source_width = std::stoi(value());
69
+ } else if (option == "--source-height") {
70
+ args.source_height = std::stoi(value());
71
+ } else if (option == "--max-frames") {
72
+ args.max_frames = std::stoi(value());
73
+ } else if (option == "--timing-warmup") {
74
+ args.timing_warmup = std::stoi(value());
75
+ } else if (option == "--warmup") {
76
+ args.warmup = std::stoi(value());
77
+ } else if (option == "--iterations") {
78
+ args.iterations = std::stoi(value());
79
+ } else if (option == "--threads") {
80
+ args.threads = std::stoi(value());
81
+ } else {
82
+ throw std::invalid_argument("unknown argument: " + option);
83
+ }
84
+ }
85
+ const int input_modes = static_cast<int>(!args.input.empty())
86
+ + static_cast<int>(!args.input_bgr.empty())
87
+ + static_cast<int>(args.raw_bgr_stdin);
88
+ if (args.engine.empty() || input_modes != 1
89
+ || args.source_width <= 0 || args.source_height <= 0
90
+ || args.timing_warmup < 0 || args.max_frames < 0) {
91
+ throw std::invalid_argument(
92
+ "usage: rclane_runtime --engine model.engine "
93
+ "(--input-nchw frame.f32 | --input-bgr frame.bgr | "
94
+ "--raw-bgr-stdin) "
95
+ "[--dump-prefix output]"
96
+ );
97
+ }
98
+ return args;
99
+ }
100
+
101
+ std::vector<float> read_floats(
102
+ const std::string& path, std::size_t expected
103
+ ) {
104
+ std::ifstream stream(path, std::ios::binary | std::ios::ate);
105
+ if (!stream) {
106
+ throw std::runtime_error("cannot open input: " + path);
107
+ }
108
+ const auto byte_count = static_cast<std::size_t>(stream.tellg());
109
+ if (byte_count != expected * sizeof(float)) {
110
+ throw std::runtime_error(
111
+ "input byte count mismatch: expected "
112
+ + std::to_string(expected * sizeof(float)) + ", got "
113
+ + std::to_string(byte_count)
114
+ );
115
+ }
116
+ std::vector<float> values(expected);
117
+ stream.seekg(0);
118
+ stream.read(
119
+ reinterpret_cast<char*>(values.data()),
120
+ static_cast<std::streamsize>(byte_count)
121
+ );
122
+ return values;
123
+ }
124
+
125
+ std::vector<std::uint8_t> read_bytes(
126
+ const std::string& path, std::size_t expected
127
+ ) {
128
+ std::ifstream stream(path, std::ios::binary | std::ios::ate);
129
+ if (!stream) {
130
+ throw std::runtime_error("cannot open input: " + path);
131
+ }
132
+ const auto byte_count = static_cast<std::size_t>(stream.tellg());
133
+ if (byte_count != expected) {
134
+ throw std::runtime_error(
135
+ "BGR byte count mismatch: expected " + std::to_string(expected)
136
+ + ", got " + std::to_string(byte_count)
137
+ );
138
+ }
139
+ std::vector<std::uint8_t> bytes(expected);
140
+ stream.seekg(0);
141
+ stream.read(reinterpret_cast<char*>(bytes.data()),
142
+ static_cast<std::streamsize>(byte_count));
143
+ return bytes;
144
+ }
145
+
146
+ void dump_outputs(
147
+ const std::string& prefix,
148
+ const std::unordered_map<std::string, rclane::Tensor>& outputs
149
+ ) {
150
+ if (prefix.empty()) {
151
+ return;
152
+ }
153
+ for (const auto& [name, tensor] : outputs) {
154
+ const std::string path = prefix + "." + name + ".f32";
155
+ std::ofstream stream(path, std::ios::binary);
156
+ if (!stream) {
157
+ throw std::runtime_error("cannot write output: " + path);
158
+ }
159
+ stream.write(
160
+ reinterpret_cast<const char*>(tensor.values.data()),
161
+ static_cast<std::streamsize>(
162
+ tensor.values.size() * sizeof(float)
163
+ )
164
+ );
165
+ }
166
+ }
167
+
168
+ void dump_input(const std::string& prefix, const std::vector<float>& input) {
169
+ if (prefix.empty()) {
170
+ return;
171
+ }
172
+ std::ofstream stream(prefix + ".input.f32", std::ios::binary);
173
+ if (!stream) {
174
+ throw std::runtime_error("cannot write normalized input");
175
+ }
176
+ stream.write(
177
+ reinterpret_cast<const char*>(input.data()),
178
+ static_cast<std::streamsize>(input.size() * sizeof(float))
179
+ );
180
+ }
181
+
182
+ struct TimingSummary {
183
+ double mean{};
184
+ double median{};
185
+ double p95{};
186
+ double minimum{};
187
+ double maximum{};
188
+ };
189
+
190
+ TimingSummary summarize(std::vector<double> values) {
191
+ if (values.empty()) {
192
+ throw std::runtime_error("no timed frames after warmup");
193
+ }
194
+ std::sort(values.begin(), values.end());
195
+ const auto percentile = [&values](double fraction) {
196
+ const auto position = static_cast<std::size_t>(std::floor(
197
+ fraction * static_cast<double>(values.size() - 1)
198
+ ));
199
+ return values[position];
200
+ };
201
+ return {
202
+ std::accumulate(values.begin(), values.end(), 0.0)
203
+ / static_cast<double>(values.size()),
204
+ percentile(0.5), percentile(0.95), values.front(), values.back(),
205
+ };
206
+ }
207
+
208
+ double milliseconds(
209
+ std::chrono::steady_clock::time_point start,
210
+ std::chrono::steady_clock::time_point stop
211
+ ) {
212
+ return std::chrono::duration<double, std::milli>(stop - start).count();
213
+ }
214
+
215
+ void write_stream_report(
216
+ const std::string& path,
217
+ std::size_t frames,
218
+ std::size_t timed_frames,
219
+ int threads,
220
+ const TimingSummary& read,
221
+ const TimingSummary& preprocess,
222
+ const TimingSummary& inference,
223
+ const TimingSummary& decode,
224
+ const TimingSummary& bev,
225
+ const TimingSummary& core,
226
+ double mean_lanes
227
+ ) {
228
+ if (path.empty()) {
229
+ return;
230
+ }
231
+ std::ofstream stream(path);
232
+ if (!stream) {
233
+ throw std::runtime_error("cannot write benchmark report: " + path);
234
+ }
235
+ const auto timing = [&stream](const char* name, const TimingSummary& value,
236
+ bool comma) {
237
+ stream << " \"" << name << "\": {\"mean_ms\": " << value.mean
238
+ << ", \"median_ms\": " << value.median
239
+ << ", \"p95_ms\": " << value.p95
240
+ << ", \"min_ms\": " << value.minimum
241
+ << ", \"max_ms\": " << value.maximum << '}'
242
+ << (comma ? ",\n" : "\n");
243
+ };
244
+ stream << std::setprecision(12)
245
+ << "{\n \"runtime\": \"native_tensorrt_cpp\",\n"
246
+ << " \"sequential_per_frame\": true,\n"
247
+ << " \"frame_overlap\": false,\n"
248
+ << " \"rendering_included\": false,\n"
249
+ << " \"video_writing_included\": false,\n"
250
+ << " \"bev_mode\": \"raw_model_projection\",\n"
251
+ << " \"parallel_assumption\": false,\n"
252
+ << " \"synthetic_lanes\": false,\n"
253
+ << " \"frames\": " << frames << ",\n"
254
+ << " \"timed_frames\": " << timed_frames << ",\n"
255
+ << " \"decode_threads\": " << threads << ",\n"
256
+ << " \"mean_output_lanes\": " << mean_lanes << ",\n"
257
+ << " \"timing\": {\n";
258
+ timing("source_read", read, true);
259
+ timing("preprocess", preprocess, true);
260
+ timing("inference_with_transfers", inference, true);
261
+ timing("decode", decode, true);
262
+ timing("bev_cubic_funnel", bev, true);
263
+ timing("core_pipeline", core, false);
264
+ stream << " },\n \"fps_from_core_median_latency\": "
265
+ << 1000.0 / core.median << "\n}\n";
266
+ }
267
+
268
+ int run_raw_stream(
269
+ const Arguments& args,
270
+ rclane::TensorRTRunner& runner
271
+ ) {
272
+ const std::size_t frame_bytes = static_cast<std::size_t>(
273
+ args.source_width * args.source_height * 3
274
+ );
275
+ std::vector<std::uint8_t> frame(frame_bytes);
276
+ std::vector<float> input(runner.input_elements(), 0.0F);
277
+ // Warm only TensorRT with a neutral tensor; actual frame timings discard
278
+ // the first timing_warmup frames so OpenMP and allocator startup are absent.
279
+ for (int index = 0; index < args.warmup; ++index) {
280
+ runner.infer_reuse(input.data());
281
+ }
282
+ rclane::DecoderConfig decoder_config;
283
+ decoder_config.threads = args.threads;
284
+ std::vector<double> read_samples;
285
+ std::vector<double> preprocess_samples;
286
+ std::vector<double> inference_samples;
287
+ std::vector<double> decode_samples;
288
+ std::vector<double> bev_samples;
289
+ std::vector<double> core_samples;
290
+ std::size_t frames = 0;
291
+ std::size_t timed_frames = 0;
292
+ double lane_sum = 0.0;
293
+ std::ofstream frame_results;
294
+ if (!args.frames_jsonl.empty()) {
295
+ frame_results.open(args.frames_jsonl);
296
+ if (!frame_results) {
297
+ throw std::runtime_error(
298
+ "cannot write frame results: " + args.frames_jsonl
299
+ );
300
+ }
301
+ frame_results << std::setprecision(9);
302
+ }
303
+ for (;;) {
304
+ if (args.max_frames > 0 && static_cast<int>(frames) >= args.max_frames) {
305
+ break;
306
+ }
307
+ const auto read_start = std::chrono::steady_clock::now();
308
+ std::cin.read(
309
+ reinterpret_cast<char*>(frame.data()),
310
+ static_cast<std::streamsize>(frame.size())
311
+ );
312
+ const auto read_stop = std::chrono::steady_clock::now();
313
+ if (std::cin.gcount() == 0) {
314
+ break;
315
+ }
316
+ if (std::cin.gcount() != static_cast<std::streamsize>(frame.size())) {
317
+ throw std::runtime_error("partial BGR frame on stdin");
318
+ }
319
+ const auto core_start = std::chrono::steady_clock::now();
320
+ const auto preprocess_start = core_start;
321
+ rclane::normalize_bgr_to_nchw(
322
+ frame.data(), args.source_width, args.source_height, input
323
+ );
324
+ const auto preprocess_stop = std::chrono::steady_clock::now();
325
+ const auto inference_start = preprocess_stop;
326
+ const auto& outputs = runner.infer_reuse(input.data());
327
+ const auto inference_stop = std::chrono::steady_clock::now();
328
+ const auto decode_start = inference_stop;
329
+ rclane::DecodeStatistics decode_statistics;
330
+ const auto lanes = rclane::decode_outputs(
331
+ outputs, decoder_config, &decode_statistics
332
+ );
333
+ const auto decode_stop = std::chrono::steady_clock::now();
334
+ const auto bev_lanes = rclane::project_lanes_to_bev(lanes);
335
+ const auto bev_stop = std::chrono::steady_clock::now();
336
+ const double frame_preprocess_ms = milliseconds(
337
+ preprocess_start, preprocess_stop
338
+ );
339
+ const double frame_inference_ms = milliseconds(
340
+ inference_start, inference_stop
341
+ );
342
+ const double frame_decode_ms = milliseconds(decode_start, decode_stop);
343
+ const double frame_bev_ms = milliseconds(decode_stop, bev_stop);
344
+ const double frame_core_ms = milliseconds(core_start, bev_stop);
345
+ // Serialization is deliberately after bev_stop, outside core latency.
346
+ if (frame_results) {
347
+ frame_results << "{\"frame_index\":" << frames
348
+ << ",\"timing\":{\"preprocess_ms\":"
349
+ << frame_preprocess_ms
350
+ << ",\"inference_ms\":" << frame_inference_ms
351
+ << ",\"decode_ms\":" << frame_decode_ms
352
+ << ",\"bev_result_ms\":" << frame_bev_ms
353
+ << ",\"core_ms\":" << frame_core_ms << "}"
354
+ << ",\"lanes\":[";
355
+ for (std::size_t lane_index = 0; lane_index < lanes.size();
356
+ ++lane_index) {
357
+ const auto& lane = lanes[lane_index];
358
+ if (lane_index != 0U) {
359
+ frame_results << ',';
360
+ }
361
+ frame_results << "{\"lane_id\":" << lane.lane_id
362
+ << ",\"role\":\"" << lane.role
363
+ << "\",\"score\":" << lane.score()
364
+ << ",\"points\":[";
365
+ for (std::size_t point_index = 0;
366
+ point_index < lane.points.size(); ++point_index) {
367
+ const auto& point = lane.points[point_index];
368
+ if (point_index != 0U) {
369
+ frame_results << ',';
370
+ }
371
+ frame_results << '[' << point.x << ',' << point.y << ','
372
+ << point.score << ']';
373
+ }
374
+ frame_results << "]}";
375
+ }
376
+ frame_results << "],\"bev_lanes\":[";
377
+ for (std::size_t lane_index = 0;
378
+ lane_index < bev_lanes.size(); ++lane_index) {
379
+ const auto& lane = bev_lanes[lane_index];
380
+ if (lane_index != 0U) {
381
+ frame_results << ',';
382
+ }
383
+ frame_results << "{\"lane_id\":" << lane.lane_id
384
+ << ",\"role\":\"" << lane.role
385
+ << "\",\"score\":" << lane.score
386
+ << ",\"fit_accepted\":"
387
+ << (lane.fit_accepted ? "true" : "false")
388
+ << ",\"funnel_clipped\":"
389
+ << (lane.funnel_clipped ? "true" : "false")
390
+ << ",\"points\":[";
391
+ for (std::size_t point_index = 0;
392
+ point_index < lane.points.size(); ++point_index) {
393
+ const auto& point = lane.points[point_index];
394
+ if (point_index != 0U) {
395
+ frame_results << ',';
396
+ }
397
+ frame_results << '[' << point.x << ',' << point.y << ','
398
+ << point.score << ']';
399
+ }
400
+ frame_results << ']';
401
+ if (lane.fit.valid) {
402
+ frame_results << ",\"fit\":{\"coefficients\":["
403
+ << lane.fit.coefficients[0] << ','
404
+ << lane.fit.coefficients[1] << ','
405
+ << lane.fit.coefficients[2] << ','
406
+ << lane.fit.coefficients[3]
407
+ << "],\"x_min\":" << lane.fit.x_min
408
+ << ",\"x_max\":" << lane.fit.x_max
409
+ << ",\"rmse\":" << lane.fit.rmse
410
+ << ",\"point_count\":"
411
+ << lane.fit.point_count
412
+ << ",\"inlier_count\":"
413
+ << lane.fit.inlier_count << '}';
414
+ } else {
415
+ frame_results << ",\"fit\":null";
416
+ }
417
+ frame_results << '}';
418
+ }
419
+ frame_results << "]}\n";
420
+ }
421
+ lane_sum += static_cast<double>(lanes.size());
422
+ if (static_cast<int>(frames) >= args.timing_warmup) {
423
+ read_samples.push_back(milliseconds(read_start, read_stop));
424
+ preprocess_samples.push_back(frame_preprocess_ms);
425
+ inference_samples.push_back(frame_inference_ms);
426
+ decode_samples.push_back(frame_decode_ms);
427
+ bev_samples.push_back(frame_bev_ms);
428
+ core_samples.push_back(frame_core_ms);
429
+ ++timed_frames;
430
+ }
431
+ ++frames;
432
+ if (frames % 100U == 0U) {
433
+ std::cerr << "processed " << frames << " frames\n";
434
+ }
435
+ }
436
+ if (frames == 0 || timed_frames == 0) {
437
+ throw std::runtime_error("raw stream produced no timed frames");
438
+ }
439
+ const auto read = summarize(std::move(read_samples));
440
+ const auto preprocess = summarize(std::move(preprocess_samples));
441
+ const auto inference = summarize(std::move(inference_samples));
442
+ const auto decode = summarize(std::move(decode_samples));
443
+ const auto bev = summarize(std::move(bev_samples));
444
+ const auto core = summarize(std::move(core_samples));
445
+ write_stream_report(
446
+ args.report, frames, timed_frames, args.threads, read, preprocess,
447
+ inference, decode, bev, core, lane_sum / static_cast<double>(frames)
448
+ );
449
+ std::cout << std::fixed << std::setprecision(3)
450
+ << "C++ sequential raw-BEV benchmark (render/write excluded)\n"
451
+ << "frames=" << frames << " timed=" << timed_frames
452
+ << " threads=" << args.threads << '\n'
453
+ << "preprocess median=" << preprocess.median << "ms\n"
454
+ << "inference+D2H median=" << inference.median << "ms\n"
455
+ << "decode median=" << decode.median << "ms\n"
456
+ << "BEV median=" << bev.median << "ms\n"
457
+ << "core median=" << core.median << "ms p95=" << core.p95
458
+ << "ms FPS=" << (1000.0 / core.median) << '\n';
459
+ return 0;
460
+ }
461
+
462
+ } // namespace
463
+
464
+ int main(int argc, char** argv) {
465
+ try {
466
+ const auto args = parse_arguments(argc, argv);
467
+ rclane::TensorRTRunner runner(args.engine);
468
+ if (args.raw_bgr_stdin) {
469
+ return run_raw_stream(args, runner);
470
+ }
471
+ std::vector<float> input;
472
+ if (!args.input.empty()) {
473
+ input = read_floats(args.input, runner.input_elements());
474
+ } else {
475
+ const auto bgr = read_bytes(
476
+ args.input_bgr, static_cast<std::size_t>(1920 * 1080 * 3)
477
+ );
478
+ rclane::normalize_bgr_to_nchw(bgr.data(), 1920, 1080, input);
479
+ }
480
+ dump_input(args.dump_prefix, input);
481
+ const auto outputs = runner.infer(input.data());
482
+ dump_outputs(args.dump_prefix, outputs);
483
+ rclane::DecoderConfig decoder_config;
484
+ decoder_config.threads = args.threads;
485
+ rclane::DecodeStatistics decode_statistics;
486
+ const auto lanes = rclane::decode_outputs(
487
+ outputs, decoder_config, &decode_statistics
488
+ );
489
+ if (!args.lanes_json.empty()) {
490
+ rclane::write_lanes_json(
491
+ args.lanes_json, lanes, &decode_statistics
492
+ );
493
+ }
494
+ const auto bev_lanes = rclane::project_lanes_to_bev(lanes);
495
+ if (!args.bev_json.empty()) {
496
+ rclane::write_bev_json(args.bev_json, bev_lanes);
497
+ }
498
+ const auto timing = runner.benchmark(
499
+ input.data(), args.warmup, args.iterations
500
+ );
501
+ std::cout << std::fixed << std::setprecision(3)
502
+ << "TensorRT C++ inference: mean=" << timing.mean_ms
503
+ << "ms median=" << timing.median_ms
504
+ << "ms p95=" << timing.p95_ms
505
+ << "ms min=" << timing.min_ms
506
+ << "ms max=" << timing.max_ms
507
+ << "ms FPS=" << (1000.0 / timing.median_ms) << '\n';
508
+ for (const auto& [name, tensor] : outputs) {
509
+ std::cout << name << " elements=" << tensor.values.size() << '\n';
510
+ }
511
+ std::cout << "decode seeds=" << decode_statistics.seeds
512
+ << " candidates=" << decode_statistics.crawled_candidates
513
+ << " NMS=" << decode_statistics.nms_candidates
514
+ << "->" << decode_statistics.nms_survivors
515
+ << " output_lanes=" << lanes.size() << '\n';
516
+ std::cout << "BEV lanes=" << bev_lanes.size() << " valid_cubics="
517
+ << std::count_if(
518
+ bev_lanes.begin(), bev_lanes.end(),
519
+ [](const rclane::BevLane& lane) {
520
+ return lane.fit_accepted;
521
+ }
522
+ ) << '\n';
523
+ return 0;
524
+ } catch (const std::exception& error) {
525
+ std::cerr << "error: " << error.what() << '\n';
526
+ return 1;
527
+ }
528
+ }
cpp/src/preprocess.cpp ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "preprocess.hpp"
2
+
3
+ #include <algorithm>
4
+ #include <cmath>
5
+ #include <cstddef>
6
+ #include <cstdint>
7
+ #include <stdexcept>
8
+
9
+ #include <omp.h>
10
+
11
+ namespace rclane {
12
+
13
+ void normalize_bgr_to_nchw(
14
+ const std::uint8_t* bgr,
15
+ int source_width,
16
+ int source_height,
17
+ std::vector<float>& destination
18
+ ) {
19
+ constexpr int output_width = 800;
20
+ constexpr int output_height = 320;
21
+ constexpr float mean[3]{0.485F, 0.456F, 0.406F};
22
+ constexpr float standard_deviation[3]{0.229F, 0.224F, 0.225F};
23
+ if (bgr == nullptr || source_width <= 0 || source_height <= 0) {
24
+ throw std::invalid_argument("invalid BGR source image");
25
+ }
26
+ const std::size_t plane = static_cast<std::size_t>(
27
+ output_width * output_height
28
+ );
29
+ destination.resize(plane * 3);
30
+ constexpr int coefficient_scale = 1 << 11;
31
+ const double scale_x = static_cast<double>(source_width)
32
+ / static_cast<double>(output_width);
33
+ const double scale_y = static_cast<double>(source_height)
34
+ / static_cast<double>(output_height);
35
+ #pragma omp parallel for schedule(static)
36
+ for (int output_y = 0; output_y < output_height; ++output_y) {
37
+ const float source_y = static_cast<float>(
38
+ (static_cast<double>(output_y) + 0.5) * scale_y - 0.5
39
+ );
40
+ int y0 = static_cast<int>(std::floor(source_y));
41
+ float wy = source_y - static_cast<float>(y0);
42
+ if (y0 < 0) {
43
+ y0 = 0;
44
+ wy = 0.0F;
45
+ }
46
+ int y1 = std::min(y0 + 1, source_height - 1);
47
+ if (y0 >= source_height - 1) {
48
+ y0 = source_height - 1;
49
+ y1 = y0;
50
+ wy = 0.0F;
51
+ }
52
+ const int beta1 = static_cast<int>(std::nearbyint(
53
+ wy * static_cast<float>(coefficient_scale)
54
+ ));
55
+ const int beta0 = static_cast<int>(std::nearbyint(
56
+ (1.0F - wy) * static_cast<float>(coefficient_scale)
57
+ ));
58
+ for (int output_x = 0; output_x < output_width; ++output_x) {
59
+ const float source_x = static_cast<float>(
60
+ (static_cast<double>(output_x) + 0.5) * scale_x - 0.5
61
+ );
62
+ int x0 = static_cast<int>(std::floor(source_x));
63
+ float wx = source_x - static_cast<float>(x0);
64
+ if (x0 < 0) {
65
+ x0 = 0;
66
+ wx = 0.0F;
67
+ }
68
+ int x1 = std::min(x0 + 1, source_width - 1);
69
+ if (x0 >= source_width - 1) {
70
+ x0 = source_width - 1;
71
+ x1 = x0;
72
+ wx = 0.0F;
73
+ }
74
+ const int alpha1 = static_cast<int>(std::nearbyint(
75
+ wx * static_cast<float>(coefficient_scale)
76
+ ));
77
+ const int alpha0 = static_cast<int>(std::nearbyint(
78
+ (1.0F - wx) * static_cast<float>(coefficient_scale)
79
+ ));
80
+ const std::size_t output = static_cast<std::size_t>(
81
+ output_y * output_width + output_x
82
+ );
83
+ const std::size_t top_left = static_cast<std::size_t>(
84
+ (y0 * source_width + x0) * 3
85
+ );
86
+ const std::size_t top_right = static_cast<std::size_t>(
87
+ (y0 * source_width + x1) * 3
88
+ );
89
+ const std::size_t bottom_left = static_cast<std::size_t>(
90
+ (y1 * source_width + x0) * 3
91
+ );
92
+ const std::size_t bottom_right = static_cast<std::size_t>(
93
+ (y1 * source_width + x1) * 3
94
+ );
95
+ for (int rgb_channel = 0; rgb_channel < 3; ++rgb_channel) {
96
+ const int bgr_channel = 2 - rgb_channel;
97
+ const int top = static_cast<int>(bgr[top_left + bgr_channel]) * alpha0
98
+ + static_cast<int>(bgr[top_right + bgr_channel]) * alpha1;
99
+ const int bottom = static_cast<int>(bgr[bottom_left + bgr_channel]) * alpha0
100
+ + static_cast<int>(bgr[bottom_right + bgr_channel]) * alpha1;
101
+ // Match OpenCV 4.11's Apache-licensed VResizeLinear<8u>; see
102
+ // cpp/THIRD_PARTY_NOTICES.md. Its
103
+ // fixed-point shifts intentionally happen before accumulation.
104
+ const int pixel_integer = (
105
+ ((beta0 * (top >> 4)) >> 16)
106
+ + ((beta1 * (bottom >> 4)) >> 16) + 2
107
+ ) >> 2;
108
+ const float pixel = static_cast<float>(
109
+ std::clamp(pixel_integer, 0, 255)
110
+ );
111
+ destination[static_cast<std::size_t>(rgb_channel) * plane + output]
112
+ = (pixel * (1.0F / 255.0F) - mean[rgb_channel])
113
+ / standard_deviation[rgb_channel];
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ } // namespace rclane
cpp/src/tensorrt_runner.cpp ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "tensorrt_runner.hpp"
2
+
3
+ #include <NvInferRuntime.h>
4
+ #include <cuda_runtime_api.h>
5
+
6
+ #include <algorithm>
7
+ #include <chrono>
8
+ #include <cmath>
9
+ #include <fstream>
10
+ #include <limits>
11
+ #include <numeric>
12
+ #include <stdexcept>
13
+ #include <utility>
14
+
15
+ namespace rclane {
16
+ namespace {
17
+
18
+ class Logger final : public nvinfer1::ILogger {
19
+ public:
20
+ void log(Severity severity, const char* message) noexcept override {
21
+ if (severity <= Severity::kWARNING) {
22
+ // TensorRT owns message storage; copying is unnecessary here.
23
+ last_message_ = message == nullptr ? "" : message;
24
+ }
25
+ }
26
+
27
+ const std::string& last_message() const { return last_message_; }
28
+
29
+ private:
30
+ std::string last_message_;
31
+ };
32
+
33
+ void check_cuda(cudaError_t status, const char* operation) {
34
+ if (status != cudaSuccess) {
35
+ throw std::runtime_error(
36
+ std::string(operation) + ": " + cudaGetErrorString(status)
37
+ );
38
+ }
39
+ }
40
+
41
+ std::vector<char> read_binary(const std::string& path) {
42
+ std::ifstream stream(path, std::ios::binary | std::ios::ate);
43
+ if (!stream) {
44
+ throw std::runtime_error("cannot open TensorRT engine: " + path);
45
+ }
46
+ const auto end = stream.tellg();
47
+ if (end <= 0) {
48
+ throw std::runtime_error("empty TensorRT engine: " + path);
49
+ }
50
+ std::vector<char> bytes(static_cast<std::size_t>(end));
51
+ stream.seekg(0);
52
+ stream.read(bytes.data(), static_cast<std::streamsize>(bytes.size()));
53
+ if (!stream) {
54
+ throw std::runtime_error("cannot read TensorRT engine: " + path);
55
+ }
56
+ return bytes;
57
+ }
58
+
59
+ std::vector<std::int64_t> dimensions(const nvinfer1::Dims& dims) {
60
+ std::vector<std::int64_t> shape;
61
+ shape.reserve(static_cast<std::size_t>(dims.nbDims));
62
+ for (int index = 0; index < dims.nbDims; ++index) {
63
+ if (dims.d[index] <= 0) {
64
+ throw std::runtime_error("dynamic/invalid TensorRT shape");
65
+ }
66
+ shape.push_back(dims.d[index]);
67
+ }
68
+ return shape;
69
+ }
70
+
71
+ std::size_t element_count(const std::vector<std::int64_t>& shape) {
72
+ return std::accumulate(
73
+ shape.begin(), shape.end(), std::size_t{1},
74
+ [](std::size_t total, std::int64_t value) {
75
+ return total * static_cast<std::size_t>(value);
76
+ }
77
+ );
78
+ }
79
+
80
+ struct DeviceTensor {
81
+ std::string name;
82
+ bool input{};
83
+ std::vector<std::int64_t> shape;
84
+ std::size_t elements{};
85
+ void* device{};
86
+ };
87
+
88
+ } // namespace
89
+
90
+ class TensorRTRunner::Impl {
91
+ public:
92
+ explicit Impl(const std::string& engine_path) {
93
+ const auto bytes = read_binary(engine_path);
94
+ runtime_.reset(nvinfer1::createInferRuntime(logger_));
95
+ if (!runtime_) {
96
+ throw std::runtime_error("createInferRuntime failed");
97
+ }
98
+ engine_.reset(runtime_->deserializeCudaEngine(
99
+ bytes.data(), bytes.size()
100
+ ));
101
+ if (!engine_) {
102
+ throw std::runtime_error(
103
+ "deserializeCudaEngine failed: " + logger_.last_message()
104
+ );
105
+ }
106
+ context_.reset(engine_->createExecutionContext());
107
+ if (!context_) {
108
+ throw std::runtime_error("createExecutionContext failed");
109
+ }
110
+ check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate");
111
+
112
+ const int tensor_count = engine_->getNbIOTensors();
113
+ for (int index = 0; index < tensor_count; ++index) {
114
+ const char* tensor_name = engine_->getIOTensorName(index);
115
+ if (engine_->getTensorDataType(tensor_name)
116
+ != nvinfer1::DataType::kFLOAT) {
117
+ throw std::runtime_error(
118
+ std::string("non-FP32 engine tensor: ") + tensor_name
119
+ );
120
+ }
121
+ DeviceTensor tensor;
122
+ tensor.name = tensor_name;
123
+ tensor.input = engine_->getTensorIOMode(tensor_name)
124
+ == nvinfer1::TensorIOMode::kINPUT;
125
+ tensor.shape = dimensions(engine_->getTensorShape(tensor_name));
126
+ tensor.elements = element_count(tensor.shape);
127
+ check_cuda(
128
+ cudaMalloc(&tensor.device, tensor.elements * sizeof(float)),
129
+ "cudaMalloc"
130
+ );
131
+ if (!context_->setTensorAddress(tensor.name.c_str(), tensor.device)) {
132
+ throw std::runtime_error(
133
+ "setTensorAddress failed for " + tensor.name
134
+ );
135
+ }
136
+ if (tensor.input) {
137
+ if (input_index_ >= 0) {
138
+ throw std::runtime_error("engine has multiple inputs");
139
+ }
140
+ input_index_ = static_cast<int>(tensors_.size());
141
+ }
142
+ tensors_.push_back(std::move(tensor));
143
+ }
144
+ if (input_index_ < 0) {
145
+ throw std::runtime_error("engine has no input tensor");
146
+ }
147
+ }
148
+
149
+ ~Impl() {
150
+ for (auto& tensor : tensors_) {
151
+ if (tensor.device != nullptr) {
152
+ cudaFree(tensor.device);
153
+ }
154
+ }
155
+ if (stream_ != nullptr) {
156
+ cudaStreamDestroy(stream_);
157
+ }
158
+ }
159
+
160
+ void enqueue(const float* input) {
161
+ const auto& tensor = tensors_[static_cast<std::size_t>(input_index_)];
162
+ check_cuda(cudaMemcpyAsync(
163
+ tensor.device, input, tensor.elements * sizeof(float),
164
+ cudaMemcpyHostToDevice, stream_
165
+ ), "cudaMemcpyAsync input");
166
+ if (!context_->enqueueV3(stream_)) {
167
+ throw std::runtime_error("TensorRT enqueueV3 failed");
168
+ }
169
+ }
170
+
171
+ void synchronize() {
172
+ check_cuda(cudaStreamSynchronize(stream_), "cudaStreamSynchronize");
173
+ }
174
+
175
+ std::unordered_map<std::string, Tensor> infer(const float* input) {
176
+ enqueue(input);
177
+ std::unordered_map<std::string, Tensor> output;
178
+ for (const auto& tensor : tensors_) {
179
+ if (tensor.input) {
180
+ continue;
181
+ }
182
+ Tensor host;
183
+ host.shape = tensor.shape;
184
+ host.values.resize(tensor.elements);
185
+ check_cuda(cudaMemcpyAsync(
186
+ host.values.data(), tensor.device,
187
+ tensor.elements * sizeof(float), cudaMemcpyDeviceToHost,
188
+ stream_
189
+ ), "cudaMemcpyAsync output");
190
+ output.emplace(tensor.name, std::move(host));
191
+ }
192
+ synchronize();
193
+ return output;
194
+ }
195
+
196
+ const std::unordered_map<std::string, Tensor>& infer_reuse(
197
+ const float* input
198
+ ) {
199
+ if (host_outputs_.empty()) {
200
+ for (const auto& tensor : tensors_) {
201
+ if (tensor.input) {
202
+ continue;
203
+ }
204
+ Tensor host;
205
+ host.shape = tensor.shape;
206
+ host.values.resize(tensor.elements);
207
+ host_outputs_.emplace(tensor.name, std::move(host));
208
+ }
209
+ }
210
+ enqueue(input);
211
+ for (const auto& tensor : tensors_) {
212
+ if (tensor.input) {
213
+ continue;
214
+ }
215
+ auto& host = host_outputs_.at(tensor.name);
216
+ check_cuda(cudaMemcpyAsync(
217
+ host.values.data(), tensor.device,
218
+ tensor.elements * sizeof(float), cudaMemcpyDeviceToHost,
219
+ stream_
220
+ ), "cudaMemcpyAsync reusable output");
221
+ }
222
+ synchronize();
223
+ return host_outputs_;
224
+ }
225
+
226
+ InferenceTiming benchmark(const float* input, int warmup, int iterations) {
227
+ if (warmup < 0 || iterations <= 0) {
228
+ throw std::invalid_argument("invalid benchmark iteration count");
229
+ }
230
+ for (int index = 0; index < warmup; ++index) {
231
+ enqueue(input);
232
+ synchronize();
233
+ }
234
+ std::vector<double> samples;
235
+ samples.reserve(static_cast<std::size_t>(iterations));
236
+ for (int index = 0; index < iterations; ++index) {
237
+ const auto started = std::chrono::steady_clock::now();
238
+ enqueue(input);
239
+ synchronize();
240
+ const auto stopped = std::chrono::steady_clock::now();
241
+ samples.push_back(std::chrono::duration<double, std::milli>(
242
+ stopped - started
243
+ ).count());
244
+ }
245
+ std::sort(samples.begin(), samples.end());
246
+ const auto percentile = [&samples](double fraction) {
247
+ const auto position = static_cast<std::size_t>(std::floor(
248
+ fraction * static_cast<double>(samples.size() - 1)
249
+ ));
250
+ return samples[position];
251
+ };
252
+ const double sum = std::accumulate(samples.begin(), samples.end(), 0.0);
253
+ return {
254
+ sum / static_cast<double>(samples.size()),
255
+ percentile(0.50),
256
+ percentile(0.95),
257
+ samples.front(),
258
+ samples.back(),
259
+ };
260
+ }
261
+
262
+ const std::vector<std::int64_t>& input_shape() const {
263
+ return tensors_[static_cast<std::size_t>(input_index_)].shape;
264
+ }
265
+
266
+ std::size_t input_elements() const {
267
+ return tensors_[static_cast<std::size_t>(input_index_)].elements;
268
+ }
269
+
270
+ private:
271
+ Logger logger_;
272
+ struct RuntimeDeleter {
273
+ template <typename T>
274
+ void operator()(T* pointer) const { delete pointer; }
275
+ };
276
+ std::unique_ptr<nvinfer1::IRuntime, RuntimeDeleter> runtime_;
277
+ std::unique_ptr<nvinfer1::ICudaEngine, RuntimeDeleter> engine_;
278
+ std::unique_ptr<nvinfer1::IExecutionContext, RuntimeDeleter> context_;
279
+ cudaStream_t stream_{};
280
+ std::vector<DeviceTensor> tensors_;
281
+ std::unordered_map<std::string, Tensor> host_outputs_;
282
+ int input_index_{-1};
283
+ };
284
+
285
+ TensorRTRunner::TensorRTRunner(const std::string& engine_path)
286
+ : impl_(std::make_unique<Impl>(engine_path)) {}
287
+ TensorRTRunner::~TensorRTRunner() = default;
288
+ TensorRTRunner::TensorRTRunner(TensorRTRunner&&) noexcept = default;
289
+ TensorRTRunner& TensorRTRunner::operator=(TensorRTRunner&&) noexcept = default;
290
+
291
+ const std::vector<std::int64_t>& TensorRTRunner::input_shape() const {
292
+ return impl_->input_shape();
293
+ }
294
+ std::size_t TensorRTRunner::input_elements() const {
295
+ return impl_->input_elements();
296
+ }
297
+ std::unordered_map<std::string, Tensor> TensorRTRunner::infer(
298
+ const float* input
299
+ ) {
300
+ return impl_->infer(input);
301
+ }
302
+ const std::unordered_map<std::string, Tensor>& TensorRTRunner::infer_reuse(
303
+ const float* input
304
+ ) {
305
+ return impl_->infer_reuse(input);
306
+ }
307
+ InferenceTiming TensorRTRunner::benchmark(
308
+ const float* input, int warmup, int iterations
309
+ ) {
310
+ return impl_->benchmark(input, warmup, iterations);
311
+ }
312
+
313
+ } // namespace rclane
dataset.py CHANGED
@@ -33,6 +33,23 @@ def normalize_image(img_bgr, W, H):
33
  return torch.from_numpy(np.ascontiguousarray(x.transpose(2, 0, 1)))
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def sparse_from_dense(gt):
37
  """Keep only foreground pixels of the dense GT maps."""
38
  ys, xs = np.where(gt["seg_map"] > 0.5)
 
33
  return torch.from_numpy(np.ascontiguousarray(x.transpose(2, 0, 1)))
34
 
35
 
36
+ def normalize_image_numpy(img_bgr, W, H):
37
+ """Fast inference-only BGR uint8 -> normalized ``(1, 3, H, W)`` NumPy.
38
+
39
+ ``cv2.dnn.blobFromImage`` performs resize, RGB channel swap and NCHW
40
+ packing in compiled code. The result is numerically equivalent to
41
+ :func:`normalize_image` within float32 rounding, while avoiding the
42
+ temporary HWC float image and Torch tensor wrapper.
43
+ """
44
+ images = cv2.dnn.blobFromImage(
45
+ img_bgr, scalefactor=1.0 / 255.0, size=(W, H), mean=(0, 0, 0),
46
+ swapRB=True, crop=False,
47
+ )
48
+ images[0] -= _MEAN.reshape(3, 1, 1)
49
+ images[0] /= _STD.reshape(3, 1, 1)
50
+ return images
51
+
52
+
53
  def sparse_from_dense(gt):
54
  """Keep only foreground pixels of the dense GT maps."""
55
  ys, xs = np.where(gt["seg_map"] > 0.5)
decode.py CHANGED
@@ -26,6 +26,44 @@ Map layout (matches encode.py / rclane.py, channel-first):
26
  import numpy as np
27
  import cv2
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  # --------------------------------------------------------------------------- #
31
  # Lane container (replaces FloatLengthLine + PointSelf)
@@ -36,32 +74,51 @@ class Lane:
36
  self.height = height
37
  self.points = [] # list of (x, y, score)
38
  self._score_sum = 0.0
39
- self.lane_id = None # stable left-to-right index, set by order_lanes()
 
 
 
40
 
41
  def append(self, x, y, score):
 
 
42
  self.points.append((float(x), float(y), float(score)))
43
  self._score_sum += float(score)
44
 
45
  def reverse(self):
46
- self.points.reverse()
 
 
 
47
 
48
  def __len__(self):
49
  return len(self.points)
50
 
51
  @property
52
  def score(self):
53
- if not self.points:
54
  return 0.0
55
  return self._score_sum / len(self.points)
56
 
57
  def concat(self, other):
58
  out = Lane(self.width, self.height)
59
- out.points = self.points + other.points
 
 
 
 
 
 
 
 
60
  out._score_sum = self._score_sum + other._score_sum
61
  return out
62
 
63
  def xy(self):
64
- return np.array([(p[0], p[1]) for p in self.points], dtype=float)
 
 
 
65
 
66
  def iou(self, other, lane_width=15):
67
  """Rasterize both lanes (cv2.line, width 15) and return mask IoU."""
@@ -83,13 +140,28 @@ class Lane:
83
  # --------------------------------------------------------------------------- #
84
  # seeding
85
  # --------------------------------------------------------------------------- #
86
- def point_nms(prob, thr=0.5, min_dist=2, max_seeds=1024):
 
87
  """Greedy point-NMS: keep highest-prob foreground pixels >= min_dist apart."""
88
  H, W = prob.shape
89
  ys, xs = np.where(prob > thr)
90
  if len(ys) == 0:
91
  return []
92
  order = np.argsort(-prob[ys, xs])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  taken = np.zeros((H, W), dtype=bool)
94
  seeds = []
95
  r = min_dist
@@ -144,6 +216,341 @@ def decode_branch(cx, cy, semantic_fine, arrow, bound, step_length, seg_threshol
144
  return lane
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  # --------------------------------------------------------------------------- #
148
  # post-processing
149
  # --------------------------------------------------------------------------- #
@@ -255,14 +662,46 @@ def _bottom_x(lane):
255
  return float(xy[int(np.argmax(xy[:, 1])), 0])
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  def order_lanes(lanes):
259
- """Sort lanes left-to-right and tag each with a stable `lane_id` (0 = leftmost).
260
 
261
  RCLane is anchor-free: `decode` emits lane instances in score order with no
262
- inherent identity -- lane 0 today could be the middle lane on the next frame.
263
- Ordering by the x at the bottom of the image (nearest the camera) imposes the
264
- usual left-to-right numbering so `lanes[i].lane_id == i` is consistent across
265
- frames. Returns a new list; also sets `.lane_id` on each Lane in place.
266
  """
267
  ordered = sorted(lanes, key=_bottom_x)
268
  for i, ln in enumerate(ordered):
@@ -270,6 +709,72 @@ def order_lanes(lanes):
270
  return ordered
271
 
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  def select_ego_lanes(lanes, max_lanes=4, ego_x=None,
274
  min_score_ratio=0.5, balance_sides=True):
275
  """Keep the closest reliable lane boundaries around the ego vehicle.
@@ -282,24 +787,45 @@ def select_ego_lanes(lanes, max_lanes=4, ego_x=None,
282
 
283
  For the usual four-lane output, ``balance_sides`` reserves two slots on
284
  either side of the camera centre when possible. Any unfilled slots are
285
- taken from the remaining closest candidates. The returned lanes are
286
- re-numbered from left to right.
 
287
  """
288
  if max_lanes is None:
289
- return order_lanes(lanes)
290
  if max_lanes <= 0:
291
  raise ValueError("max_lanes must be positive or None")
292
  if not 0.0 <= min_score_ratio <= 1.0:
293
  raise ValueError("min_score_ratio must be in [0, 1]")
294
 
295
  ordered = order_lanes(lanes)
296
- if len(ordered) <= max_lanes:
297
- return ordered
298
-
299
  if ego_x is None:
300
  ego_x = ordered[0].width / 2.0
301
  ego_x = float(ego_x)
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  best_score = max(lane.score for lane in ordered)
304
  reliable = [
305
  lane for lane in ordered
@@ -308,18 +834,20 @@ def select_ego_lanes(lanes, max_lanes=4, ego_x=None,
308
  # Never let the reliability gate force the output below the requested cap.
309
  pool = reliable if len(reliable) >= max_lanes else ordered
310
 
 
 
311
  def proximity_key(lane):
312
- return (abs(_bottom_x(lane) - ego_x), -lane.score)
313
 
314
  ranked = sorted(pool, key=proximity_key)
315
  selected = []
316
  if balance_sides and max_lanes >= 2:
317
  left = sorted(
318
- (lane for lane in pool if _bottom_x(lane) < ego_x),
319
  key=proximity_key,
320
  )
321
  right = sorted(
322
- (lane for lane in pool if _bottom_x(lane) >= ego_x),
323
  key=proximity_key,
324
  )
325
  left_slots = max_lanes // 2
@@ -327,13 +855,14 @@ def select_ego_lanes(lanes, max_lanes=4, ego_x=None,
327
  selected.extend(left[:left_slots])
328
  selected.extend(right[:right_slots])
329
 
330
- for lane in ranked:
331
- if lane not in selected:
332
- selected.append(lane)
333
- if len(selected) == max_lanes:
334
- break
 
335
 
336
- return order_lanes(selected[:max_lanes])
337
 
338
 
339
  # --------------------------------------------------------------------------- #
@@ -344,7 +873,9 @@ def decode(seg_prob, up_arrow, down_arrow, up_bound, down_bound,
344
  score_thresh=0.10, iou_thresh=0.5, seed_threshold=None,
345
  max_seeds=1024, nms_max_lanes=128, nms_scale=0.25,
346
  sort_lanes=True, max_output_lanes=4, ego_x=None,
347
- ego_min_score_ratio=0.5, balance_ego_sides=True):
 
 
348
  """
349
  Args:
350
  seg_prob: (H, W) foreground probability.
@@ -361,17 +892,49 @@ def decode(seg_prob, up_arrow, down_arrow, up_bound, down_bound,
361
  H, W = seg_prob.shape
362
  if seed_threshold is None:
363
  seed_threshold = seg_threshold
364
- seeds = point_nms(seg_prob, seed_threshold, seed_min_dist, max_seeds)
 
 
 
365
  ub0, db0 = up_bound[0], down_bound[0] # bound channel 0 (both channels equal)
366
 
367
- lines = []
368
- for (x, y) in seeds:
369
- up = decode_branch(x, y, seg_prob, up_arrow, ub0, step_length, seg_threshold)
370
- down = decode_branch(x, y, seg_prob, down_arrow, db0, step_length, seg_threshold)
371
- up.reverse()
372
- full = up.concat(down)
373
- if len(full) > 1:
374
- lines.append(full)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
  lines = thresh_line(lines, score_thresh)
377
  lines = iou_nms(lines, iou_thresh, max_lanes=nms_max_lanes,
@@ -486,4 +1049,43 @@ if __name__ == "__main__":
486
  f"ego selector kept the wrong lanes: {ego_xs}"
487
  )
488
  assert [lane.lane_id for lane in ego_lanes] == [0, 1, 2, 3]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  print("OK -- ego post-processing keeps four reliable nearby lanes.")
 
26
  import numpy as np
27
  import cv2
28
 
29
+ try:
30
+ from numba import njit, prange
31
+ except ImportError: # portable fallback for environments without the JIT
32
+ njit = None
33
+ prange = range
34
+
35
+
36
+ if njit is not None:
37
+ @njit(cache=True)
38
+ def _greedy_seed_select_numba(sorted_x, sorted_y, height, width,
39
+ radius, max_seeds):
40
+ taken = np.zeros((height, width), dtype=np.uint8)
41
+ limit = len(sorted_x) if max_seeds < 0 else min(
42
+ len(sorted_x), max_seeds
43
+ )
44
+ seeds = np.empty((limit, 2), dtype=np.int32)
45
+ seed_count = 0
46
+ for candidate in range(len(sorted_x)):
47
+ x = int(sorted_x[candidate])
48
+ y = int(sorted_y[candidate])
49
+ if taken[y, x] != 0:
50
+ continue
51
+ seeds[seed_count, 0] = x
52
+ seeds[seed_count, 1] = y
53
+ seed_count += 1
54
+ y_start = max(0, y - radius)
55
+ y_stop = min(height, y + radius + 1)
56
+ x_start = max(0, x - radius)
57
+ x_stop = min(width, x + radius + 1)
58
+ for yy in range(y_start, y_stop):
59
+ for xx in range(x_start, x_stop):
60
+ taken[yy, xx] = 1
61
+ if max_seeds >= 0 and seed_count >= max_seeds:
62
+ break
63
+ return seeds[:seed_count]
64
+ else:
65
+ _greedy_seed_select_numba = None
66
+
67
 
68
  # --------------------------------------------------------------------------- #
69
  # Lane container (replaces FloatLengthLine + PointSelf)
 
74
  self.height = height
75
  self.points = [] # list of (x, y, score)
76
  self._score_sum = 0.0
77
+ self.lane_id = None
78
+ self.lane_role = None
79
+ self.is_ego_boundary = False
80
+ self.lateral_rank = None
81
 
82
  def append(self, x, y, score):
83
+ if isinstance(self.points, np.ndarray):
84
+ self.points = [tuple(map(float, point)) for point in self.points]
85
  self.points.append((float(x), float(y), float(score)))
86
  self._score_sum += float(score)
87
 
88
  def reverse(self):
89
+ if isinstance(self.points, np.ndarray):
90
+ self.points = self.points[::-1].copy()
91
+ else:
92
+ self.points.reverse()
93
 
94
  def __len__(self):
95
  return len(self.points)
96
 
97
  @property
98
  def score(self):
99
+ if len(self.points) == 0:
100
  return 0.0
101
  return self._score_sum / len(self.points)
102
 
103
  def concat(self, other):
104
  out = Lane(self.width, self.height)
105
+ if isinstance(self.points, np.ndarray) or isinstance(
106
+ other.points, np.ndarray
107
+ ):
108
+ out.points = np.concatenate((
109
+ np.asarray(self.points, dtype=np.float32),
110
+ np.asarray(other.points, dtype=np.float32),
111
+ ))
112
+ else:
113
+ out.points = self.points + other.points
114
  out._score_sum = self._score_sum + other._score_sum
115
  return out
116
 
117
  def xy(self):
118
+ points = np.asarray(self.points, dtype=np.float32)
119
+ if points.size == 0:
120
+ return np.empty((0, 2), dtype=np.float32)
121
+ return points[:, :2]
122
 
123
  def iou(self, other, lane_width=15):
124
  """Rasterize both lanes (cv2.line, width 15) and return mask IoU."""
 
140
  # --------------------------------------------------------------------------- #
141
  # seeding
142
  # --------------------------------------------------------------------------- #
143
+ def point_nms(prob, thr=0.5, min_dist=2, max_seeds=1024,
144
+ backend="auto"):
145
  """Greedy point-NMS: keep highest-prob foreground pixels >= min_dist apart."""
146
  H, W = prob.shape
147
  ys, xs = np.where(prob > thr)
148
  if len(ys) == 0:
149
  return []
150
  order = np.argsort(-prob[ys, xs])
151
+ selected_backend = backend
152
+ if backend == "auto":
153
+ selected_backend = (
154
+ "numba" if _greedy_seed_select_numba is not None else "python"
155
+ )
156
+ if selected_backend == "numba":
157
+ limit = -1 if max_seeds is None else int(max_seeds)
158
+ return _greedy_seed_select_numba(
159
+ np.ascontiguousarray(xs[order], dtype=np.int32),
160
+ np.ascontiguousarray(ys[order], dtype=np.int32),
161
+ H, W, int(min_dist), limit,
162
+ )
163
+ if selected_backend != "python":
164
+ raise ValueError("point-NMS backend must be auto, numba, or python")
165
  taken = np.zeros((H, W), dtype=bool)
166
  seeds = []
167
  r = min_dist
 
216
  return lane
217
 
218
 
219
+ def decode_branches_batch(seeds, semantic_fine, arrow, bound, step_length,
220
+ seg_threshold):
221
+ """Vectorized equivalent of :func:`decode_branch` for many seeds.
222
+
223
+ The relay walk is inherently sequential along each lane, but every seed at
224
+ a given crawl step is independent. Advancing all active seeds with NumPy
225
+ gathers removes the expensive Python ``seeds x steps`` nested loop while
226
+ retaining the original stopping rule and integer pixel trajectory.
227
+
228
+ Returns:
229
+ ``(points, lengths)`` where ``points`` has shape ``(N, H, 3)`` and each
230
+ valid prefix stores ``(x, y, score)`` exactly like ``Lane.points``.
231
+ """
232
+ H, W = semantic_fine.shape
233
+ seed_array = np.asarray(seeds, dtype=np.int32)
234
+ if seed_array.size == 0:
235
+ return np.empty((0, H, 3), dtype=np.float32), np.zeros(0, np.int32)
236
+ if seed_array.ndim != 2 or seed_array.shape[1] != 2:
237
+ raise ValueError("seeds must have shape (N, 2)")
238
+
239
+ count = len(seed_array)
240
+ cx = seed_array[:, 0].copy()
241
+ cy = seed_array[:, 1].copy()
242
+ active = np.ones(count, dtype=bool)
243
+ lengths = np.zeros(count, dtype=np.int32)
244
+ remain_sq_sum = np.zeros(count, dtype=np.float64)
245
+ remain_count = np.zeros(count, dtype=np.int32)
246
+ points = np.empty((count, H, 3), dtype=np.float32)
247
+ arrow_dx, arrow_dy = arrow[0], arrow[1]
248
+
249
+ for index in range(H):
250
+ active_ids = np.flatnonzero(active)
251
+ if len(active_ids) == 0:
252
+ break
253
+ current_x = cx[active_ids]
254
+ current_y = cy[active_ids]
255
+ current_score = semantic_fine[current_y, current_x]
256
+ foreground = current_score > seg_threshold
257
+ if np.any(foreground):
258
+ foreground_ids = active_ids[foreground]
259
+ remain = (
260
+ bound[current_y[foreground], current_x[foreground]]
261
+ * 100.0 / step_length + index
262
+ )
263
+ remain_sq_sum[foreground_ids] += remain * remain
264
+ remain_count[foreground_ids] += 1
265
+
266
+ dx = arrow_dx[current_y, current_x]
267
+ dy = arrow_dy[current_y, current_x]
268
+ norm = np.sqrt(dx * dx + dy * dy)
269
+ movable = np.isfinite(norm) & (norm != 0.0)
270
+ if np.any(~movable):
271
+ active[active_ids[~movable]] = False
272
+ active_ids = active_ids[movable]
273
+ if len(active_ids) == 0:
274
+ continue
275
+ next_x = np.floor(
276
+ cx[active_ids] + dx[movable] / norm[movable] * step_length
277
+ ).astype(np.int32)
278
+ next_y = np.floor(
279
+ cy[active_ids] + dy[movable] / norm[movable] * step_length
280
+ ).astype(np.int32)
281
+ in_bounds = (
282
+ (next_x >= 0) & (next_x < W) & (next_y >= 0) & (next_y < H)
283
+ )
284
+ if np.any(~in_bounds):
285
+ active[active_ids[~in_bounds]] = False
286
+ active_ids = active_ids[in_bounds]
287
+ if len(active_ids) == 0:
288
+ continue
289
+ next_x = next_x[in_bounds]
290
+ next_y = next_y[in_bounds]
291
+ cx[active_ids] = next_x
292
+ cy[active_ids] = next_y
293
+ next_score = semantic_fine[next_y, next_x]
294
+ points[active_ids, index, 0] = next_x
295
+ points[active_ids, index, 1] = next_y
296
+ points[active_ids, index, 2] = next_score
297
+ lengths[active_ids] = index + 1
298
+
299
+ has_remaining = remain_count[active_ids] > 0
300
+ remaining = np.ones(len(active_ids), dtype=np.float64)
301
+ remaining[has_remaining] = np.sqrt(
302
+ remain_sq_sum[active_ids[has_remaining]]
303
+ / remain_count[active_ids[has_remaining]]
304
+ )
305
+ stop = (
306
+ (next_score <= seg_threshold)
307
+ & (index > remaining * 0.75)
308
+ )
309
+ if np.any(stop):
310
+ active[active_ids[stop]] = False
311
+ return points, lengths
312
+
313
+
314
+ if njit is not None:
315
+ @njit(cache=True, parallel=True)
316
+ def _decode_branches_numba_impl(seeds, semantic_fine, arrow, bound,
317
+ step_length, seg_threshold):
318
+ """Parallel scalar relay walks compiled to native CPU code."""
319
+ height, width = semantic_fine.shape
320
+ seed_count = len(seeds)
321
+ points = np.empty((seed_count, height, 3), dtype=np.float32)
322
+ lengths = np.zeros(seed_count, dtype=np.int32)
323
+ for seed_index in prange(seed_count):
324
+ cx = int(seeds[seed_index, 0])
325
+ cy = int(seeds[seed_index, 1])
326
+ remain_sq_sum = 0.0
327
+ remain_count = 0
328
+ for index in range(height):
329
+ if semantic_fine[cy, cx] > seg_threshold:
330
+ remain = (
331
+ bound[cy, cx] * 100.0 / step_length + index
332
+ )
333
+ remain_sq_sum += remain * remain
334
+ remain_count += 1
335
+
336
+ dx = arrow[0, cy, cx]
337
+ dy = arrow[1, cy, cx]
338
+ norm = np.sqrt(dx * dx + dy * dy)
339
+ if norm == 0.0 or not np.isfinite(norm):
340
+ break
341
+ cx = int(np.floor(cx + dx / norm * step_length))
342
+ cy = int(np.floor(cy + dy / norm * step_length))
343
+ if not (0 <= cx < width and 0 <= cy < height):
344
+ break
345
+
346
+ score = semantic_fine[cy, cx]
347
+ points[seed_index, index, 0] = cx
348
+ points[seed_index, index, 1] = cy
349
+ points[seed_index, index, 2] = score
350
+ lengths[seed_index] = index + 1
351
+
352
+ ret = (
353
+ np.sqrt(remain_sq_sum / remain_count)
354
+ if remain_count else 1.0
355
+ )
356
+ if score > seg_threshold:
357
+ continue
358
+ if index > ret * 0.75:
359
+ break
360
+ return points, lengths
361
+
362
+
363
+ @njit(cache=True, parallel=True)
364
+ def _candidate_metadata_numba_impl(up_points, up_lengths,
365
+ down_points, down_lengths, bin_px):
366
+ seed_count = len(up_lengths)
367
+ scores = np.zeros(seed_count, dtype=np.float64)
368
+ bins = np.zeros(seed_count, dtype=np.int32)
369
+ total_lengths = up_lengths + down_lengths
370
+ for seed_index in prange(seed_count):
371
+ up_length = int(up_lengths[seed_index])
372
+ down_length = int(down_lengths[seed_index])
373
+ total_length = up_length + down_length
374
+ if total_length <= 1:
375
+ continue
376
+ y_values = np.empty(total_length, dtype=np.float32)
377
+ score_sum = 0.0
378
+ position = 0
379
+ for point_index in range(up_length):
380
+ score_sum += up_points[seed_index, point_index, 2]
381
+ y_values[position] = up_points[seed_index, point_index, 1]
382
+ position += 1
383
+ for point_index in range(down_length):
384
+ score_sum += down_points[seed_index, point_index, 2]
385
+ y_values[position] = down_points[seed_index, point_index, 1]
386
+ position += 1
387
+ scores[seed_index] = score_sum / total_length
388
+ median_y = np.median(y_values)
389
+ lower_x = np.empty(total_length, dtype=np.float32)
390
+ lower_count = 0
391
+ for point_index in range(up_length):
392
+ if up_points[seed_index, point_index, 1] >= median_y:
393
+ lower_x[lower_count] = up_points[
394
+ seed_index, point_index, 0
395
+ ]
396
+ lower_count += 1
397
+ for point_index in range(down_length):
398
+ if down_points[seed_index, point_index, 1] >= median_y:
399
+ lower_x[lower_count] = down_points[
400
+ seed_index, point_index, 0
401
+ ]
402
+ lower_count += 1
403
+ bins[seed_index] = int(
404
+ np.median(lower_x[:lower_count]) // bin_px
405
+ )
406
+ return total_lengths, scores, bins
407
+ else:
408
+ _decode_branches_numba_impl = None
409
+ _candidate_metadata_numba_impl = None
410
+
411
+
412
+ def decode_branches_numba(seeds, semantic_fine, arrow, bound, step_length,
413
+ seg_threshold):
414
+ if _decode_branches_numba_impl is None:
415
+ raise RuntimeError(
416
+ "Numba crawl requested but numba is not installed; "
417
+ "install requirements.txt or use crawl_backend='numpy'"
418
+ )
419
+ seed_array = np.asarray(seeds, dtype=np.int32)
420
+ if seed_array.size == 0:
421
+ height = semantic_fine.shape[0]
422
+ return (
423
+ np.empty((0, height, 3), dtype=np.float32),
424
+ np.zeros(0, dtype=np.int32),
425
+ )
426
+ return _decode_branches_numba_impl(
427
+ seed_array,
428
+ np.ascontiguousarray(semantic_fine, dtype=np.float32),
429
+ np.ascontiguousarray(arrow, dtype=np.float32),
430
+ np.ascontiguousarray(bound, dtype=np.float32),
431
+ float(step_length),
432
+ float(seg_threshold),
433
+ )
434
+
435
+
436
+ def warmup_decode_backend(crawl_backend="auto"):
437
+ """Compile the optional Numba backend before latency measurements."""
438
+ backend = crawl_backend
439
+ if crawl_backend == "auto":
440
+ backend = "numba" if njit is not None else "numpy"
441
+ if backend != "numba":
442
+ return backend
443
+ semantic = np.zeros((8, 8), dtype=np.float32)
444
+ arrow = np.zeros((2, 8, 8), dtype=np.float32)
445
+ arrow[1] = 1.0
446
+ bound = np.zeros((8, 8), dtype=np.float32)
447
+ points, lengths = decode_branches_numba(
448
+ [(4, 4)], semantic, arrow, bound, 1, 0.5
449
+ )
450
+ _candidate_metadata_numba_impl(
451
+ points, lengths, points, lengths, 16
452
+ )
453
+ _greedy_seed_select_numba(
454
+ np.array((4,), dtype=np.int32),
455
+ np.array((4,), dtype=np.int32),
456
+ 8, 8, 2, 1,
457
+ )
458
+ return backend
459
+
460
+
461
+ def configure_decode_threads(thread_count=8):
462
+ """Set Numba's relay-crawl worker count and return the applied value."""
463
+ if njit is None:
464
+ return 1
465
+ import numba
466
+ maximum = int(numba.config.NUMBA_NUM_THREADS)
467
+ if not 1 <= int(thread_count) <= maximum:
468
+ raise ValueError(f"decode threads must be in [1, {maximum}]")
469
+ numba.set_num_threads(int(thread_count))
470
+ return numba.get_num_threads()
471
+
472
+
473
+ def _candidate_metadata_numpy(up_points, up_lengths,
474
+ down_points, down_lengths, bin_px):
475
+ total_lengths = up_lengths + down_lengths
476
+ scores = np.zeros(len(total_lengths), dtype=np.float64)
477
+ bins = np.zeros(len(total_lengths), dtype=np.int32)
478
+ for seed_index, total_length in enumerate(total_lengths):
479
+ if total_length <= 1:
480
+ continue
481
+ up = up_points[seed_index, :up_lengths[seed_index]]
482
+ down = down_points[seed_index, :down_lengths[seed_index]]
483
+ merged = np.concatenate((up, down))
484
+ scores[seed_index] = merged[:, 2].sum(dtype=np.float64) / len(merged)
485
+ median_y = np.median(merged[:, 1])
486
+ lower = merged[merged[:, 1] >= median_y]
487
+ bins[seed_index] = int(np.median(lower[:, 0]) // bin_px)
488
+ return total_lengths, scores, bins
489
+
490
+
491
+ def _preselect_batched_candidates(up_points, up_lengths,
492
+ down_points, down_lengths, score_threshold,
493
+ max_lanes, backend, bin_px=16):
494
+ metadata = (
495
+ _candidate_metadata_numba_impl
496
+ if backend == "numba" else _candidate_metadata_numpy
497
+ )
498
+ total_lengths, scores, bins = metadata(
499
+ up_points, up_lengths, down_points, down_lengths, bin_px
500
+ )
501
+ valid = np.flatnonzero(
502
+ (total_lengths > 1) & (scores >= score_threshold)
503
+ )
504
+ order = valid[np.argsort(-scores[valid], kind="stable")]
505
+ if max_lanes is None or len(order) <= max_lanes:
506
+ return order
507
+
508
+ buckets = {}
509
+ for candidate in order:
510
+ buckets.setdefault(int(bins[candidate]), []).append(int(candidate))
511
+ keys = list(buckets)
512
+ positions = {key: 0 for key in keys}
513
+ selected = []
514
+ while len(selected) < max_lanes:
515
+ progressed = False
516
+ for key in keys:
517
+ position = positions[key]
518
+ if position < len(buckets[key]):
519
+ selected.append(buckets[key][position])
520
+ positions[key] += 1
521
+ progressed = True
522
+ if len(selected) >= max_lanes:
523
+ break
524
+ if not progressed:
525
+ break
526
+ return np.asarray(
527
+ sorted(selected, key=lambda index: scores[index], reverse=True),
528
+ dtype=np.int32,
529
+ )
530
+
531
+
532
+ def _lines_from_batched_crawls(up_points, up_lengths,
533
+ down_points, down_lengths, width, height,
534
+ candidate_indices=None):
535
+ lines = []
536
+ if candidate_indices is None:
537
+ candidate_indices = range(len(up_lengths))
538
+ for seed_index in candidate_indices:
539
+ up_length = int(up_lengths[seed_index])
540
+ down_length = int(down_lengths[seed_index])
541
+ if up_length + down_length <= 1:
542
+ continue
543
+ merged = np.concatenate((
544
+ up_points[seed_index, :up_length][::-1],
545
+ down_points[seed_index, :down_length],
546
+ ))
547
+ lane = Lane(width, height)
548
+ lane.points = merged
549
+ lane._score_sum = float(merged[:, 2].sum(dtype=np.float64))
550
+ lines.append(lane)
551
+ return lines
552
+
553
+
554
  # --------------------------------------------------------------------------- #
555
  # post-processing
556
  # --------------------------------------------------------------------------- #
 
662
  return float(xy[int(np.argmax(xy[:, 1])), 0])
663
 
664
 
665
+ def _ego_reference_x(lane, target_y=None):
666
+ """Estimate where a boundary meets the near-camera reference row.
667
+
668
+ On a sharp bend, multiple boundaries can leave through the same image side.
669
+ Comparing their last visible x then becomes ambiguous (both are about 0 or
670
+ ``width - 1``), and a short outer boundary can be mistaken for the ego-lane
671
+ boundary. Extrapolating the lower 40% of each polyline to a common row keeps
672
+ their lateral order after they leave the image.
673
+ """
674
+ xy = lane.xy()
675
+ if len(xy) < 2:
676
+ return _bottom_x(lane)
677
+ xy = xy[np.isfinite(xy).all(axis=1)]
678
+ if len(xy) < 2:
679
+ return _bottom_x(lane)
680
+ if target_y is None:
681
+ target_y = lane.height - 1.0
682
+
683
+ cutoff = np.quantile(xy[:, 1], 0.6)
684
+ lower = xy[xy[:, 1] >= cutoff]
685
+ if len(lower) < 2 or np.ptp(lower[:, 1]) < 1.0:
686
+ return _bottom_x(lane)
687
+
688
+ ys = lower[:, 1]
689
+ xs = lower[:, 0]
690
+ centered_y = ys - ys.mean()
691
+ denominator = float(np.dot(centered_y, centered_y))
692
+ if denominator <= 1e-6:
693
+ return _bottom_x(lane)
694
+ slope = float(np.dot(centered_y, xs - xs.mean()) / denominator)
695
+ return float(xs.mean() + slope * (float(target_y) - ys.mean()))
696
+
697
+
698
  def order_lanes(lanes):
699
+ """Sort lanes left-to-right and assign a frame-local index.
700
 
701
  RCLane is anchor-free: `decode` emits lane instances in score order with no
702
+ inherent identity. This helper only establishes spatial order inside one
703
+ frame; it must not be used as a persistent video identity because a missing
704
+ outer lane would shift every following index.
 
705
  """
706
  ordered = sorted(lanes, key=_bottom_x)
707
  for i, ln in enumerate(ordered):
 
709
  return ordered
710
 
711
 
712
+ def assign_ego_lane_roles(lanes, ego_x=None):
713
+ """Assign stable semantic IDs relative to the ego vehicle.
714
+
715
+ IDs describe a lane boundary's role rather than its position in a variable
716
+ length list:
717
+
718
+ * P1: nearest boundary left of ego (current-lane left boundary)
719
+ * P2: nearest boundary right of ego (current-lane right boundary)
720
+ * P0: next boundary to the left
721
+ * P3: next boundary to the right
722
+
723
+ Consequently P1/P2 do not become P0/P1 merely because an outer boundary is
724
+ temporarily missing. With the default four-lane cap, returned IDs are in
725
+ ``[0, 3]``. More uncapped lanes continue outward with negative IDs on the
726
+ left and IDs greater than three on the right.
727
+ """
728
+ if not lanes:
729
+ return []
730
+ if ego_x is None:
731
+ ego_x = lanes[0].width / 2.0
732
+ ego_x = float(ego_x)
733
+
734
+ for lane in lanes:
735
+ lane.lane_id = None
736
+ lane.lane_role = None
737
+ lane.is_ego_boundary = False
738
+ lane.lateral_rank = None
739
+
740
+ reference_x = {lane: _ego_reference_x(lane) for lane in lanes}
741
+ left = sorted(
742
+ (lane for lane in lanes if reference_x[lane] < ego_x),
743
+ key=lambda lane: (abs(reference_x[lane] - ego_x), -lane.score),
744
+ )
745
+ right = sorted(
746
+ (lane for lane in lanes if reference_x[lane] >= ego_x),
747
+ key=lambda lane: (abs(reference_x[lane] - ego_x), -lane.score),
748
+ )
749
+
750
+ for rank, lane in enumerate(left, 1):
751
+ lane.lane_id = 2 - rank # nearest left=P1, next=P0
752
+ lane.lateral_rank = -rank
753
+ lane.is_ego_boundary = rank == 1
754
+ lane.lane_role = "ego_left" if rank == 1 else f"left_{rank}"
755
+ for rank, lane in enumerate(right, 1):
756
+ lane.lane_id = 1 + rank # nearest right=P2, next=P3
757
+ lane.lateral_rank = rank
758
+ lane.is_ego_boundary = rank == 1
759
+ lane.lane_role = "ego_right" if rank == 1 else f"right_{rank}"
760
+
761
+ return sorted(lanes, key=lambda lane: lane.lane_id)
762
+
763
+
764
+ def ego_lane_boundaries(lanes):
765
+ """Return ``(left, right)`` boundaries of the lane containing ego.
766
+
767
+ Either value can be ``None`` when that side was not detected.
768
+ """
769
+ left = next(
770
+ (lane for lane in lanes if lane.lane_role == "ego_left"), None
771
+ )
772
+ right = next(
773
+ (lane for lane in lanes if lane.lane_role == "ego_right"), None
774
+ )
775
+ return left, right
776
+
777
+
778
  def select_ego_lanes(lanes, max_lanes=4, ego_x=None,
779
  min_score_ratio=0.5, balance_sides=True):
780
  """Keep the closest reliable lane boundaries around the ego vehicle.
 
787
 
788
  For the usual four-lane output, ``balance_sides`` reserves two slots on
789
  either side of the camera centre when possible. Any unfilled slots are
790
+ taken from the remaining closest candidates. The returned IDs are semantic:
791
+ P1/P2 are the current-lane boundaries, while P0/P3 are the adjacent outer
792
+ boundaries. Missing outer lanes therefore do not shift the ego-lane IDs.
793
  """
794
  if max_lanes is None:
795
+ return assign_ego_lane_roles(lanes, ego_x)
796
  if max_lanes <= 0:
797
  raise ValueError("max_lanes must be positive or None")
798
  if not 0.0 <= min_score_ratio <= 1.0:
799
  raise ValueError("min_score_ratio must be in [0, 1]")
800
 
801
  ordered = order_lanes(lanes)
802
+ if not ordered:
803
+ return []
 
804
  if ego_x is None:
805
  ego_x = ordered[0].width / 2.0
806
  ego_x = float(ego_x)
807
 
808
+ # The four-lane semantic contract has exactly two possible boundaries per
809
+ # side: P0/P1 on the left and P2/P3 on the right. If one side is missing,
810
+ # return fewer lanes instead of filling the gap with P4/P-1 farther out.
811
+ if len(ordered) <= max_lanes:
812
+ if balance_sides and max_lanes == 4:
813
+ reference_x = {lane: _ego_reference_x(lane) for lane in ordered}
814
+
815
+ def near_ego(lane):
816
+ return (abs(reference_x[lane] - ego_x), -lane.score)
817
+
818
+ left = sorted(
819
+ (lane for lane in ordered if reference_x[lane] < ego_x),
820
+ key=near_ego,
821
+ )
822
+ right = sorted(
823
+ (lane for lane in ordered if reference_x[lane] >= ego_x),
824
+ key=near_ego,
825
+ )
826
+ ordered = left[:2] + right[:2]
827
+ return assign_ego_lane_roles(ordered, ego_x)
828
+
829
  best_score = max(lane.score for lane in ordered)
830
  reliable = [
831
  lane for lane in ordered
 
834
  # Never let the reliability gate force the output below the requested cap.
835
  pool = reliable if len(reliable) >= max_lanes else ordered
836
 
837
+ reference_x = {lane: _ego_reference_x(lane) for lane in pool}
838
+
839
  def proximity_key(lane):
840
+ return (abs(reference_x[lane] - ego_x), -lane.score)
841
 
842
  ranked = sorted(pool, key=proximity_key)
843
  selected = []
844
  if balance_sides and max_lanes >= 2:
845
  left = sorted(
846
+ (lane for lane in pool if reference_x[lane] < ego_x),
847
  key=proximity_key,
848
  )
849
  right = sorted(
850
+ (lane for lane in pool if reference_x[lane] >= ego_x),
851
  key=proximity_key,
852
  )
853
  left_slots = max_lanes // 2
 
855
  selected.extend(left[:left_slots])
856
  selected.extend(right[:right_slots])
857
 
858
+ if not (balance_sides and max_lanes == 4):
859
+ for lane in ranked:
860
+ if lane not in selected:
861
+ selected.append(lane)
862
+ if len(selected) == max_lanes:
863
+ break
864
 
865
+ return assign_ego_lane_roles(selected[:max_lanes], ego_x)
866
 
867
 
868
  # --------------------------------------------------------------------------- #
 
873
  score_thresh=0.10, iou_thresh=0.5, seed_threshold=None,
874
  max_seeds=1024, nms_max_lanes=128, nms_scale=0.25,
875
  sort_lanes=True, max_output_lanes=4, ego_x=None,
876
+ ego_min_score_ratio=0.5, balance_ego_sides=True,
877
+ batch_crawl=True, crawl_backend="auto",
878
+ point_nms_backend="auto"):
879
  """
880
  Args:
881
  seg_prob: (H, W) foreground probability.
 
892
  H, W = seg_prob.shape
893
  if seed_threshold is None:
894
  seed_threshold = seg_threshold
895
+ seeds = point_nms(
896
+ seg_prob, seed_threshold, seed_min_dist, max_seeds,
897
+ backend=point_nms_backend,
898
+ )
899
  ub0, db0 = up_bound[0], down_bound[0] # bound channel 0 (both channels equal)
900
 
901
+ if batch_crawl:
902
+ backend = crawl_backend
903
+ if crawl_backend == "auto":
904
+ backend = "numba" if njit is not None else "numpy"
905
+ if backend not in ("numba", "numpy"):
906
+ raise ValueError("crawl_backend must be auto, numba, or numpy")
907
+ crawl = (
908
+ decode_branches_numba if backend == "numba"
909
+ else decode_branches_batch
910
+ )
911
+ up_points, up_lengths = crawl(
912
+ seeds, seg_prob, up_arrow, ub0, step_length, seg_threshold
913
+ )
914
+ down_points, down_lengths = crawl(
915
+ seeds, seg_prob, down_arrow, db0, step_length, seg_threshold
916
+ )
917
+ candidate_indices = _preselect_batched_candidates(
918
+ up_points, up_lengths, down_points, down_lengths,
919
+ score_thresh, nms_max_lanes, backend,
920
+ )
921
+ lines = _lines_from_batched_crawls(
922
+ up_points, up_lengths, down_points, down_lengths, W, H,
923
+ candidate_indices,
924
+ )
925
+ else:
926
+ lines = []
927
+ for (x, y) in seeds:
928
+ up = decode_branch(
929
+ x, y, seg_prob, up_arrow, ub0, step_length, seg_threshold
930
+ )
931
+ down = decode_branch(
932
+ x, y, seg_prob, down_arrow, db0, step_length, seg_threshold
933
+ )
934
+ up.reverse()
935
+ full = up.concat(down)
936
+ if len(full) > 1:
937
+ lines.append(full)
938
 
939
  lines = thresh_line(lines, score_thresh)
940
  lines = iou_nms(lines, iou_thresh, max_lanes=nms_max_lanes,
 
1049
  f"ego selector kept the wrong lanes: {ego_xs}"
1050
  )
1051
  assert [lane.lane_id for lane in ego_lanes] == [0, 1, 2, 3]
1052
+ ego_left, ego_right = ego_lane_boundaries(ego_lanes)
1053
+ assert ego_left is not None and int(_bottom_x(ego_left)) == 95
1054
+ assert ego_right is not None and int(_bottom_x(ego_right)) == 748
1055
+
1056
+ # Semantic IDs must not shift when an outer lane disappears. P1/P2 remain
1057
+ # the current-lane boundaries and P3 remains the next boundary on the right.
1058
+ missing_outer_left = select_ego_lanes(
1059
+ [vertical_lane(95, 0.94), vertical_lane(748, 0.89),
1060
+ vertical_lane(796, 0.81)],
1061
+ max_lanes=4,
1062
+ )
1063
+ assert [lane.lane_id for lane in missing_outer_left] == [1, 2, 3]
1064
+ missing_outer_right = select_ego_lanes(
1065
+ [vertical_lane(8, 0.93), vertical_lane(95, 0.94),
1066
+ vertical_lane(748, 0.89)],
1067
+ max_lanes=4,
1068
+ )
1069
+ assert [lane.lane_id for lane in missing_outer_right] == [0, 1, 2]
1070
+
1071
+ right_only = select_ego_lanes(
1072
+ [vertical_lane(500, 0.95), vertical_lane(600, 0.90),
1073
+ vertical_lane(700, 0.80)],
1074
+ max_lanes=4,
1075
+ )
1076
+ assert [lane.lane_id for lane in right_only] == [2, 3]
1077
+
1078
+ # Two right boundaries can both leave through x=width on a sharp curve.
1079
+ # The longer/nearer curve must remain P2 even if its last visible x is a
1080
+ # little farther right than the short outer curve's last x.
1081
+ near_right = Lane(800, 320)
1082
+ outer_right = Lane(800, 320)
1083
+ for x, y in ((700, 200), (740, 225), (780, 250)):
1084
+ near_right.append(x, y, 0.9)
1085
+ for x, y in ((700, 125), (750, 137.5), (790, 147.5)):
1086
+ outer_right.append(x, y, 0.8)
1087
+ curved_right = assign_ego_lane_roles([outer_right, near_right], ego_x=400)
1088
+ assert near_right.lane_id == 2 and near_right.lane_role == "ego_right"
1089
+ assert outer_right.lane_id == 3 and outer_right.lane_role == "right_2"
1090
+ assert [lane.lane_id for lane in curved_right] == [2, 3]
1091
  print("OK -- ego post-processing keeps four reliable nearby lanes.")
requirements.txt CHANGED
@@ -10,3 +10,6 @@ onnx>=1.16,<2.0
10
  # `onnxruntime` package alongside this package; both expose the same module.
11
  onnxruntime-gpu==1.27.0.dev20260511001
12
  torch==2.13.0+cu130
 
 
 
 
10
  # `onnxruntime` package alongside this package; both expose the same module.
11
  onnxruntime-gpu==1.27.0.dev20260511001
12
  torch==2.13.0+cu130
13
+ # TensorRT 10 is required because ONNX Runtime's provider links libnvinfer.so.10.
14
+ tensorrt-cu13==10.16.1.11
15
+ numba>=0.61,<0.63
test_video_bev_onnx.py ADDED
@@ -0,0 +1,781 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run RCLane ONNX and export ego-centric cubic lane models in BEV.
2
+
3
+ Each JSONL frame stores one metric polynomial per visible marking:
4
+
5
+ Y(X) = c0 + c1*X + c2*X^2 + c3*X^3
6
+
7
+ X points forward from ego and Y points left. Coefficients are valid only inside
8
+ the exported ``x_domain_m``; the implementation never extrapolates a detected
9
+ lane to the full 300 m visualization range.
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import time
16
+ from collections import Counter
17
+ from pathlib import Path
18
+
19
+ import cv2
20
+ import numpy as np
21
+
22
+ from bev import (
23
+ BevRange,
24
+ CameraCalibration,
25
+ clip_cubic_fit_to_funnel,
26
+ evaluate_cubic,
27
+ fit_cubic_lane,
28
+ model_lane_to_ground,
29
+ )
30
+ from dataset import normalize_image_numpy
31
+ from decode import configure_decode_threads, decode, warmup_decode_backend
32
+ from test_video_onnx import (
33
+ MODEL_HEIGHT,
34
+ MODEL_WIDTH,
35
+ OUTPUT_NAMES,
36
+ create_session,
37
+ draw_predictions,
38
+ softmax_foreground,
39
+ timing_summary,
40
+ )
41
+
42
+
43
+ LANE_COLORS = {
44
+ 0: (255, 255, 0),
45
+ 1: (0, 255, 0),
46
+ 2: (0, 165, 255),
47
+ 3: (255, 255, 0),
48
+ }
49
+
50
+
51
+ def parse_args():
52
+ parser = argparse.ArgumentParser(description=__doc__)
53
+ parser.add_argument("--model", required=True)
54
+ parser.add_argument("--video", required=True)
55
+ parser.add_argument("--output", default="runs/video_bev_e19.mp4")
56
+ parser.add_argument("--polynomials", default=None,
57
+ help="per-frame JSONL; defaults next to output")
58
+ parser.add_argument("--summary", default=None)
59
+ parser.add_argument(
60
+ "--provider", choices=("tensorrt", "cuda", "cpu"),
61
+ default="tensorrt",
62
+ )
63
+ parser.add_argument("--trt-cache-dir", default="exports/trt_cache")
64
+ parser.add_argument("--allow-tf32", action="store_true")
65
+ parser.add_argument(
66
+ "--start-frame", type=int, default=0,
67
+ help="zero-based source frame at which processing starts",
68
+ )
69
+ parser.add_argument("--max-frames", type=int, default=None)
70
+ parser.add_argument("--x-min", type=float, default=0.0)
71
+ parser.add_argument("--x-max", type=float, default=300.0)
72
+ parser.add_argument("--y-min", type=float, default=-85.0)
73
+ parser.add_argument("--y-max", type=float, default=85.0)
74
+ parser.add_argument(
75
+ "--max-cubic-rmse", type=float, default=0.5,
76
+ help="reject a cubic visualization above this metric RMSE",
77
+ )
78
+ parser.add_argument("--decode-seg-threshold", type=float, default=0.5)
79
+ parser.add_argument("--decode-seed-threshold", type=float, default=None)
80
+ parser.add_argument("--decode-seed-min-dist", type=int, default=2)
81
+ parser.add_argument("--decode-score-thresh", type=float, default=0.10)
82
+ parser.add_argument("--decode-nms-iou", type=float, default=0.5)
83
+ parser.add_argument("--decode-max-seeds", type=int, default=1024)
84
+ parser.add_argument(
85
+ "--decode-crawl-backend", choices=("auto", "numba", "numpy"),
86
+ default="auto",
87
+ )
88
+ parser.add_argument("--decode-cpu-threads", type=int, default=8)
89
+ parser.add_argument("--decode-nms-max-lanes", type=int, default=128)
90
+ parser.add_argument("--decode-nms-scale", type=float, default=0.25)
91
+ parser.add_argument("--max-ego-lanes", type=int, default=4)
92
+ parser.add_argument(
93
+ "--funnel-margin", type=float, default=0.10,
94
+ help="metric tolerance around the calibrated camera funnel",
95
+ )
96
+ return parser.parse_args()
97
+
98
+
99
+ def lane_to_record(lane, calibration, bev_range, max_cubic_rmse=0.5):
100
+ points, scores = model_lane_to_ground(
101
+ lane, MODEL_WIDTH, MODEL_HEIGHT, calibration, bev_range
102
+ )
103
+ fit = fit_cubic_lane(points, scores, iterations=3)
104
+ lane_id = int(lane.lane_id) if lane.lane_id is not None else None
105
+ valid_fit = fit is not None and fit["rmse"] <= max_cubic_rmse
106
+ record = {
107
+ "lane_id": f"P{lane_id}" if lane_id is not None else None,
108
+ "lane_index": lane_id,
109
+ "role": lane.lane_role,
110
+ "score": float(lane.score),
111
+ "projected_point_count": int(len(points)),
112
+ "valid_fit": valid_fit,
113
+ "fit_status": (
114
+ "ok" if valid_fit else
115
+ "rmse_above_limit" if fit is not None else
116
+ "insufficient_geometry"
117
+ ),
118
+ }
119
+ if fit is not None:
120
+ record.update({
121
+ "polynomial": "Y(X)=c0+c1*X+c2*X^2+c3*X^3",
122
+ "coefficients_c0_to_c3": [
123
+ float(value) for value in fit["coefficients"]
124
+ ],
125
+ "x_domain_m": [fit["x_min"], fit["x_max"]],
126
+ "rmse_m": fit["rmse"],
127
+ "fit_point_count": fit["point_count"],
128
+ "inlier_count": fit["inlier_count"],
129
+ "inlier_ratio": fit["inlier_ratio"],
130
+ })
131
+ return {
132
+ "record": record,
133
+ "points": points,
134
+ "fit": fit if valid_fit else None,
135
+ }
136
+
137
+
138
+ def clip_lane_results_to_funnel(lane_results, args):
139
+ """Clip raw model-derived cubics without changing their geometry.
140
+
141
+ Every output lane corresponds one-to-one with a decoded image-space lane.
142
+ No lane is synthesized, shifted, reordered, or forced to be parallel. The
143
+ cubic coefficients remain unchanged; only the declared X domain may shrink
144
+ when the fitted curve leaves the calibrated camera footprint.
145
+ """
146
+ calibration = CameraCalibration()
147
+ report = {
148
+ "mode": "raw_model_projection",
149
+ "parallel_assumption": False,
150
+ "synthetic_lanes": False,
151
+ "camera_x_m": float(calibration.camera_to_vehicle_matrix[0, 3]),
152
+ "horizontal_fov_deg": float(calibration.horizontal_fov_deg),
153
+ "margin_m": float(args.funnel_margin),
154
+ "clipped_lanes": [],
155
+ "rejected_lanes": [],
156
+ }
157
+ for result in lane_results:
158
+ fit = result["fit"]
159
+ record = result["record"]
160
+ if fit is None:
161
+ continue
162
+ original_coefficients = np.asarray(
163
+ fit["coefficients"], dtype=np.float64
164
+ ).copy()
165
+ clipped_fit, clip_report = clip_cubic_fit_to_funnel(
166
+ fit,
167
+ camera_x_m=report["camera_x_m"],
168
+ horizontal_fov_deg=report["horizontal_fov_deg"],
169
+ margin_m=args.funnel_margin,
170
+ )
171
+ lane_label = record["lane_id"]
172
+ if clipped_fit is None:
173
+ result["fit"] = None
174
+ record.update({
175
+ "valid_fit": False,
176
+ "fit_status": "outside_camera_funnel",
177
+ "funnel_guard": clip_report,
178
+ })
179
+ report["rejected_lanes"].append(lane_label)
180
+ continue
181
+ if not np.array_equal(
182
+ original_coefficients, clipped_fit["coefficients"]
183
+ ):
184
+ raise AssertionError("funnel clipping changed cubic coefficients")
185
+ result["fit"] = clipped_fit
186
+ record["x_domain_m"] = [
187
+ float(clipped_fit["x_min"]), float(clipped_fit["x_max"])
188
+ ]
189
+ record["funnel_guard"] = clip_report
190
+ if clip_report["clipped"]:
191
+ report["clipped_lanes"].append(lane_label)
192
+ return report
193
+
194
+
195
+ def _bev_pixel(x_forward, y_left, bev_range, bounds):
196
+ left, top, right, bottom = bounds
197
+ px = left + (bev_range.y_max - y_left) / (
198
+ bev_range.y_max - bev_range.y_min
199
+ ) * (right - left)
200
+ py = top + (bev_range.x_max - x_forward) / (
201
+ bev_range.x_max - bev_range.x_min
202
+ ) * (bottom - top)
203
+ return np.column_stack((px, py))
204
+
205
+
206
+ def _draw_dashed_curve(canvas, points, color, thickness=4,
207
+ dash_points=7, gap_points=4):
208
+ stride = dash_points + gap_points
209
+ for start in range(0, len(points) - 1, stride):
210
+ segment = points[start:min(start + dash_points + 1, len(points))]
211
+ if len(segment) >= 2:
212
+ cv2.polylines(
213
+ canvas, [segment], False, (0, 0, 0), thickness + 4,
214
+ cv2.LINE_AA,
215
+ )
216
+ cv2.polylines(
217
+ canvas, [segment], False, color, thickness, cv2.LINE_AA,
218
+ )
219
+
220
+
221
+ def draw_bev(lane_results, bev_range, calibration, funnel_report=None,
222
+ width=640, height=1080):
223
+ canvas = np.full((height, width, 3), (35, 19, 10), dtype=np.uint8)
224
+ bounds = (72, 62, width - 24, height - 70)
225
+ left, top, right, bottom = bounds
226
+
227
+ # Requested sensor-style ROI funnel: ego at the apex and the configured
228
+ # metric ROI at its far edge. It is a visualization of the output ROI, not
229
+ # a replacement for the calibrated camera ray/ground intersection.
230
+ camera_x = float(calibration.camera_to_vehicle_matrix[0, 3])
231
+ half_fov_radians = np.deg2rad(
232
+ calibration.horizontal_fov_deg * 0.5
233
+ )
234
+ fov_half_width = min(
235
+ bev_range.y_max,
236
+ max(0.0, bev_range.x_max - camera_x) * np.tan(half_fov_radians),
237
+ )
238
+ roi_metric = np.array(
239
+ ((camera_x, 0.0),
240
+ (bev_range.x_max, fov_half_width),
241
+ (bev_range.x_max, -fov_half_width)),
242
+ dtype=np.float64,
243
+ )
244
+ roi_pixels = np.round(_bev_pixel(
245
+ roi_metric[:, 0], roi_metric[:, 1], bev_range, bounds
246
+ )).astype(np.int32)
247
+ overlay = canvas.copy()
248
+ cv2.fillPoly(overlay, [roi_pixels], (105, 74, 105), cv2.LINE_AA)
249
+ cv2.addWeighted(overlay, 0.72, canvas, 0.28, 0.0, canvas)
250
+ cv2.polylines(canvas, [roi_pixels], True, (150, 120, 165), 2, cv2.LINE_AA)
251
+
252
+ for x in np.arange(
253
+ np.ceil(bev_range.x_min / 50.0) * 50.0,
254
+ bev_range.x_max + 0.1,
255
+ 50.0,
256
+ ):
257
+ half_width = min(
258
+ fov_half_width,
259
+ max(0.0, x - camera_x) * np.tan(half_fov_radians),
260
+ )
261
+ y_at_left_edge = half_width
262
+ y_at_right_edge = -half_width
263
+ range_line = _bev_pixel(
264
+ np.array([x, x]),
265
+ np.array([y_at_left_edge, y_at_right_edge]),
266
+ bev_range,
267
+ bounds,
268
+ )
269
+ range_line = np.round(range_line).astype(np.int32)
270
+ row = int(round(range_line[0, 1]))
271
+ cv2.line(
272
+ canvas, tuple(range_line[0]), tuple(range_line[1]),
273
+ (118, 91, 120), 1, cv2.LINE_AA,
274
+ )
275
+ cv2.putText(
276
+ canvas, f"{x:.0f}m", (8, min(bottom, max(top + 12, row + 5))),
277
+ cv2.FONT_HERSHEY_SIMPLEX, 0.42, (205, 190, 210), 1,
278
+ cv2.LINE_AA,
279
+ )
280
+
281
+ zero = _bev_pixel(
282
+ np.array([bev_range.x_min]), np.array([0.0]), bev_range, bounds
283
+ )[0].astype(int)
284
+ far_center = _bev_pixel(
285
+ np.array([bev_range.x_max]), np.array([0.0]), bev_range, bounds
286
+ )[0].astype(int)
287
+ for y0 in range(zero[1] - 12, far_center[1], -28):
288
+ y1 = max(far_center[1], y0 - 15)
289
+ cv2.line(canvas, (zero[0], y0), (far_center[0], y1),
290
+ (225, 215, 230), 2, cv2.LINE_AA)
291
+ ego = np.array(
292
+ ((zero[0] - 10, bottom - 22), (zero[0] + 10, bottom - 22),
293
+ (zero[0] + 14, bottom), (zero[0] - 14, bottom)),
294
+ dtype=np.int32,
295
+ )
296
+ cv2.fillPoly(canvas, [ego], (238, 238, 238), cv2.LINE_AA)
297
+ cv2.polylines(canvas, [ego], True, (20, 20, 20), 2, cv2.LINE_AA)
298
+
299
+ formula_rows = []
300
+ for result in lane_results:
301
+ record = result["record"]
302
+ lane_id = record["lane_index"]
303
+ color = LANE_COLORS.get(lane_id, (220, 220, 220))
304
+ points = result["points"]
305
+ if len(points):
306
+ pixels = _bev_pixel(
307
+ points[:, 0], points[:, 1], bev_range, bounds
308
+ )
309
+ pixels = np.round(pixels).astype(np.int32)
310
+ for pixel in pixels[::max(1, len(pixels) // 40)]:
311
+ cv2.circle(canvas, tuple(pixel), 2, color, -1, cv2.LINE_AA)
312
+
313
+ fit = result["fit"]
314
+ if fit is None:
315
+ rmse = record.get("rmse_m")
316
+ reason = (
317
+ f"rejected rmse={rmse:.2f}m" if rmse is not None
318
+ else "insufficient geometry"
319
+ )
320
+ formula_rows.append((color, f"{record['lane_id']}: {reason}"))
321
+ continue
322
+ x = np.linspace(fit["x_min"], fit["x_max"], 160)
323
+ y = evaluate_cubic(fit["coefficients"], x)
324
+ valid = (
325
+ np.isfinite(y)
326
+ & (y >= bev_range.y_min)
327
+ & (y <= bev_range.y_max)
328
+ )
329
+ curve = _bev_pixel(x[valid], y[valid], bev_range, bounds)
330
+ if len(curve) >= 2:
331
+ curve = np.round(curve).astype(np.int32)
332
+ _draw_dashed_curve(canvas, curve, color)
333
+ cv2.putText(
334
+ canvas, record["lane_id"], tuple(curve[-1]),
335
+ cv2.FONT_HERSHEY_SIMPLEX, 0.48, color, 2, cv2.LINE_AA,
336
+ )
337
+ formula_rows.append((
338
+ color,
339
+ f"{record['lane_id']}: X={fit['x_min']:.1f}..{fit['x_max']:.1f}m "
340
+ f"rmse={fit['rmse']:.2f}m",
341
+ ))
342
+
343
+ cv2.putText(
344
+ canvas,
345
+ f"CAMERA FOV {calibration.horizontal_fov_deg:.0f}deg: "
346
+ "X forward / Y left",
347
+ (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.54,
348
+ (255, 255, 255), 2, cv2.LINE_AA,
349
+ )
350
+ cv2.putText(
351
+ canvas, "RAW MODEL LANES | no synthetic/parallel prior",
352
+ (20, 52), cv2.FONT_HERSHEY_SIMPLEX, 0.42,
353
+ (80, 220, 255), 1, cv2.LINE_AA,
354
+ )
355
+ cv2.putText(
356
+ canvas, "Y left (+) Y right (-)",
357
+ (left, height - 16), cv2.FONT_HERSHEY_SIMPLEX, 0.38,
358
+ (210, 210, 210), 1, cv2.LINE_AA,
359
+ )
360
+ for row, (color, text) in enumerate(formula_rows[:4]):
361
+ cv2.putText(
362
+ canvas, text, (82, 82 + row * 18),
363
+ cv2.FONT_HERSHEY_SIMPLEX, 0.33, color, 1, cv2.LINE_AA,
364
+ )
365
+ return canvas
366
+
367
+
368
+ def _preprocess_and_infer(session, frame):
369
+ """Run the producer stage; safe to execute in a dedicated GPU thread."""
370
+ stage = time.perf_counter()
371
+ images = normalize_image_numpy(frame, MODEL_WIDTH, MODEL_HEIGHT)
372
+ preprocess_ms = (time.perf_counter() - stage) * 1000.0
373
+
374
+ stage = time.perf_counter()
375
+ outputs = session.run(list(OUTPUT_NAMES), {"images": images})
376
+ inference_ms = (time.perf_counter() - stage) * 1000.0
377
+ return outputs, preprocess_ms, inference_ms
378
+
379
+
380
+ def _decode_and_generate_bev_result(outputs, args, calibration, bev_range):
381
+ """Generate metric BEV data without drawing, compositing, or file I/O."""
382
+ stage = time.perf_counter()
383
+ lanes = decode(
384
+ softmax_foreground(outputs[0])[0], outputs[1][0], outputs[2][0],
385
+ outputs[3][0], outputs[4][0],
386
+ seg_threshold=args.decode_seg_threshold,
387
+ seed_threshold=args.decode_seed_threshold,
388
+ seed_min_dist=args.decode_seed_min_dist,
389
+ score_thresh=args.decode_score_thresh,
390
+ iou_thresh=args.decode_nms_iou,
391
+ max_seeds=args.decode_max_seeds,
392
+ nms_max_lanes=args.decode_nms_max_lanes,
393
+ nms_scale=args.decode_nms_scale,
394
+ max_output_lanes=args.max_ego_lanes,
395
+ crawl_backend=args.decode_crawl_backend,
396
+ )
397
+ decode_ms = (time.perf_counter() - stage) * 1000.0
398
+
399
+ stage = time.perf_counter()
400
+ lane_results = [
401
+ lane_to_record(lane, calibration, bev_range, args.max_cubic_rmse)
402
+ for lane in lanes
403
+ ]
404
+ funnel_report = clip_lane_results_to_funnel(lane_results, args)
405
+ valid_records = [
406
+ result["record"] for result in lane_results
407
+ if result["record"]["valid_fit"]
408
+ ]
409
+ bev_result_ms = (time.perf_counter() - stage) * 1000.0
410
+ return {
411
+ "lanes": lanes,
412
+ "lane_results": lane_results,
413
+ "funnel_report": funnel_report,
414
+ "valid_records": valid_records,
415
+ "decode_ms": decode_ms,
416
+ "bev_result_ms": bev_result_ms,
417
+ }
418
+
419
+
420
+ def generate_bev_results(capture, target_frames, start_frame, session, args,
421
+ calibration, bev_range):
422
+ """Generate ordered BEV results sequentially, one complete frame at a time.
423
+
424
+ No BEV/camera drawing, compositing, video writing, or JSON serialization is
425
+ performed inside this timed region.
426
+ """
427
+ timings = {
428
+ "preprocess": [],
429
+ "inference": [],
430
+ "decode": [],
431
+ "bev_result": [],
432
+ "pipeline": [],
433
+ }
434
+ results = []
435
+ generation_started = time.perf_counter()
436
+
437
+ def finish(frame_index, inference_payload):
438
+ outputs, preprocess_ms, inference_ms = inference_payload
439
+ bundle = _decode_and_generate_bev_result(
440
+ outputs, args, calibration, bev_range
441
+ )
442
+ compute_ms = (
443
+ preprocess_ms + inference_ms + bundle["decode_ms"]
444
+ + bundle["bev_result_ms"]
445
+ )
446
+ bundle.update({
447
+ "frame_index": frame_index,
448
+ "preprocess_ms": preprocess_ms,
449
+ "inference_ms": inference_ms,
450
+ "compute_ms": compute_ms,
451
+ })
452
+ results.append(bundle)
453
+ timings["preprocess"].append(preprocess_ms)
454
+ timings["inference"].append(inference_ms)
455
+ timings["decode"].append(bundle["decode_ms"])
456
+ timings["bev_result"].append(bundle["bev_result_ms"])
457
+ timings["pipeline"].append(compute_ms)
458
+ processed = len(results)
459
+ if processed % 25 == 0 or processed == target_frames:
460
+ elapsed = time.perf_counter() - generation_started
461
+ print(
462
+ f"result {processed}/{target_frames} "
463
+ f"(source={frame_index}) | "
464
+ f"lanes={len(bundle['lanes'])} "
465
+ f"cubic={len(bundle['valid_records'])} "
466
+ f"decode={bundle['decode_ms']:.1f}ms "
467
+ f"bev-result={bundle['bev_result_ms']:.1f}ms "
468
+ f"throughput={processed / max(elapsed, 1e-9):.1f} FPS",
469
+ flush=True,
470
+ )
471
+
472
+ for offset in range(target_frames):
473
+ ok, frame = capture.read()
474
+ if not ok:
475
+ break
476
+ finish(
477
+ start_frame + offset,
478
+ _preprocess_and_infer(session, frame),
479
+ )
480
+
481
+ generation_seconds = time.perf_counter() - generation_started
482
+ if not results:
483
+ raise RuntimeError("input video produced no BEV results")
484
+ return results, timings, generation_seconds
485
+
486
+
487
+ def make_composite(frame, bev_canvas, frame_index, fit_count, pipeline_ms,
488
+ funnel_report=None, result_generation_fps=None):
489
+ output = np.zeros((1080, 1920, 3), dtype=np.uint8)
490
+ bev_width = 640
491
+ output[:, :bev_width] = cv2.resize(
492
+ bev_canvas, (bev_width, 1080), interpolation=cv2.INTER_AREA
493
+ )
494
+ camera_width = 1920 - bev_width
495
+ scaled_height = int(round(frame.shape[0] * camera_width / frame.shape[1]))
496
+ camera_view = cv2.resize(frame, (camera_width, scaled_height),
497
+ interpolation=cv2.INTER_AREA)
498
+ top = (1080 - scaled_height) // 2
499
+ output[top:top + scaled_height, bev_width:] = camera_view
500
+ cv2.line(output, (bev_width, 0), (bev_width, 1079), (255, 255, 255), 2)
501
+ if result_generation_fps is None:
502
+ result_generation_fps = 1000.0 / max(float(pipeline_ms), 1e-9)
503
+ runtime_label = (
504
+ f"frame={frame_index} | cubic lanes={fit_count} | "
505
+ f"BEV-result={result_generation_fps:.1f} FPS "
506
+ "(sequential, no render)"
507
+ )
508
+ cv2.putText(
509
+ output, runtime_label,
510
+ (bev_width + 24, 42), cv2.FONT_HERSHEY_SIMPLEX, 0.78,
511
+ (0, 0, 0), 5, cv2.LINE_AA,
512
+ )
513
+ cv2.putText(
514
+ output, runtime_label,
515
+ (bev_width + 24, 42), cv2.FONT_HERSHEY_SIMPLEX, 0.78,
516
+ (255, 255, 255), 2, cv2.LINE_AA,
517
+ )
518
+ cv2.putText(
519
+ output, "CAMERA: raw decode (not back-projected)",
520
+ (bev_width + 24, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.55,
521
+ (0, 0, 0), 4, cv2.LINE_AA,
522
+ )
523
+ cv2.putText(
524
+ output, "CAMERA: raw decode (not back-projected)",
525
+ (bev_width + 24, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.55,
526
+ (255, 255, 255), 1, cv2.LINE_AA,
527
+ )
528
+ return output
529
+
530
+
531
+ def main():
532
+ args = parse_args()
533
+ if args.max_frames is not None and args.max_frames <= 0:
534
+ raise ValueError("--max-frames must be positive")
535
+ if args.start_frame < 0:
536
+ raise ValueError("--start-frame must be non-negative")
537
+ if args.max_cubic_rmse <= 0:
538
+ raise ValueError("--max-cubic-rmse must be positive")
539
+ if not args.x_min < args.x_max or not args.y_min < args.y_max:
540
+ raise ValueError("invalid BEV range")
541
+ if args.funnel_margin < 0:
542
+ raise ValueError("--funnel-margin must be non-negative")
543
+
544
+ model_path = Path(args.model).expanduser().resolve()
545
+ video_path = Path(args.video).expanduser().resolve()
546
+ output_path = Path(args.output).expanduser().resolve()
547
+ polynomial_path = (
548
+ Path(args.polynomials).expanduser().resolve()
549
+ if args.polynomials else output_path.with_suffix(".lanes.jsonl")
550
+ )
551
+ summary_path = (
552
+ Path(args.summary).expanduser().resolve()
553
+ if args.summary else output_path.with_suffix(".json")
554
+ )
555
+ for path in (model_path, video_path):
556
+ if not path.is_file():
557
+ raise FileNotFoundError(path)
558
+ for path in (output_path, polynomial_path, summary_path):
559
+ path.parent.mkdir(parents=True, exist_ok=True)
560
+
561
+ calibration = CameraCalibration()
562
+ bev_range = BevRange(args.x_min, args.x_max, args.y_min, args.y_max)
563
+ cv2.setNumThreads(1)
564
+ configure_decode_threads(args.decode_cpu_threads)
565
+ session = create_session(
566
+ model_path, args.provider, args.allow_tf32, args.trt_cache_dir
567
+ )
568
+ warmup_decode_backend(args.decode_crawl_backend)
569
+ warmup = np.zeros((1, 3, MODEL_HEIGHT, MODEL_WIDTH), dtype=np.float32)
570
+ for _ in range(5):
571
+ session.run(list(OUTPUT_NAMES), {"images": warmup})
572
+
573
+ capture = cv2.VideoCapture(str(video_path))
574
+ if not capture.isOpened():
575
+ raise RuntimeError(f"cannot open video: {video_path}")
576
+ width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
577
+ height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
578
+ fps = float(capture.get(cv2.CAP_PROP_FPS) or 20.0)
579
+ source_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
580
+ if (width, height) != (calibration.width, calibration.height):
581
+ capture.release()
582
+ raise ValueError(
583
+ f"calibration is {calibration.width}x{calibration.height}, "
584
+ f"video is {width}x{height}; crop/resize must be calibrated"
585
+ )
586
+ if args.start_frame >= source_frames:
587
+ capture.release()
588
+ raise ValueError(
589
+ f"--start-frame {args.start_frame} is outside {source_frames} frames"
590
+ )
591
+ if args.start_frame:
592
+ capture.set(cv2.CAP_PROP_POS_FRAMES, args.start_frame)
593
+ available_frames = source_frames - args.start_frame
594
+ target_frames = min(available_frames, args.max_frames or available_frames)
595
+
596
+ overall_started = time.perf_counter()
597
+ try:
598
+ results, timings, generation_seconds = generate_bev_results(
599
+ capture, target_frames, args.start_frame, session, args,
600
+ calibration, bev_range,
601
+ )
602
+ finally:
603
+ capture.release()
604
+ processed_frames = len(results)
605
+ result_generation_fps = processed_frames / generation_seconds
606
+
607
+ fit_counts = Counter()
608
+ funnel_clipped_lanes = Counter()
609
+ funnel_rejected_lanes = Counter()
610
+ lane_counts = []
611
+ for bundle in results:
612
+ valid_records = bundle["valid_records"]
613
+ funnel_report = bundle["funnel_report"]
614
+ for record in valid_records:
615
+ fit_counts[record["lane_id"]] += 1
616
+ funnel_clipped_lanes.update(
617
+ funnel_report["clipped_lanes"]
618
+ )
619
+ funnel_rejected_lanes.update(
620
+ funnel_report["rejected_lanes"]
621
+ )
622
+ lane_counts.append(len(bundle["lanes"]))
623
+
624
+ temporary_polynomials = polynomial_path.with_suffix(
625
+ polynomial_path.suffix + ".tmp"
626
+ )
627
+ with temporary_polynomials.open("w") as polynomial_file:
628
+ for bundle in results:
629
+ polynomial_file.write(json.dumps({
630
+ "frame_index": bundle["frame_index"],
631
+ "timestamp_seconds": bundle["frame_index"] / fps,
632
+ "coordinate_system": {"X": "forward_m", "Y": "left_m"},
633
+ "funnel_guard": bundle["funnel_report"],
634
+ "lanes": [
635
+ result["record"] for result in bundle["lane_results"]
636
+ ],
637
+ }) + "\n")
638
+ os.replace(temporary_polynomials, polynomial_path)
639
+
640
+ # Rendering is a separate, untimed pass. Reopen the source video so none
641
+ # of these operations can affect the reported BEV-result throughput.
642
+ temporary_output = output_path.with_name(
643
+ output_path.stem + ".tmp" + output_path.suffix
644
+ )
645
+ render_capture = cv2.VideoCapture(str(video_path))
646
+ if not render_capture.isOpened():
647
+ raise RuntimeError(f"cannot reopen video for rendering: {video_path}")
648
+ if args.start_frame:
649
+ render_capture.set(cv2.CAP_PROP_POS_FRAMES, args.start_frame)
650
+ writer = cv2.VideoWriter(
651
+ str(temporary_output), cv2.VideoWriter_fourcc(*"mp4v"), fps,
652
+ (1920, 1080),
653
+ )
654
+ if not writer.isOpened():
655
+ render_capture.release()
656
+ raise RuntimeError(f"cannot open writer: {temporary_output}")
657
+ render_started = time.perf_counter()
658
+ try:
659
+ for position, bundle in enumerate(results, 1):
660
+ ok, frame = render_capture.read()
661
+ if not ok:
662
+ raise RuntimeError(
663
+ f"render pass stopped before frame {bundle['frame_index']}"
664
+ )
665
+ bev_canvas = draw_bev(
666
+ bundle["lane_results"], bev_range, calibration,
667
+ bundle["funnel_report"],
668
+ )
669
+ draw_predictions(frame, bundle["lanes"])
670
+ composite = make_composite(
671
+ frame, bev_canvas, bundle["frame_index"],
672
+ len(bundle["valid_records"]), bundle["compute_ms"],
673
+ bundle["funnel_report"],
674
+ result_generation_fps=result_generation_fps,
675
+ )
676
+ writer.write(composite)
677
+ if position % 100 == 0 or position == processed_frames:
678
+ print(
679
+ f"render {position}/{processed_frames} "
680
+ "(excluded from BEV-result FPS)",
681
+ flush=True,
682
+ )
683
+ finally:
684
+ render_capture.release()
685
+ writer.release()
686
+ render_seconds = time.perf_counter() - render_started
687
+ os.replace(temporary_output, output_path)
688
+
689
+ summary = {
690
+ "model": str(model_path),
691
+ "video": str(video_path),
692
+ "output_video": str(output_path),
693
+ "output_polynomials": str(polynomial_path),
694
+ "processed_frames": processed_frames,
695
+ "start_frame": args.start_frame,
696
+ "source_frames": source_frames,
697
+ "fps": fps,
698
+ "provider": args.provider,
699
+ "execution": {
700
+ "mode": "sequential_per_frame",
701
+ "result_generation_scope": (
702
+ "video read + normalize + inference + decode + metric "
703
+ "projection + cubic fit + camera-funnel clipping"
704
+ ),
705
+ "excluded_from_result_fps": [
706
+ "draw_bev",
707
+ "draw_predictions",
708
+ "make_composite",
709
+ "video_encode_write",
710
+ "json_serialize_write",
711
+ ],
712
+ "result_generation_wall_seconds": generation_seconds,
713
+ "result_generation_fps": result_generation_fps,
714
+ "render_wall_seconds": render_seconds,
715
+ },
716
+ "coordinate_system": {
717
+ "X": "forward from ego, metres",
718
+ "Y": "left of ego, metres",
719
+ "Z": "not exported; local road plane is Z=0",
720
+ },
721
+ "camera": {
722
+ "resolution": [calibration.width, calibration.height],
723
+ "horizontal_fov_deg": calibration.horizontal_fov_deg,
724
+ "intrinsic": calibration.intrinsic.tolist(),
725
+ "camera_to_vehicle": calibration.camera_to_vehicle_matrix.tolist(),
726
+ "distortion": None,
727
+ },
728
+ "bev_range_m": {
729
+ "X": [bev_range.x_min, bev_range.x_max],
730
+ "Y": [bev_range.y_min, bev_range.y_max],
731
+ },
732
+ "polynomial": {
733
+ "formula": "Y(X)=c0+c1*X+c2*X^2+c3*X^3",
734
+ "coefficient_order": ["c0", "c1", "c2", "c3"],
735
+ "max_accepted_rmse_m": args.max_cubic_rmse,
736
+ "fit_counts_by_lane": dict(sorted(fit_counts.items())),
737
+ },
738
+ "bev_projection": {
739
+ "mode": "raw_model_projection",
740
+ "parallel_assumption": False,
741
+ "synthetic_lanes": False,
742
+ "funnel_margin_m": args.funnel_margin,
743
+ "funnel_clipped_lane_fits": int(
744
+ sum(funnel_clipped_lanes.values())
745
+ ),
746
+ "funnel_clipped_by_lane": dict(
747
+ sorted(funnel_clipped_lanes.items())
748
+ ),
749
+ "funnel_rejected_lane_fits": int(
750
+ sum(funnel_rejected_lanes.values())
751
+ ),
752
+ "funnel_rejected_by_lane": dict(
753
+ sorted(funnel_rejected_lanes.items())
754
+ ),
755
+ },
756
+ "lane_count": {
757
+ "mean": float(np.mean(lane_counts)),
758
+ "min": int(min(lane_counts)),
759
+ "max": int(max(lane_counts)),
760
+ },
761
+ "timings": {
762
+ name: timing_summary(values) for name, values in timings.items()
763
+ },
764
+ "wall_time_seconds": time.perf_counter() - overall_started,
765
+ }
766
+ temporary_summary = summary_path.with_suffix(summary_path.suffix + ".tmp")
767
+ with temporary_summary.open("w") as handle:
768
+ json.dump(summary, handle, indent=2)
769
+ handle.write("\n")
770
+ os.replace(temporary_summary, summary_path)
771
+ print(f"video OK: {output_path}")
772
+ print(f"polynomials OK: {polynomial_path}")
773
+ print(f"summary: {summary_path}")
774
+ print(
775
+ f"BEV-result throughput: {result_generation_fps:.2f} FPS "
776
+ f"({generation_seconds:.3f}s, render excluded)"
777
+ )
778
+
779
+
780
+ if __name__ == "__main__":
781
+ main()
test_video_onnx.py CHANGED
@@ -1,8 +1,8 @@
1
  """Run an RCLane ONNX model on a video and render predicted lanes + timing.
2
 
3
- The input video may already contain ground-truth annotations. Predictions are
4
- drawn as solid cyan polylines with a black outline so they remain distinguishable
5
- from colored GT points.
6
  """
7
 
8
  import argparse
@@ -16,8 +16,13 @@ import cv2
16
  import numpy as np
17
  import onnxruntime as ort
18
 
19
- from dataset import normalize_image
20
- from decode import decode
 
 
 
 
 
21
 
22
 
23
  MODEL_HEIGHT = 320
@@ -32,77 +37,183 @@ OUTPUT_NAMES = (
32
 
33
 
34
  def softmax_foreground(logits):
35
- shifted = logits - np.max(logits, axis=1, keepdims=True)
36
- exp_logits = np.exp(shifted)
37
- return exp_logits[:, 1] / np.sum(exp_logits, axis=1)
 
 
38
 
39
 
40
- def create_session(model_path, provider, allow_tf32):
41
  available = ort.get_available_providers()
42
- if provider == "cuda":
43
- if "CUDAExecutionProvider" not in available:
 
 
 
 
 
 
 
44
  raise RuntimeError(
45
- "CUDAExecutionProvider is unavailable; install the CUDA 13 "
46
- "onnxruntime-gpu build from requirements.txt"
47
  )
 
 
 
 
 
 
 
 
 
 
 
48
  providers = [
49
  (
50
- "CUDAExecutionProvider",
51
  {
52
  "device_id": "0",
53
- "use_tf32": "1" if allow_tf32 else "0",
54
- "cudnn_conv_algo_search": "HEURISTIC",
55
- "cudnn_conv_use_max_workspace": "1",
56
- "do_copy_in_default_stream": "1",
 
 
 
 
 
 
 
 
57
  },
58
  ),
 
 
 
 
 
 
 
 
 
 
 
59
  "CPUExecutionProvider",
60
  ]
61
  else:
62
  providers = ["CPUExecutionProvider"]
63
 
64
  session = ort.InferenceSession(str(model_path), providers=providers)
65
- if provider == "cuda" and session.get_providers()[0] != "CUDAExecutionProvider":
 
 
 
 
66
  raise RuntimeError(
67
- f"CUDA provider was requested but session uses {session.get_providers()}"
 
68
  )
69
  return session
70
 
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  def draw_predictions(frame, lanes):
73
  height, width = frame.shape[:2]
74
- sx = width / MODEL_WIDTH
75
- sy = height / MODEL_HEIGHT
 
76
  for index, lane in enumerate(lanes):
77
- points = lane.xy().copy()
78
  if len(points) < 2:
79
  continue
80
- points[:, 0] *= sx
81
- points[:, 1] *= sy
82
- points[:, 0] = np.clip(points[:, 0], 0, width - 1)
83
- points[:, 1] = np.clip(points[:, 1], 0, height - 1)
84
  points = np.round(points).astype(np.int32)
85
- cv2.polylines(frame, [points], False, (0, 0, 0), 10, cv2.LINE_AA)
86
- cv2.polylines(frame, [points], False, (255, 255, 0), 5, cv2.LINE_AA)
 
 
 
 
 
 
 
 
 
87
 
88
  near = points[int(np.argmax(points[:, 1]))]
89
  lane_id = lane.lane_id if lane.lane_id is not None else index
90
- label = f"P{lane_id} {lane.score:.2f}"
91
- position = (int(near[0]) + 8, max(32, int(near[1]) - 8))
 
 
 
 
 
92
  cv2.putText(
93
  frame, label, position, cv2.FONT_HERSHEY_SIMPLEX,
94
  0.65, (0, 0, 0), 5, cv2.LINE_AA,
95
  )
96
  cv2.putText(
97
  frame, label, position, cv2.FONT_HERSHEY_SIMPLEX,
98
- 0.65, (255, 255, 0), 2, cv2.LINE_AA,
99
  )
100
 
101
 
102
  def draw_runtime(frame, provider, lane_count, infer_ms, decode_ms, pipeline_ms,
103
- rolling_pipeline_ms, source_fps, input_has_gt):
 
104
  height, width = frame.shape[:2]
105
- box_width, box_height = min(690, width - 20), 160
106
  left, top = width - box_width - 10, 10
107
  overlay = frame.copy()
108
  cv2.rectangle(
@@ -113,8 +224,11 @@ def draw_runtime(frame, provider, lane_count, infer_ms, decode_ms, pipeline_ms,
113
  model_fps = 1000.0 / max(infer_ms, 1e-9)
114
  pipeline_fps = 1000.0 / max(rolling_pipeline_ms, 1e-9)
115
  realtime = "YES" if pipeline_fps >= source_fps else "NO"
 
 
116
  lines = (
117
  f"RCLane e19 ONNX {provider.upper()} | lanes={lane_count}",
 
118
  f"infer {infer_ms:6.1f} ms ({model_fps:5.1f} FPS)",
119
  f"decode {decode_ms:6.1f} ms | pipeline {pipeline_ms:6.1f} ms",
120
  f"rolling pipeline {pipeline_fps:5.1f} FPS | realtime@{source_fps:g}: {realtime}",
@@ -126,9 +240,10 @@ def draw_runtime(frame, provider, lane_count, infer_ms, decode_ms, pipeline_ms,
126
  2, cv2.LINE_AA,
127
  )
128
 
 
129
  legend = (
130
- "GT: colored dots | Prediction: cyan solid lines"
131
- if input_has_gt else "Prediction: cyan solid lines"
132
  )
133
  cv2.putText(
134
  frame, legend, (16, height - 20), cv2.FONT_HERSHEY_SIMPLEX,
@@ -158,7 +273,11 @@ def parse_args():
158
  parser.add_argument("--output", default="runs/video_test.mp4")
159
  parser.add_argument("--summary", default=None,
160
  help="timing JSON; defaults next to output video")
161
- parser.add_argument("--provider", choices=["cuda", "cpu"], default="cuda")
 
 
 
 
162
  parser.add_argument("--allow-tf32", action="store_true",
163
  help="faster CUDA math with slightly larger numerical drift")
164
  parser.add_argument("--input-has-gt", action="store_true",
@@ -177,6 +296,11 @@ def parse_args():
177
  "--max-ego-lanes", type=int, default=4,
178
  help="post-process to at most N reliable lanes nearest the ego vehicle",
179
  )
 
 
 
 
 
180
  return parser.parse_args()
181
 
182
 
@@ -204,7 +328,12 @@ def main():
204
  output_path.stem + ".tmp" + output_path.suffix
205
  )
206
 
207
- session = create_session(model_path, args.provider, args.allow_tf32)
 
 
 
 
 
208
  warmup = np.zeros((1, 3, MODEL_HEIGHT, MODEL_WIDTH), dtype=np.float32)
209
  for _ in range(5):
210
  session.run(list(OUTPUT_NAMES), {"images": warmup})
@@ -228,6 +357,9 @@ def main():
228
 
229
  timings = {"preprocess": [], "inference": [], "decode": [], "pipeline": []}
230
  lane_counts = []
 
 
 
231
  rolling = deque(maxlen=30)
232
  started = time.perf_counter()
233
  frame_index = 0
@@ -239,8 +371,7 @@ def main():
239
  pipeline_start = time.perf_counter()
240
 
241
  stage = time.perf_counter()
242
- images = normalize_image(frame, MODEL_WIDTH, MODEL_HEIGHT)
243
- images = images.unsqueeze(0).numpy()
244
  preprocess_ms = (time.perf_counter() - stage) * 1000
245
 
246
  stage = time.perf_counter()
@@ -266,6 +397,7 @@ def main():
266
  nms_max_lanes=args.decode_nms_max_lanes,
267
  nms_scale=args.decode_nms_scale,
268
  max_output_lanes=args.max_ego_lanes,
 
269
  )
270
  decode_ms = (time.perf_counter() - stage) * 1000
271
  pipeline_ms = (time.perf_counter() - pipeline_start) * 1000
@@ -276,12 +408,16 @@ def main():
276
  timings["pipeline"].append(pipeline_ms)
277
  lane_counts.append(len(lanes))
278
  rolling.append(pipeline_ms)
 
 
 
 
279
 
280
  draw_predictions(frame, lanes)
281
  draw_runtime(
282
  frame, args.provider, len(lanes), inference_ms, decode_ms,
283
  pipeline_ms, float(np.median(rolling)), source_fps,
284
- args.input_has_gt,
285
  )
286
  writer.write(frame)
287
  frame_index += 1
@@ -313,6 +449,12 @@ def main():
313
  "max_ego_lanes": args.max_ego_lanes,
314
  "min_score_ratio": 0.5,
315
  "balance_sides": True,
 
 
 
 
 
 
316
  },
317
  "resolution": [width, height],
318
  "source_fps": source_fps,
@@ -326,6 +468,12 @@ def main():
326
  "min": int(min(lane_counts)),
327
  "max": int(max(lane_counts)),
328
  },
 
 
 
 
 
 
329
  }
330
  temporary_summary = summary_path.with_suffix(summary_path.suffix + ".tmp")
331
  with temporary_summary.open("w") as handle:
 
1
  """Run an RCLane ONNX model on a video and render predicted lanes + timing.
2
 
3
+ The input video may already contain ground-truth annotations. Ego-lane boundaries
4
+ are highlighted in green/orange, other predictions in cyan, all with a black
5
+ outline so they remain distinguishable from colored GT points.
6
  """
7
 
8
  import argparse
 
16
  import numpy as np
17
  import onnxruntime as ort
18
 
19
+ from dataset import normalize_image_numpy
20
+ from decode import (
21
+ configure_decode_threads,
22
+ decode,
23
+ ego_lane_boundaries,
24
+ warmup_decode_backend,
25
+ )
26
 
27
 
28
  MODEL_HEIGHT = 320
 
37
 
38
 
39
  def softmax_foreground(logits):
40
+ # Binary softmax: exp(l1)/(exp(l0)+exp(l1)) = sigmoid(l1-l0).
41
+ # This halves exponent work and avoids allocating a two-channel temporary.
42
+ difference = logits[:, 0] - logits[:, 1]
43
+ with np.errstate(over="ignore"):
44
+ return 1.0 / (1.0 + np.exp(difference))
45
 
46
 
47
+ def create_session(model_path, provider, allow_tf32, trt_cache_dir=None):
48
  available = ort.get_available_providers()
49
+ cuda_options = {
50
+ "device_id": "0",
51
+ "use_tf32": "1" if allow_tf32 else "0",
52
+ "cudnn_conv_algo_search": "HEURISTIC",
53
+ "cudnn_conv_use_max_workspace": "1",
54
+ "do_copy_in_default_stream": "1",
55
+ }
56
+ if provider == "tensorrt":
57
+ if "TensorrtExecutionProvider" not in available:
58
  raise RuntimeError(
59
+ "TensorrtExecutionProvider is unavailable in ONNX Runtime"
 
60
  )
61
+ try:
62
+ import tensorrt # noqa: F401 - preloads libnvinfer for ORT
63
+ except ImportError as exc:
64
+ raise RuntimeError(
65
+ "TensorRT provider requested but tensorrt-cu13 is not installed"
66
+ ) from exc
67
+ cache_dir = Path(
68
+ trt_cache_dir or Path(model_path).resolve().parent / "trt_cache"
69
+ )
70
+ cache_dir.mkdir(parents=True, exist_ok=True)
71
+ shape = f"images:1x3x{MODEL_HEIGHT}x{MODEL_WIDTH}"
72
  providers = [
73
  (
74
+ "TensorrtExecutionProvider",
75
  {
76
  "device_id": "0",
77
+ "trt_fp16_enable": "True",
78
+ "trt_engine_cache_enable": "True",
79
+ "trt_engine_cache_path": str(cache_dir),
80
+ "trt_timing_cache_enable": "True",
81
+ "trt_timing_cache_path": str(cache_dir),
82
+ "trt_force_timing_cache": "True",
83
+ "trt_builder_optimization_level": "3",
84
+ "trt_max_workspace_size": str(2 * 1024 ** 3),
85
+ "trt_min_subgraph_size": "1",
86
+ "trt_profile_min_shapes": shape,
87
+ "trt_profile_opt_shapes": shape,
88
+ "trt_profile_max_shapes": shape,
89
  },
90
  ),
91
+ ("CUDAExecutionProvider", cuda_options),
92
+ "CPUExecutionProvider",
93
+ ]
94
+ elif provider == "cuda":
95
+ if "CUDAExecutionProvider" not in available:
96
+ raise RuntimeError(
97
+ "CUDAExecutionProvider is unavailable; install the CUDA 13 "
98
+ "onnxruntime-gpu build from requirements.txt"
99
+ )
100
+ providers = [
101
+ ("CUDAExecutionProvider", cuda_options),
102
  "CPUExecutionProvider",
103
  ]
104
  else:
105
  providers = ["CPUExecutionProvider"]
106
 
107
  session = ort.InferenceSession(str(model_path), providers=providers)
108
+ expected_provider = {
109
+ "cuda": "CUDAExecutionProvider",
110
+ "tensorrt": "TensorrtExecutionProvider",
111
+ }.get(provider)
112
+ if expected_provider and session.get_providers()[0] != expected_provider:
113
  raise RuntimeError(
114
+ f"{expected_provider} was requested but session uses "
115
+ f"{session.get_providers()}"
116
  )
117
  return session
118
 
119
 
120
+ def _lane_points_in_frame(lane, width, height):
121
+ points = lane.xy().copy()
122
+ if len(points) < 2:
123
+ return np.empty((0, 2), dtype=np.float32)
124
+ points[:, 0] *= width / MODEL_WIDTH
125
+ points[:, 1] *= height / MODEL_HEIGHT
126
+ points[:, 0] = np.clip(points[:, 0], 0, width - 1)
127
+ points[:, 1] = np.clip(points[:, 1], 0, height - 1)
128
+ return points
129
+
130
+
131
+ def _x_at_rows(points, rows):
132
+ order = np.argsort(points[:, 1])
133
+ ys = points[order, 1]
134
+ xs = points[order, 0]
135
+ unique_y, inverse = np.unique(ys, return_inverse=True)
136
+ x_sum = np.zeros(len(unique_y), dtype=np.float64)
137
+ count = np.zeros(len(unique_y), dtype=np.float64)
138
+ np.add.at(x_sum, inverse, xs)
139
+ np.add.at(count, inverse, 1)
140
+ return np.interp(rows, unique_y, x_sum / count)
141
+
142
+
143
+ def draw_ego_corridor(frame, ego_left, ego_right):
144
+ """Shade the visible region bounded by the two current-lane boundaries."""
145
+ if ego_left is None or ego_right is None:
146
+ return
147
+ height, width = frame.shape[:2]
148
+ left = _lane_points_in_frame(ego_left, width, height)
149
+ right = _lane_points_in_frame(ego_right, width, height)
150
+ if len(left) < 2 or len(right) < 2:
151
+ return
152
+ y_start = max(float(left[:, 1].min()), float(right[:, 1].min()))
153
+ y_stop = min(float(left[:, 1].max()), float(right[:, 1].max()))
154
+ if y_stop - y_start < 8:
155
+ return
156
+ rows = np.linspace(y_start, y_stop, 64)
157
+ left_x = _x_at_rows(left, rows)
158
+ right_x = _x_at_rows(right, rows)
159
+ valid = right_x > left_x
160
+ if np.count_nonzero(valid) < 2:
161
+ return
162
+ rows = rows[valid]
163
+ left_edge = np.column_stack((left_x[valid], rows))
164
+ right_edge = np.column_stack((right_x[valid], rows))[::-1]
165
+ polygon = np.round(np.vstack((left_edge, right_edge))).astype(np.int32)
166
+ overlay = frame.copy()
167
+ cv2.fillPoly(overlay, [polygon], (40, 140, 40), cv2.LINE_AA)
168
+ cv2.addWeighted(overlay, 0.18, frame, 0.82, 0, frame)
169
+
170
+
171
  def draw_predictions(frame, lanes):
172
  height, width = frame.shape[:2]
173
+ ego_left, ego_right = ego_lane_boundaries(lanes)
174
+ draw_ego_corridor(frame, ego_left, ego_right)
175
+
176
  for index, lane in enumerate(lanes):
177
+ points = _lane_points_in_frame(lane, width, height)
178
  if len(points) < 2:
179
  continue
 
 
 
 
180
  points = np.round(points).astype(np.int32)
181
+ role = getattr(lane, "lane_role", None)
182
+ if role == "ego_left":
183
+ color, role_text, thickness = (0, 255, 0), "EGO-L", 7
184
+ elif role == "ego_right":
185
+ color, role_text, thickness = (0, 165, 255), "EGO-R", 7
186
+ else:
187
+ color, role_text, thickness = (255, 255, 0), "", 5
188
+ cv2.polylines(
189
+ frame, [points], False, (0, 0, 0), thickness + 5, cv2.LINE_AA
190
+ )
191
+ cv2.polylines(frame, [points], False, color, thickness, cv2.LINE_AA)
192
 
193
  near = points[int(np.argmax(points[:, 1]))]
194
  lane_id = lane.lane_id if lane.lane_id is not None else index
195
+ role_label = f" {role_text}" if role_text else ""
196
+ label = f"P{lane_id}{role_label} {lane.score:.2f}"
197
+ text_size = cv2.getTextSize(
198
+ label, cv2.FONT_HERSHEY_SIMPLEX, 0.65, 2
199
+ )[0]
200
+ label_x = min(max(8, int(near[0]) + 8), width - text_size[0] - 8)
201
+ position = (label_x, max(32, int(near[1]) - 8))
202
  cv2.putText(
203
  frame, label, position, cv2.FONT_HERSHEY_SIMPLEX,
204
  0.65, (0, 0, 0), 5, cv2.LINE_AA,
205
  )
206
  cv2.putText(
207
  frame, label, position, cv2.FONT_HERSHEY_SIMPLEX,
208
+ 0.65, color, 2, cv2.LINE_AA,
209
  )
210
 
211
 
212
  def draw_runtime(frame, provider, lane_count, infer_ms, decode_ms, pipeline_ms,
213
+ rolling_pipeline_ms, source_fps, input_has_gt, ego_left,
214
+ ego_right):
215
  height, width = frame.shape[:2]
216
+ box_width, box_height = min(690, width - 20), 196
217
  left, top = width - box_width - 10, 10
218
  overlay = frame.copy()
219
  cv2.rectangle(
 
224
  model_fps = 1000.0 / max(infer_ms, 1e-9)
225
  pipeline_fps = 1000.0 / max(rolling_pipeline_ms, 1e-9)
226
  realtime = "YES" if pipeline_fps >= source_fps else "NO"
227
+ left_id = f"P{ego_left.lane_id}" if ego_left is not None else "missing"
228
+ right_id = f"P{ego_right.lane_id}" if ego_right is not None else "missing"
229
  lines = (
230
  f"RCLane e19 ONNX {provider.upper()} | lanes={lane_count}",
231
+ f"ego lane boundaries: {left_id} (L) | {right_id} (R)",
232
  f"infer {infer_ms:6.1f} ms ({model_fps:5.1f} FPS)",
233
  f"decode {decode_ms:6.1f} ms | pipeline {pipeline_ms:6.1f} ms",
234
  f"rolling pipeline {pipeline_fps:5.1f} FPS | realtime@{source_fps:g}: {realtime}",
 
240
  2, cv2.LINE_AA,
241
  )
242
 
243
+ prediction_legend = "EGO-L: green | EGO-R: orange | other: cyan"
244
  legend = (
245
+ f"GT: colored dots | {prediction_legend}"
246
+ if input_has_gt else prediction_legend
247
  )
248
  cv2.putText(
249
  frame, legend, (16, height - 20), cv2.FONT_HERSHEY_SIMPLEX,
 
273
  parser.add_argument("--output", default="runs/video_test.mp4")
274
  parser.add_argument("--summary", default=None,
275
  help="timing JSON; defaults next to output video")
276
+ parser.add_argument(
277
+ "--provider", choices=["tensorrt", "cuda", "cpu"],
278
+ default="tensorrt",
279
+ )
280
+ parser.add_argument("--trt-cache-dir", default="exports/trt_cache")
281
  parser.add_argument("--allow-tf32", action="store_true",
282
  help="faster CUDA math with slightly larger numerical drift")
283
  parser.add_argument("--input-has-gt", action="store_true",
 
296
  "--max-ego-lanes", type=int, default=4,
297
  help="post-process to at most N reliable lanes nearest the ego vehicle",
298
  )
299
+ parser.add_argument(
300
+ "--decode-crawl-backend", choices=("auto", "numba", "numpy"),
301
+ default="auto",
302
+ )
303
+ parser.add_argument("--decode-cpu-threads", type=int, default=8)
304
  return parser.parse_args()
305
 
306
 
 
328
  output_path.stem + ".tmp" + output_path.suffix
329
  )
330
 
331
+ cv2.setNumThreads(1)
332
+ configure_decode_threads(args.decode_cpu_threads)
333
+ session = create_session(
334
+ model_path, args.provider, args.allow_tf32, args.trt_cache_dir
335
+ )
336
+ warmup_decode_backend(args.decode_crawl_backend)
337
  warmup = np.zeros((1, 3, MODEL_HEIGHT, MODEL_WIDTH), dtype=np.float32)
338
  for _ in range(5):
339
  session.run(list(OUTPUT_NAMES), {"images": warmup})
 
357
 
358
  timings = {"preprocess": [], "inference": [], "decode": [], "pipeline": []}
359
  lane_counts = []
360
+ ego_left_frames = 0
361
+ ego_right_frames = 0
362
+ ego_pair_frames = 0
363
  rolling = deque(maxlen=30)
364
  started = time.perf_counter()
365
  frame_index = 0
 
371
  pipeline_start = time.perf_counter()
372
 
373
  stage = time.perf_counter()
374
+ images = normalize_image_numpy(frame, MODEL_WIDTH, MODEL_HEIGHT)
 
375
  preprocess_ms = (time.perf_counter() - stage) * 1000
376
 
377
  stage = time.perf_counter()
 
397
  nms_max_lanes=args.decode_nms_max_lanes,
398
  nms_scale=args.decode_nms_scale,
399
  max_output_lanes=args.max_ego_lanes,
400
+ crawl_backend=args.decode_crawl_backend,
401
  )
402
  decode_ms = (time.perf_counter() - stage) * 1000
403
  pipeline_ms = (time.perf_counter() - pipeline_start) * 1000
 
408
  timings["pipeline"].append(pipeline_ms)
409
  lane_counts.append(len(lanes))
410
  rolling.append(pipeline_ms)
411
+ ego_left, ego_right = ego_lane_boundaries(lanes)
412
+ ego_left_frames += int(ego_left is not None)
413
+ ego_right_frames += int(ego_right is not None)
414
+ ego_pair_frames += int(ego_left is not None and ego_right is not None)
415
 
416
  draw_predictions(frame, lanes)
417
  draw_runtime(
418
  frame, args.provider, len(lanes), inference_ms, decode_ms,
419
  pipeline_ms, float(np.median(rolling)), source_fps,
420
+ args.input_has_gt, ego_left, ego_right,
421
  )
422
  writer.write(frame)
423
  frame_index += 1
 
449
  "max_ego_lanes": args.max_ego_lanes,
450
  "min_score_ratio": 0.5,
451
  "balance_sides": True,
452
+ "lane_id_semantics": {
453
+ "P0": "next boundary left of ego lane",
454
+ "P1": "ego lane left boundary",
455
+ "P2": "ego lane right boundary",
456
+ "P3": "next boundary right of ego lane",
457
+ },
458
  },
459
  "resolution": [width, height],
460
  "source_fps": source_fps,
 
468
  "min": int(min(lane_counts)),
469
  "max": int(max(lane_counts)),
470
  },
471
+ "ego_lane_boundary_coverage": {
472
+ "left_frames": ego_left_frames,
473
+ "right_frames": ego_right_frames,
474
+ "pair_frames": ego_pair_frames,
475
+ "pair_rate": ego_pair_frames / frame_index,
476
+ },
477
  }
478
  temporary_summary = summary_path.with_suffix(summary_path.suffix + ".tmp")
479
  with temporary_summary.open("w") as handle: