Spaces:
Paused
Paused
File size: 7,356 Bytes
e8829d6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | """Render bevfusion synthetic scenes to schematic sensor images.
The bevfusion package (imported read-only) provides deterministic scenes
with 3D ground truth, planted occlusion windows, and day/night/rain
conditions. This module rasterizes those scenes into two image spaces:
* BEV top-down grayscale images — one pixel maps linearly to ego-frame
meters, so dense optical flow in BEV pixel space is directly a motion
field in the ego frame (used by HIL detection and temporal profiling).
* Front-camera stereo pairs — pinhole projection with a horizontal baseline,
so block-matching disparity converts back to metric depth (used by the
stereo consistency check).
These are schematic renderings (intensity-coded boxes plus sensor noise),
not photorealistic frames; they exist so the acceleration backends process
images whose ground truth is exactly known.
"""
from __future__ import annotations
from typing import Dict, List, Tuple
import numpy as np
from sensorflow.bevfusion.scenes import SceneFrame, SceneSequence
# BEV raster geometry: ego-frame x in [0, 80] m forward, y in [-25, 25] m.
BEV_H, BEV_W = 192, 256
X_MIN, X_MAX = 0.0, 80.0
Y_MIN, Y_MAX = -25.0, 25.0
CLASS_INTENSITY = {"vehicle": 0.85, "truck": 0.95, "pedestrian": 0.55,
"cyclist": 0.65, "motorcycle": 0.75}
# Front stereo camera model.
FOCAL_PX = 200.0
BASELINE_M = 0.5
CAM_H, CAM_W = 128, 256
CAM_HEIGHT_M = 1.6
def ego_to_bev_px(x: float, y: float) -> Tuple[float, float]:
"""Ego meters -> (row, col) in the BEV raster. Row 0 is the far field."""
row = (X_MAX - x) / (X_MAX - X_MIN) * (BEV_H - 1)
col = (y - Y_MIN) / (Y_MAX - Y_MIN) * (BEV_W - 1)
return row, col
def bev_px_to_ego(row: float, col: float) -> Tuple[float, float]:
x = X_MAX - row / (BEV_H - 1) * (X_MAX - X_MIN)
y = Y_MIN + col / (BEV_W - 1) * (Y_MAX - Y_MIN)
return x, y
BEV_M_PER_PX_X = (X_MAX - X_MIN) / (BEV_H - 1) # meters per row
BEV_M_PER_PX_Y = (Y_MAX - Y_MIN) / (BEV_W - 1) # meters per col
def _condition_params(seq: SceneSequence) -> Dict:
scale = 1.0
noise = 0.012
if seq.time_of_day == "night":
scale = 0.45 # dimmer returns -> fewer effective signal bits
noise = 0.02
if seq.weather == "rain":
scale *= 0.8
noise += 0.015
return {"intensity_scale": scale, "noise_sigma": noise}
def render_bev_frame(frame: SceneFrame, seq: SceneSequence, seed: int,
include_occluded: bool = True) -> np.ndarray:
"""Grayscale BEV raster (BEV_H, BEV_W) in [0, 1] with per-frame noise.
Noise is seeded from (seed, frame index) so BOTH backends see the exact
same source pixels — every ref-vs-vitis difference downstream is caused
by the backend, never by the renderer.
"""
params = _condition_params(seq)
img = np.zeros((BEV_H, BEV_W), dtype=np.float32)
for gt in frame.gt:
if gt.occluded and not include_occluded:
continue
x, y, _, l, w, _, _ = gt.bbox_3d
r, c = ego_to_bev_px(x, y)
hl = max(1.5, l / 2.0 / BEV_M_PER_PX_X)
hw = max(1.5, w / 2.0 / BEV_M_PER_PX_Y)
r0, r1 = int(max(0, r - hl)), int(min(BEV_H, r + hl + 1))
c0, c1 = int(max(0, c - hw)), int(min(BEV_W, c + hw + 1))
inten = CLASS_INTENSITY.get(gt.class_name, 0.7) * params["intensity_scale"]
if gt.occluded:
inten *= 0.55 # partially attenuated return
img[r0:r1, c0:c1] = np.maximum(img[r0:r1, c0:c1], inten)
rng = np.random.default_rng((seed * 1_000_003 + frame.index * 7919) % (2**63))
img += rng.normal(0.0, params["noise_sigma"], img.shape).astype(np.float32)
img += 0.04 # ground-return floor
return np.clip(img, 0.0, 1.0)
def render_bev_rgb(frame: SceneFrame, seq: SceneSequence, seed: int) -> np.ndarray:
"""Color BEV raster (class-hued boxes) used as ISP/augmentation source."""
gray = render_bev_frame(frame, seq, seed)
hue = {"vehicle": (1.0, 0.85, 0.6), "truck": (1.0, 0.7, 0.4),
"pedestrian": (0.6, 1.0, 0.7), "cyclist": (0.6, 0.85, 1.0),
"motorcycle": (0.9, 0.7, 1.0)}
rgb = np.stack([gray, gray, gray], axis=-1)
for gt in frame.gt:
x, y, _, l, w, _, _ = gt.bbox_3d
r, c = ego_to_bev_px(x, y)
hl = max(1.5, l / 2.0 / BEV_M_PER_PX_X)
hw = max(1.5, w / 2.0 / BEV_M_PER_PX_Y)
r0, r1 = int(max(0, r - hl)), int(min(BEV_H, r + hl + 1))
c0, c1 = int(max(0, c - hw)), int(min(BEV_W, c + hw + 1))
tint = np.array(hue.get(gt.class_name, (1.0, 1.0, 1.0)), dtype=np.float32)
rgb[r0:r1, c0:c1] *= tint
return np.clip(rgb, 0.0, 1.0)
def render_stereo_pair(frame: SceneFrame, seq: SceneSequence,
seed: int) -> Tuple[np.ndarray, np.ndarray, List[Dict]]:
"""Front-camera stereo pair + per-object projection records.
Left/right pinhole cameras separated by BASELINE_M; the right image sees
each object shifted left by disparity = FOCAL_PX * BASELINE_M / depth.
Returns (left, right, objects) where objects carry
{instance_id, class_name, depth_m, u, v, disparity_px}.
"""
params = _condition_params(seq)
cx, cy = CAM_W / 2.0, CAM_H / 2.0
left = np.zeros((CAM_H, CAM_W), dtype=np.float32)
right = np.zeros((CAM_H, CAM_W), dtype=np.float32)
objects: List[Dict] = []
# Draw far to near so nearer objects overwrite (correct occlusion order).
for gt in sorted(frame.gt, key=lambda g: -g.bbox_3d[0]):
if gt.occluded:
continue
x, y, z, l, w, h, _ = gt.bbox_3d
if x < 3.0:
continue
depth = float(x)
u = FOCAL_PX * (-y) / depth + cx
v = FOCAL_PX * (CAM_HEIGHT_M - z) / depth + cy
half_w = max(1.5, FOCAL_PX * (w / 2.0) / depth)
half_h = max(1.5, FOCAL_PX * (h / 2.0) / depth)
disparity = FOCAL_PX * BASELINE_M / depth
inten = CLASS_INTENSITY.get(gt.class_name, 0.7) * params["intensity_scale"]
# Textured fill so block matching has gradients to lock onto.
rng_tex = np.random.default_rng(abs(hash(gt.instance_id)) % (2**63))
for img, du in ((left, 0.0), (right, -disparity)):
u0 = int(max(0, u + du - half_w))
u1 = int(min(CAM_W, u + du + half_w + 1))
v0 = int(max(0, v - half_h))
v1 = int(min(CAM_H, v + half_h + 1))
if u1 <= u0 or v1 <= v0:
continue
tex = rng_tex.uniform(0.7, 1.0, (v1 - v0, u1 - u0)).astype(np.float32)
# Same texture crop in both views (rng reset below).
img[v0:v1, u0:u1] = inten * tex
rng_tex = np.random.default_rng(abs(hash(gt.instance_id)) % (2**63))
if 0 <= u < CAM_W and 0 <= v < CAM_H:
objects.append({"instance_id": gt.instance_id,
"class_name": gt.class_name,
"depth_m": round(depth, 3),
"u": round(u, 2), "v": round(v, 2),
"disparity_px": round(disparity, 3)})
rng = np.random.default_rng((seed * 999_983 + frame.index * 104_729) % (2**63))
for img in (left, right):
img += rng.normal(0.0, params["noise_sigma"], img.shape).astype(np.float32)
img += 0.03
return (np.clip(left, 0.0, 1.0), np.clip(right, 0.0, 1.0), objects)
|