RCLane_2D_Detection / decode.py
huyluongngoc's picture
feat(RCLane): switch to C++ for runtime modeling
3624d0b
Raw
History Blame Contribute Delete
42.6 kB
"""
RCLane relay-chain decoder -- ported from src/lane_codec.py (decode / decode_branch /
thresh_line / iou_nms) and src/lane_geometry.py (PointSelf / FloatLengthLine._iou).
Turns the 5 predicted maps back into lane polylines. This is the inference-time
counterpart of `encode`. Pure NumPy + OpenCV, framework-agnostic.
Pipeline:
1. seeds = point-NMS on the seg foreground map (keep peaks >= min_dist apart).
2. for each seed: crawl a forward (down_arrow) and backward (up_arrow) relay chain,
each step = normalized transfer vector * step_length; stop via the distance
(bound) heuristic once the walk leaves the foreground.
3. merge reversed(up) + down into one lane.
4. drop low-score lanes (thresh_line), then IoU-NMS to remove duplicates.
Note: the stopping rule inside `decode_branch` (RMS of remaining-step estimates,
the 0.75 factor, "keep going while on foreground") is NOT described in the paper --
it is ported verbatim from the MindSpore code. A `norm == 0` guard is added so a
zero transfer vector breaks the walk instead of producing NaNs.
Map layout (matches encode.py / rclane.py, channel-first):
seg_prob: (H, W) foreground probability in [0, 1]
up/down_arrow/bound: (2, H, W) float32
"""
import numpy as np
import cv2
try:
from numba import njit, prange
except ImportError: # portable fallback for environments without the JIT
njit = None
prange = range
if njit is not None:
@njit(cache=True)
def _greedy_seed_select_numba(sorted_x, sorted_y, height, width,
radius, max_seeds):
taken = np.zeros((height, width), dtype=np.uint8)
limit = len(sorted_x) if max_seeds < 0 else min(
len(sorted_x), max_seeds
)
seeds = np.empty((limit, 2), dtype=np.int32)
seed_count = 0
for candidate in range(len(sorted_x)):
x = int(sorted_x[candidate])
y = int(sorted_y[candidate])
if taken[y, x] != 0:
continue
seeds[seed_count, 0] = x
seeds[seed_count, 1] = y
seed_count += 1
y_start = max(0, y - radius)
y_stop = min(height, y + radius + 1)
x_start = max(0, x - radius)
x_stop = min(width, x + radius + 1)
for yy in range(y_start, y_stop):
for xx in range(x_start, x_stop):
taken[yy, xx] = 1
if max_seeds >= 0 and seed_count >= max_seeds:
break
return seeds[:seed_count]
else:
_greedy_seed_select_numba = None
# --------------------------------------------------------------------------- #
# Lane container (replaces FloatLengthLine + PointSelf)
# --------------------------------------------------------------------------- #
class Lane:
def __init__(self, width, height):
self.width = width
self.height = height
self.points = [] # list of (x, y, score)
self._score_sum = 0.0
self.lane_id = None
self.lane_role = None
self.is_ego_boundary = False
self.lateral_rank = None
def append(self, x, y, score):
if isinstance(self.points, np.ndarray):
self.points = [tuple(map(float, point)) for point in self.points]
self.points.append((float(x), float(y), float(score)))
self._score_sum += float(score)
def reverse(self):
if isinstance(self.points, np.ndarray):
self.points = self.points[::-1].copy()
else:
self.points.reverse()
def __len__(self):
return len(self.points)
@property
def score(self):
if len(self.points) == 0:
return 0.0
return self._score_sum / len(self.points)
def concat(self, other):
out = Lane(self.width, self.height)
if isinstance(self.points, np.ndarray) or isinstance(
other.points, np.ndarray
):
out.points = np.concatenate((
np.asarray(self.points, dtype=np.float32),
np.asarray(other.points, dtype=np.float32),
))
else:
out.points = self.points + other.points
out._score_sum = self._score_sum + other._score_sum
return out
def xy(self):
points = np.asarray(self.points, dtype=np.float32)
if points.size == 0:
return np.empty((0, 2), dtype=np.float32)
return points[:, :2]
def iou(self, other, lane_width=15):
"""Rasterize both lanes (cv2.line, width 15) and return mask IoU."""
im1 = np.zeros((self.height, self.width), np.uint8)
im2 = np.zeros((self.height, self.width), np.uint8)
p1 = self.xy().astype(np.int32)
p2 = other.xy().astype(np.int32)
for i in range(len(p1) - 1):
cv2.line(im1, tuple(p1[i]), tuple(p1[i + 1]), 255, lane_width)
for i in range(len(p2) - 1):
cv2.line(im2, tuple(p2[i]), tuple(p2[i + 1]), 255, lane_width)
union = int((cv2.bitwise_or(im1, im2) > 0).sum())
if union == 0:
return 0.0
inter = int((im1 > 0).sum()) + int((im2 > 0).sum()) - union
return inter / float(union)
# --------------------------------------------------------------------------- #
# seeding
# --------------------------------------------------------------------------- #
def point_nms(prob, thr=0.5, min_dist=2, max_seeds=1024,
backend="auto"):
"""Greedy point-NMS: keep highest-prob foreground pixels >= min_dist apart."""
H, W = prob.shape
ys, xs = np.where(prob > thr)
if len(ys) == 0:
return []
order = np.argsort(-prob[ys, xs])
selected_backend = backend
if backend == "auto":
selected_backend = (
"numba" if _greedy_seed_select_numba is not None else "python"
)
if selected_backend == "numba":
limit = -1 if max_seeds is None else int(max_seeds)
return _greedy_seed_select_numba(
np.ascontiguousarray(xs[order], dtype=np.int32),
np.ascontiguousarray(ys[order], dtype=np.int32),
H, W, int(min_dist), limit,
)
if selected_backend != "python":
raise ValueError("point-NMS backend must be auto, numba, or python")
taken = np.zeros((H, W), dtype=bool)
seeds = []
r = min_dist
for idx in order:
y, x = int(ys[idx]), int(xs[idx])
if taken[y, x]:
continue
seeds.append((x, y))
taken[max(0, y - r):y + r + 1, max(0, x - r):x + r + 1] = True
if max_seeds is not None and len(seeds) >= max_seeds:
break
return seeds
# --------------------------------------------------------------------------- #
# relay-chain crawl (port of decode_branch)
# --------------------------------------------------------------------------- #
def decode_branch(cx, cy, semantic_fine, arrow, bound, step_length, seg_threshold):
H, W = semantic_fine.shape
arrow_dx, arrow_dy = arrow[0], arrow[1]
lane = Lane(W, H)
remain_sq_sum = 0.0
remain_count = 0
cx, cy = int(cx), int(cy)
for index in range(H):
if semantic_fine[cy, cx] > seg_threshold:
remain = bound[cy, cx] * 100 / step_length + index
remain_sq_sum += remain * remain
remain_count += 1
dx = arrow_dx[cy, cx]
dy = arrow_dy[cy, cx]
norm = np.sqrt(dx * dx + dy * dy)
if norm == 0: # guard (not in original): dead transfer vector -> stop
break
cx = int(np.floor(cx + dx / norm * step_length))
cy = int(np.floor(cy + dy / norm * step_length))
if not (0 <= cx < W and 0 <= cy < H):
break
lane.append(cx, cy, semantic_fine[cy, cx])
if remain_count:
ret = np.sqrt(remain_sq_sum / remain_count)
else:
ret = 1
if semantic_fine[cy, cx] > seg_threshold:
continue
if index > ret * 0.75:
break
return lane
def decode_branches_batch(seeds, semantic_fine, arrow, bound, step_length,
seg_threshold):
"""Vectorized equivalent of :func:`decode_branch` for many seeds.
The relay walk is inherently sequential along each lane, but every seed at
a given crawl step is independent. Advancing all active seeds with NumPy
gathers removes the expensive Python ``seeds x steps`` nested loop while
retaining the original stopping rule and integer pixel trajectory.
Returns:
``(points, lengths)`` where ``points`` has shape ``(N, H, 3)`` and each
valid prefix stores ``(x, y, score)`` exactly like ``Lane.points``.
"""
H, W = semantic_fine.shape
seed_array = np.asarray(seeds, dtype=np.int32)
if seed_array.size == 0:
return np.empty((0, H, 3), dtype=np.float32), np.zeros(0, np.int32)
if seed_array.ndim != 2 or seed_array.shape[1] != 2:
raise ValueError("seeds must have shape (N, 2)")
count = len(seed_array)
cx = seed_array[:, 0].copy()
cy = seed_array[:, 1].copy()
active = np.ones(count, dtype=bool)
lengths = np.zeros(count, dtype=np.int32)
remain_sq_sum = np.zeros(count, dtype=np.float64)
remain_count = np.zeros(count, dtype=np.int32)
points = np.empty((count, H, 3), dtype=np.float32)
arrow_dx, arrow_dy = arrow[0], arrow[1]
for index in range(H):
active_ids = np.flatnonzero(active)
if len(active_ids) == 0:
break
current_x = cx[active_ids]
current_y = cy[active_ids]
current_score = semantic_fine[current_y, current_x]
foreground = current_score > seg_threshold
if np.any(foreground):
foreground_ids = active_ids[foreground]
remain = (
bound[current_y[foreground], current_x[foreground]]
* 100.0 / step_length + index
)
remain_sq_sum[foreground_ids] += remain * remain
remain_count[foreground_ids] += 1
dx = arrow_dx[current_y, current_x]
dy = arrow_dy[current_y, current_x]
norm = np.sqrt(dx * dx + dy * dy)
movable = np.isfinite(norm) & (norm != 0.0)
if np.any(~movable):
active[active_ids[~movable]] = False
active_ids = active_ids[movable]
if len(active_ids) == 0:
continue
next_x = np.floor(
cx[active_ids] + dx[movable] / norm[movable] * step_length
).astype(np.int32)
next_y = np.floor(
cy[active_ids] + dy[movable] / norm[movable] * step_length
).astype(np.int32)
in_bounds = (
(next_x >= 0) & (next_x < W) & (next_y >= 0) & (next_y < H)
)
if np.any(~in_bounds):
active[active_ids[~in_bounds]] = False
active_ids = active_ids[in_bounds]
if len(active_ids) == 0:
continue
next_x = next_x[in_bounds]
next_y = next_y[in_bounds]
cx[active_ids] = next_x
cy[active_ids] = next_y
next_score = semantic_fine[next_y, next_x]
points[active_ids, index, 0] = next_x
points[active_ids, index, 1] = next_y
points[active_ids, index, 2] = next_score
lengths[active_ids] = index + 1
has_remaining = remain_count[active_ids] > 0
remaining = np.ones(len(active_ids), dtype=np.float64)
remaining[has_remaining] = np.sqrt(
remain_sq_sum[active_ids[has_remaining]]
/ remain_count[active_ids[has_remaining]]
)
stop = (
(next_score <= seg_threshold)
& (index > remaining * 0.75)
)
if np.any(stop):
active[active_ids[stop]] = False
return points, lengths
if njit is not None:
@njit(cache=True, parallel=True)
def _decode_branches_numba_impl(seeds, semantic_fine, arrow, bound,
step_length, seg_threshold):
"""Parallel scalar relay walks compiled to native CPU code."""
height, width = semantic_fine.shape
seed_count = len(seeds)
points = np.empty((seed_count, height, 3), dtype=np.float32)
lengths = np.zeros(seed_count, dtype=np.int32)
for seed_index in prange(seed_count):
cx = int(seeds[seed_index, 0])
cy = int(seeds[seed_index, 1])
remain_sq_sum = 0.0
remain_count = 0
for index in range(height):
if semantic_fine[cy, cx] > seg_threshold:
remain = (
bound[cy, cx] * 100.0 / step_length + index
)
remain_sq_sum += remain * remain
remain_count += 1
dx = arrow[0, cy, cx]
dy = arrow[1, cy, cx]
norm = np.sqrt(dx * dx + dy * dy)
if norm == 0.0 or not np.isfinite(norm):
break
cx = int(np.floor(cx + dx / norm * step_length))
cy = int(np.floor(cy + dy / norm * step_length))
if not (0 <= cx < width and 0 <= cy < height):
break
score = semantic_fine[cy, cx]
points[seed_index, index, 0] = cx
points[seed_index, index, 1] = cy
points[seed_index, index, 2] = score
lengths[seed_index] = index + 1
ret = (
np.sqrt(remain_sq_sum / remain_count)
if remain_count else 1.0
)
if score > seg_threshold:
continue
if index > ret * 0.75:
break
return points, lengths
@njit(cache=True, parallel=True)
def _candidate_metadata_numba_impl(up_points, up_lengths,
down_points, down_lengths, bin_px):
seed_count = len(up_lengths)
scores = np.zeros(seed_count, dtype=np.float64)
bins = np.zeros(seed_count, dtype=np.int32)
total_lengths = up_lengths + down_lengths
for seed_index in prange(seed_count):
up_length = int(up_lengths[seed_index])
down_length = int(down_lengths[seed_index])
total_length = up_length + down_length
if total_length <= 1:
continue
y_values = np.empty(total_length, dtype=np.float32)
score_sum = 0.0
position = 0
for point_index in range(up_length):
score_sum += up_points[seed_index, point_index, 2]
y_values[position] = up_points[seed_index, point_index, 1]
position += 1
for point_index in range(down_length):
score_sum += down_points[seed_index, point_index, 2]
y_values[position] = down_points[seed_index, point_index, 1]
position += 1
scores[seed_index] = score_sum / total_length
median_y = np.median(y_values)
lower_x = np.empty(total_length, dtype=np.float32)
lower_count = 0
for point_index in range(up_length):
if up_points[seed_index, point_index, 1] >= median_y:
lower_x[lower_count] = up_points[
seed_index, point_index, 0
]
lower_count += 1
for point_index in range(down_length):
if down_points[seed_index, point_index, 1] >= median_y:
lower_x[lower_count] = down_points[
seed_index, point_index, 0
]
lower_count += 1
bins[seed_index] = int(
np.median(lower_x[:lower_count]) // bin_px
)
return total_lengths, scores, bins
else:
_decode_branches_numba_impl = None
_candidate_metadata_numba_impl = None
def decode_branches_numba(seeds, semantic_fine, arrow, bound, step_length,
seg_threshold):
if _decode_branches_numba_impl is None:
raise RuntimeError(
"Numba crawl requested but numba is not installed; "
"install requirements.txt or use crawl_backend='numpy'"
)
seed_array = np.asarray(seeds, dtype=np.int32)
if seed_array.size == 0:
height = semantic_fine.shape[0]
return (
np.empty((0, height, 3), dtype=np.float32),
np.zeros(0, dtype=np.int32),
)
return _decode_branches_numba_impl(
seed_array,
np.ascontiguousarray(semantic_fine, dtype=np.float32),
np.ascontiguousarray(arrow, dtype=np.float32),
np.ascontiguousarray(bound, dtype=np.float32),
float(step_length),
float(seg_threshold),
)
def warmup_decode_backend(crawl_backend="auto"):
"""Compile the optional Numba backend before latency measurements."""
backend = crawl_backend
if crawl_backend == "auto":
backend = "numba" if njit is not None else "numpy"
if backend != "numba":
return backend
semantic = np.zeros((8, 8), dtype=np.float32)
arrow = np.zeros((2, 8, 8), dtype=np.float32)
arrow[1] = 1.0
bound = np.zeros((8, 8), dtype=np.float32)
points, lengths = decode_branches_numba(
[(4, 4)], semantic, arrow, bound, 1, 0.5
)
_candidate_metadata_numba_impl(
points, lengths, points, lengths, 16
)
_greedy_seed_select_numba(
np.array((4,), dtype=np.int32),
np.array((4,), dtype=np.int32),
8, 8, 2, 1,
)
return backend
def configure_decode_threads(thread_count=8):
"""Set Numba's relay-crawl worker count and return the applied value."""
if njit is None:
return 1
import numba
maximum = int(numba.config.NUMBA_NUM_THREADS)
if not 1 <= int(thread_count) <= maximum:
raise ValueError(f"decode threads must be in [1, {maximum}]")
numba.set_num_threads(int(thread_count))
return numba.get_num_threads()
def _candidate_metadata_numpy(up_points, up_lengths,
down_points, down_lengths, bin_px):
total_lengths = up_lengths + down_lengths
scores = np.zeros(len(total_lengths), dtype=np.float64)
bins = np.zeros(len(total_lengths), dtype=np.int32)
for seed_index, total_length in enumerate(total_lengths):
if total_length <= 1:
continue
up = up_points[seed_index, :up_lengths[seed_index]]
down = down_points[seed_index, :down_lengths[seed_index]]
merged = np.concatenate((up, down))
scores[seed_index] = merged[:, 2].sum(dtype=np.float64) / len(merged)
median_y = np.median(merged[:, 1])
lower = merged[merged[:, 1] >= median_y]
bins[seed_index] = int(np.median(lower[:, 0]) // bin_px)
return total_lengths, scores, bins
def _preselect_batched_candidates(up_points, up_lengths,
down_points, down_lengths, score_threshold,
max_lanes, backend, bin_px=16):
metadata = (
_candidate_metadata_numba_impl
if backend == "numba" else _candidate_metadata_numpy
)
total_lengths, scores, bins = metadata(
up_points, up_lengths, down_points, down_lengths, bin_px
)
valid = np.flatnonzero(
(total_lengths > 1) & (scores >= score_threshold)
)
order = valid[np.argsort(-scores[valid], kind="stable")]
if max_lanes is None or len(order) <= max_lanes:
return order
buckets = {}
for candidate in order:
buckets.setdefault(int(bins[candidate]), []).append(int(candidate))
keys = list(buckets)
positions = {key: 0 for key in keys}
selected = []
while len(selected) < max_lanes:
progressed = False
for key in keys:
position = positions[key]
if position < len(buckets[key]):
selected.append(buckets[key][position])
positions[key] += 1
progressed = True
if len(selected) >= max_lanes:
break
if not progressed:
break
return np.asarray(
sorted(selected, key=lambda index: scores[index], reverse=True),
dtype=np.int32,
)
def _lines_from_batched_crawls(up_points, up_lengths,
down_points, down_lengths, width, height,
candidate_indices=None):
lines = []
if candidate_indices is None:
candidate_indices = range(len(up_lengths))
for seed_index in candidate_indices:
up_length = int(up_lengths[seed_index])
down_length = int(down_lengths[seed_index])
if up_length + down_length <= 1:
continue
merged = np.concatenate((
up_points[seed_index, :up_length][::-1],
down_points[seed_index, :down_length],
))
lane = Lane(width, height)
lane.points = merged
lane._score_sum = float(merged[:, 2].sum(dtype=np.float64))
lines.append(lane)
return lines
# --------------------------------------------------------------------------- #
# post-processing
# --------------------------------------------------------------------------- #
def thresh_line(lines, thr=0.10):
return [ln for ln in lines if ln.score >= thr]
def _diverse_preselect(order, max_lanes, bin_px=16):
"""Cap the candidate list while keeping spatial diversity.
`order` is already sorted by score (desc). Taking the top `max_lanes`
outright lets the single strongest lane monopolize every slot -- its copies
all score highest -- so genuine but slightly weaker lanes get dropped before
IoU-NMS ever compares them (this collapsed multi-lane curves to one lane).
Instead, bucket candidates by their horizontal position on the lower half of
the line (where lanes are well separated) and pick round-robin across
buckets, so every lane keeps representatives within the cap.
"""
if max_lanes is None:
return order
if max_lanes <= 0:
raise ValueError("max_lanes must be positive or None")
if bin_px <= 0:
raise ValueError("bin_px must be positive")
if len(order) <= max_lanes:
return order
buckets = {}
for ln in order: # already score-sorted
xy = ln.xy()
if len(xy) == 0:
continue
low = xy[xy[:, 1] >= np.median(xy[:, 1])] # lower (near) half
ref = low if len(low) else xy
key = int(np.median(ref[:, 0]) // bin_px)
buckets.setdefault(key, []).append(ln)
keys = list(buckets.keys())
idx = {k: 0 for k in keys}
selected = []
while len(selected) < max_lanes:
progressed = False
for k in keys:
if idx[k] < len(buckets[k]):
selected.append(buckets[k][idx[k]])
idx[k] += 1
progressed = True
if len(selected) >= max_lanes:
break
if not progressed:
break
# NMS is greedy, so restore global score priority after choosing a spatially
# diverse candidate set.
return sorted(selected, key=lambda ln: ln.score, reverse=True)
def iou_nms(lines, thr=0.5, max_lanes=128, scale=0.25,
lane_width=15):
"""Lane IoU NMS with one cached, downscaled mask per candidate.
Rasterizes each candidate once (downscaled) instead of re-rasterizing both
masks per pair. The candidate list is capped with `_diverse_preselect` rather
than a plain top-score cut, so the strongest lane cannot crowd out the others
before NMS runs.
"""
if not lines:
return []
order = sorted(lines, key=lambda ln: ln.score, reverse=True)
order = _diverse_preselect(order, max_lanes)
height = max(1, int(round(order[0].height * scale)))
width = max(1, int(round(order[0].width * scale)))
scaled_width = max(1, int(round(lane_width * scale)))
masks = []
areas = []
for line in order:
mask = np.zeros((height, width), np.uint8)
points = line.xy() * scale
if len(points) >= 2:
points[:, 0] = np.clip(points[:, 0], 0, width - 1)
points[:, 1] = np.clip(points[:, 1], 0, height - 1)
cv2.polylines(mask, [points.astype(np.int32)], False, 1,
scaled_width)
masks.append(mask)
areas.append(int(np.count_nonzero(mask)))
suppressed = [False] * len(order)
keep = []
for i in range(len(order)):
if suppressed[i]:
continue
keep.append(order[i])
for j in range(i + 1, len(order)):
if suppressed[j]:
continue
inter = int(np.count_nonzero(masks[i] & masks[j]))
union = areas[i] + areas[j] - inter
if union > 0 and inter / union >= thr:
suppressed[j] = True
return keep
# --------------------------------------------------------------------------- #
# lane identity (left-to-right ordering)
# --------------------------------------------------------------------------- #
def _bottom_x(lane):
"""x where a lane meets its nearest row (largest y). Lanes fan out near the
camera, so this is the most reliable place to order them left-to-right."""
xy = lane.xy()
if len(xy) == 0:
return float("inf")
return float(xy[int(np.argmax(xy[:, 1])), 0])
def _ego_reference_x(lane, target_y=None):
"""Estimate where a boundary meets the near-camera reference row.
On a sharp bend, multiple boundaries can leave through the same image side.
Comparing their last visible x then becomes ambiguous (both are about 0 or
``width - 1``), and a short outer boundary can be mistaken for the ego-lane
boundary. Extrapolating the lower 40% of each polyline to a common row keeps
their lateral order after they leave the image.
"""
xy = lane.xy()
if len(xy) < 2:
return _bottom_x(lane)
xy = xy[np.isfinite(xy).all(axis=1)]
if len(xy) < 2:
return _bottom_x(lane)
if target_y is None:
target_y = lane.height - 1.0
cutoff = np.quantile(xy[:, 1], 0.6)
lower = xy[xy[:, 1] >= cutoff]
if len(lower) < 2 or np.ptp(lower[:, 1]) < 1.0:
return _bottom_x(lane)
ys = lower[:, 1]
xs = lower[:, 0]
centered_y = ys - ys.mean()
denominator = float(np.dot(centered_y, centered_y))
if denominator <= 1e-6:
return _bottom_x(lane)
slope = float(np.dot(centered_y, xs - xs.mean()) / denominator)
return float(xs.mean() + slope * (float(target_y) - ys.mean()))
def order_lanes(lanes):
"""Sort lanes left-to-right and assign a frame-local index.
RCLane is anchor-free: `decode` emits lane instances in score order with no
inherent identity. This helper only establishes spatial order inside one
frame; it must not be used as a persistent video identity because a missing
outer lane would shift every following index.
"""
ordered = sorted(lanes, key=_bottom_x)
for i, ln in enumerate(ordered):
ln.lane_id = i
return ordered
def assign_ego_lane_roles(lanes, ego_x=None):
"""Assign stable semantic IDs relative to the ego vehicle.
IDs describe a lane boundary's role rather than its position in a variable
length list:
* P1: nearest boundary left of ego (current-lane left boundary)
* P2: nearest boundary right of ego (current-lane right boundary)
* P0: next boundary to the left
* P3: next boundary to the right
Consequently P1/P2 do not become P0/P1 merely because an outer boundary is
temporarily missing. With the default four-lane cap, returned IDs are in
``[0, 3]``. More uncapped lanes continue outward with negative IDs on the
left and IDs greater than three on the right.
"""
if not lanes:
return []
if ego_x is None:
ego_x = lanes[0].width / 2.0
ego_x = float(ego_x)
for lane in lanes:
lane.lane_id = None
lane.lane_role = None
lane.is_ego_boundary = False
lane.lateral_rank = None
reference_x = {lane: _ego_reference_x(lane) for lane in lanes}
left = sorted(
(lane for lane in lanes if reference_x[lane] < ego_x),
key=lambda lane: (abs(reference_x[lane] - ego_x), -lane.score),
)
right = sorted(
(lane for lane in lanes if reference_x[lane] >= ego_x),
key=lambda lane: (abs(reference_x[lane] - ego_x), -lane.score),
)
for rank, lane in enumerate(left, 1):
lane.lane_id = 2 - rank # nearest left=P1, next=P0
lane.lateral_rank = -rank
lane.is_ego_boundary = rank == 1
lane.lane_role = "ego_left" if rank == 1 else f"left_{rank}"
for rank, lane in enumerate(right, 1):
lane.lane_id = 1 + rank # nearest right=P2, next=P3
lane.lateral_rank = rank
lane.is_ego_boundary = rank == 1
lane.lane_role = "ego_right" if rank == 1 else f"right_{rank}"
return sorted(lanes, key=lambda lane: lane.lane_id)
def ego_lane_boundaries(lanes):
"""Return ``(left, right)`` boundaries of the lane containing ego.
Either value can be ``None`` when that side was not detected.
"""
left = next(
(lane for lane in lanes if lane.lane_role == "ego_left"), None
)
right = next(
(lane for lane in lanes if lane.lane_role == "ego_right"), None
)
return left, right
def select_ego_lanes(lanes, max_lanes=4, ego_x=None,
min_score_ratio=0.5, balance_sides=True):
"""Keep the closest reliable lane boundaries around the ego vehicle.
The decoder can occasionally return an extra low-confidence crawl in
addition to the real road boundaries. When more than ``max_lanes`` are
present, first prefer candidates whose score is at least
``min_score_ratio`` of the best candidate (provided that still leaves enough
lanes), then select the nearest boundaries using their near-camera x.
For the usual four-lane output, ``balance_sides`` reserves two slots on
either side of the camera centre when possible. Any unfilled slots are
taken from the remaining closest candidates. The returned IDs are semantic:
P1/P2 are the current-lane boundaries, while P0/P3 are the adjacent outer
boundaries. Missing outer lanes therefore do not shift the ego-lane IDs.
"""
if max_lanes is None:
return assign_ego_lane_roles(lanes, ego_x)
if max_lanes <= 0:
raise ValueError("max_lanes must be positive or None")
if not 0.0 <= min_score_ratio <= 1.0:
raise ValueError("min_score_ratio must be in [0, 1]")
ordered = order_lanes(lanes)
if not ordered:
return []
if ego_x is None:
ego_x = ordered[0].width / 2.0
ego_x = float(ego_x)
# The four-lane semantic contract has exactly two possible boundaries per
# side: P0/P1 on the left and P2/P3 on the right. If one side is missing,
# return fewer lanes instead of filling the gap with P4/P-1 farther out.
if len(ordered) <= max_lanes:
if balance_sides and max_lanes == 4:
reference_x = {lane: _ego_reference_x(lane) for lane in ordered}
def near_ego(lane):
return (abs(reference_x[lane] - ego_x), -lane.score)
left = sorted(
(lane for lane in ordered if reference_x[lane] < ego_x),
key=near_ego,
)
right = sorted(
(lane for lane in ordered if reference_x[lane] >= ego_x),
key=near_ego,
)
ordered = left[:2] + right[:2]
return assign_ego_lane_roles(ordered, ego_x)
best_score = max(lane.score for lane in ordered)
reliable = [
lane for lane in ordered
if lane.score >= best_score * min_score_ratio
]
# Never let the reliability gate force the output below the requested cap.
pool = reliable if len(reliable) >= max_lanes else ordered
reference_x = {lane: _ego_reference_x(lane) for lane in pool}
def proximity_key(lane):
return (abs(reference_x[lane] - ego_x), -lane.score)
ranked = sorted(pool, key=proximity_key)
selected = []
if balance_sides and max_lanes >= 2:
left = sorted(
(lane for lane in pool if reference_x[lane] < ego_x),
key=proximity_key,
)
right = sorted(
(lane for lane in pool if reference_x[lane] >= ego_x),
key=proximity_key,
)
left_slots = max_lanes // 2
right_slots = max_lanes - left_slots
selected.extend(left[:left_slots])
selected.extend(right[:right_slots])
if not (balance_sides and max_lanes == 4):
for lane in ranked:
if lane not in selected:
selected.append(lane)
if len(selected) == max_lanes:
break
return assign_ego_lane_roles(selected[:max_lanes], ego_x)
# --------------------------------------------------------------------------- #
# full decode
# --------------------------------------------------------------------------- #
def decode(seg_prob, up_arrow, down_arrow, up_bound, down_bound,
step_length=10, seg_threshold=0.5, seed_min_dist=2,
score_thresh=0.10, iou_thresh=0.5, seed_threshold=None,
max_seeds=1024, nms_max_lanes=128, nms_scale=0.25,
sort_lanes=True, max_output_lanes=4, ego_x=None,
ego_min_score_ratio=0.5, balance_ego_sides=True,
batch_crawl=True, crawl_backend="auto",
point_nms_backend="auto"):
"""
Args:
seg_prob: (H, W) foreground probability.
up_arrow, down_arrow, up_bound, down_bound: (2, H, W).
seg_threshold: foreground test used while crawling a chain.
seed_threshold: threshold for picking seeds (defaults to seg_threshold).
RCLane seg maps are low-magnitude (OHEM 15:1), so seeds often sit below
0.5 -- set this lower (e.g. 0.3) for under-trained models.
max_output_lanes: final ego-centric lane cap. Defaults to four; pass
``None`` to preserve every lane surviving NMS.
Returns:
list of Lane. Use `lane.xy()` for the (N, 2) point array and `lane.score`.
"""
H, W = seg_prob.shape
if seed_threshold is None:
seed_threshold = seg_threshold
seeds = point_nms(
seg_prob, seed_threshold, seed_min_dist, max_seeds,
backend=point_nms_backend,
)
ub0, db0 = up_bound[0], down_bound[0] # bound channel 0 (both channels equal)
if batch_crawl:
backend = crawl_backend
if crawl_backend == "auto":
backend = "numba" if njit is not None else "numpy"
if backend not in ("numba", "numpy"):
raise ValueError("crawl_backend must be auto, numba, or numpy")
crawl = (
decode_branches_numba if backend == "numba"
else decode_branches_batch
)
up_points, up_lengths = crawl(
seeds, seg_prob, up_arrow, ub0, step_length, seg_threshold
)
down_points, down_lengths = crawl(
seeds, seg_prob, down_arrow, db0, step_length, seg_threshold
)
candidate_indices = _preselect_batched_candidates(
up_points, up_lengths, down_points, down_lengths,
score_thresh, nms_max_lanes, backend,
)
lines = _lines_from_batched_crawls(
up_points, up_lengths, down_points, down_lengths, W, H,
candidate_indices,
)
else:
lines = []
for (x, y) in seeds:
up = decode_branch(
x, y, seg_prob, up_arrow, ub0, step_length, seg_threshold
)
down = decode_branch(
x, y, seg_prob, down_arrow, db0, step_length, seg_threshold
)
up.reverse()
full = up.concat(down)
if len(full) > 1:
lines.append(full)
lines = thresh_line(lines, score_thresh)
lines = iou_nms(lines, iou_thresh, max_lanes=nms_max_lanes,
scale=nms_scale)
if max_output_lanes is not None:
lines = select_ego_lanes(
lines,
max_lanes=max_output_lanes,
ego_x=ego_x,
min_score_ratio=ego_min_score_ratio,
balance_sides=balance_ego_sides,
)
elif sort_lanes:
lines = order_lanes(lines)
return lines
def decode_predictions(pred_dict, **kwargs):
"""Convenience: decode a batch of network outputs (torch tensors, B,2,H,W).
Returns a list (len B) of lists of Lane. Requires torch only for the input.
"""
import torch # local import so the module stays torch-free otherwise
seg = torch.softmax(pred_dict["seg_map"], dim=1)[:, 1] # (B,H,W) fg prob
seg = seg.detach().cpu().numpy()
ua = pred_dict["up_arrow"].detach().cpu().numpy()
da = pred_dict["down_arrow"].detach().cpu().numpy()
ub = pred_dict["up_bound"].detach().cpu().numpy()
db = pred_dict["down_bound"].detach().cpu().numpy()
return [decode(seg[b], ua[b], da[b], ub[b], db[b], **kwargs) for b in range(seg.shape[0])]
# --------------------------------------------------------------------------- #
# smoke test -- encode/decode ROUND TRIP
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
from encode import encode, IMG_SIZE
H, W = IMG_SIZE
# ground-truth lane (an S-curve), points in 800x320 space
ys = np.linspace(20, 300, 40)
xs = 400 + 150 * np.sin(ys / 300 * np.pi)
gt_lane = np.stack([xs, ys], axis=1)
gt = encode([list(map(tuple, gt_lane))])
# decode straight from the GT maps -> should reconstruct the lane.
# use a coarser seed spacing to keep the test fast.
lanes = decode(
gt["seg_map"], gt["up_arrow"], gt["down_arrow"], gt["up_bound"], gt["down_bound"],
seed_min_dist=12,
)
print(f"decoded {len(lanes)} lane(s) from GT maps")
assert len(lanes) >= 1, "round trip produced no lanes!"
# take the longest recovered lane, measure how far its points sit from the GT curve
best = max(lanes, key=len)
pred_xy = best.xy()
print(f"longest lane: {len(best)} points, score={best.score:.2f}")
def dist_to_gt(pt):
d = np.hypot(gt_lane[:, 0] - pt[0], gt_lane[:, 1] - pt[1])
return d.min()
errs = np.array([dist_to_gt(p) for p in pred_xy])
print(f"mean dist to GT curve = {errs.mean():.2f}px, max = {errs.max():.2f}px")
assert errs.mean() < 8.0, "reconstructed lane strays too far from GT!"
print("OK -- encode/decode round trip reconstructs the lane.")
# Regression: a high-scoring lane may have hundreds of near-duplicate
# crawls. The cap must still retain weaker candidates from other lanes,
# while greedy NMS must receive candidates in descending score order.
def vertical_lane(x, score):
lane = Lane(W, H)
lane.append(x, 200, score)
lane.append(x, 300, score)
return lane
candidates = [
vertical_lane(100 + index % 2, 0.99 - index * 0.001)
for index in range(24)
]
candidates += [vertical_lane(350, 0.90), vertical_lane(650, 0.89)]
candidates.sort(key=lambda lane: lane.score, reverse=True)
selected = _diverse_preselect(candidates, max_lanes=8, bin_px=16)
selected_bins = {
int(np.median(lane.xy()[:, 0]) // 16) for lane in selected
}
expected_bins = {100 // 16, 350 // 16, 650 // 16}
assert expected_bins <= selected_bins, "spatial preselection dropped a lane"
selected_scores = [lane.score for lane in selected]
assert selected_scores == sorted(selected_scores, reverse=True), (
"spatial preselection changed greedy NMS score priority"
)
print("OK -- diverse NMS preselection retains spatially distinct lanes.")
# Regression: cap the final output around the ego vehicle without keeping a
# weak extra crawl merely because its endpoint is slightly closer laterally.
ego_candidates = [
vertical_lane(8, 0.93),
vertical_lane(20, 0.24), # spurious fifth crawl
vertical_lane(95, 0.94),
vertical_lane(748, 0.89),
vertical_lane(796, 0.81),
]
ego_lanes = select_ego_lanes(ego_candidates, max_lanes=4)
ego_xs = [int(_bottom_x(lane)) for lane in ego_lanes]
assert ego_xs == [8, 95, 748, 796], (
f"ego selector kept the wrong lanes: {ego_xs}"
)
assert [lane.lane_id for lane in ego_lanes] == [0, 1, 2, 3]
ego_left, ego_right = ego_lane_boundaries(ego_lanes)
assert ego_left is not None and int(_bottom_x(ego_left)) == 95
assert ego_right is not None and int(_bottom_x(ego_right)) == 748
# Semantic IDs must not shift when an outer lane disappears. P1/P2 remain
# the current-lane boundaries and P3 remains the next boundary on the right.
missing_outer_left = select_ego_lanes(
[vertical_lane(95, 0.94), vertical_lane(748, 0.89),
vertical_lane(796, 0.81)],
max_lanes=4,
)
assert [lane.lane_id for lane in missing_outer_left] == [1, 2, 3]
missing_outer_right = select_ego_lanes(
[vertical_lane(8, 0.93), vertical_lane(95, 0.94),
vertical_lane(748, 0.89)],
max_lanes=4,
)
assert [lane.lane_id for lane in missing_outer_right] == [0, 1, 2]
right_only = select_ego_lanes(
[vertical_lane(500, 0.95), vertical_lane(600, 0.90),
vertical_lane(700, 0.80)],
max_lanes=4,
)
assert [lane.lane_id for lane in right_only] == [2, 3]
# Two right boundaries can both leave through x=width on a sharp curve.
# The longer/nearer curve must remain P2 even if its last visible x is a
# little farther right than the short outer curve's last x.
near_right = Lane(800, 320)
outer_right = Lane(800, 320)
for x, y in ((700, 200), (740, 225), (780, 250)):
near_right.append(x, y, 0.9)
for x, y in ((700, 125), (750, 137.5), (790, 147.5)):
outer_right.append(x, y, 0.8)
curved_right = assign_ego_lane_roles([outer_right, near_right], ego_x=400)
assert near_right.lane_id == 2 and near_right.lane_role == "ego_right"
assert outer_right.lane_id == 3 and outer_right.lane_role == "right_2"
assert [lane.lane_id for lane in curved_right] == [2, 3]
print("OK -- ego post-processing keeps four reliable nearby lanes.")