File size: 14,841 Bytes
fe51a63 | 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 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 | # SPDX-License-Identifier: Apache-2.0
"""The Autoware ``lidar_frnet`` parameters this port reproduces, and the port's compile-time constants.
Sources (autoware_universe main ``9ceaccf``, ``perception/autoware_lidar_frnet`` = ``PKG/``; weights
``AutowareFoundation/lidar_frnet@v2.0`` = commit ``a53a1c11``; research/frnet/SPEC.md sections 1-3):
- per variant, from the weights repo: ``ml_package_frnet_<variant>.param.yaml`` (``fov_up_deg`` / ``fov_down_deg``,
``frustum_width`` / ``frustum_height`` (the network grid), ``interpolation_width`` / ``interpolation_height`` (the
pre-processing grid), the 27 ``class_names``, the 81-int ``palette``, the ``num_points`` / ``num_unique_coors``
[min, opt, max] profiles) and ``deploy_metadata.yaml`` (``version: v2.0``);
- node-wide, :data:`NODE_PARAMS` = ``PKG/config/frnet.param.yaml:3-16`` (filter threshold and classes, the filtered
cloud's output format, the ego crop box).
Every validation the node does on these values is repeated here: palette size and 0-255 range
(``PKG/include/.../utils.hpp`` ``make_palette``), filter classes must be class names (``make_filter_class_indices``),
the crop box is enabled iff any float32 bound is non-zero (``PKG/src/lidar_frnet_node.cpp:51-66``). ``yaml`` is
imported when a file is read, never at module import; nothing here imports torch / onnx / ttnn.
The two variants are an inseparable tuple (weights, frustum H x W baked into the ONNX, FOV, interpolation grid:
S:frnet:535-538): a user selects ``ot128`` / ``qt128`` only (LOAD), every number below follows from it.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
import numpy as np
__all__ = [
"VARIANTS", "DEFAULT_VARIANT", "DEPLOY_VERSION", "ONNX_SHA256", "NODE_PARAMS", "NUM_CLASSES", "STRIDES",
"BLOCKS", "CROP_BOUND_KEYS", "FRNetConfig", "variant_files", "parse_crop_box", "parse_filter_classes",
]
VARIANTS = ("ot128", "qt128") # PKG/launch/lidar_frnet.launch.xml:9-12 (sensor_model choices)
DEFAULT_VARIANT = "ot128" # the launch default (Hesai OT128)
DEPLOY_VERSION = "v2.0" # deploy_metadata.yaml of the pinned weights; Autoware's ansible pin (S:frnet:75-80)
# sha256 of the deployed graphs at the pinned revision (S:frnet:84-93): a different file is refused.
ONNX_SHA256 = {
"ot128": "d29bf5950432360d800c22ff86a590d4d9d006acc501563ac611cfa33747c11f",
"qt128": "647af92790e3d591a19d065fe94d3ded797916ac9ffe5bf96b5e8b8324b31ad2",
}
# PKG/config/frnet.param.yaml:3-16 (node parameters; RT-host in this port)
NODE_PARAMS: Dict[str, Any] = {
"class_probability_threshold": 0.05,
"filter_classes": ("drivable_surface",),
"output_format": "",
"ego_crop_box": {"reference_frame": "base_link", "min_x": -1.53, "min_y": -1.15, "min_z": -0.1,
"max_x": 6.1, "max_y": 1.15, "max_z": 3.1},
}
CROP_BOUND_KEYS = ("min_x", "min_y", "min_z", "max_x", "max_y", "max_z")
NUM_CLASSES = 27 # conv_seg Gemm 64 -> 27; ml_package class_names (COMPILE)
STRIDES = (1, 2, 4, 8) # AWML configs/model/frnet.py: backbone strides of layer1..4 (checked on the graph)
BLOCKS = (3, 4, 6, 3) # BasicBlocks per stage (checked on the graph)
POSTPROCESS_MAX_CLASSES = 64 # PKG/lib/postprocess/postprocess_kernel.cu:32 (const_palette[64])
POSTPROCESS_MAX_FILTER_CLASSES = 16 # postprocess_kernel.cu:30 (const_filter_class_indices[16])
def variant_files(weights_dir: Any, variant: str) -> Dict[str, Path]:
"""The files of one variant in a weights directory (the flat HF repo layout)."""
if variant not in VARIANTS:
raise ValueError(f"unknown variant {variant!r}; choose one of {VARIANTS}")
root = Path(weights_dir)
return {"onnx": root / f"frnet_{variant}.onnx", "ml_package": root / f"ml_package_frnet_{variant}.param.yaml",
"deploy_metadata": root / "deploy_metadata.yaml"}
def _ros_params(path: Path) -> Dict[str, Any]:
import yaml
with open(path) as f:
doc = yaml.safe_load(f)
try:
return doc["/**"]["ros__parameters"]
except (KeyError, TypeError):
raise ValueError(f"{path}: not a ROS 2 parameter file (no /**: ros__parameters)") from None
def _profile(values: Sequence[Any], name: str) -> Tuple[int, int, int]:
v = tuple(int(x) for x in values)
if len(v) != 3 or not (0 < v[0] <= v[1] <= v[2]):
raise ValueError(f"{name} must be [min, opt, max] with 0 < min <= opt <= max, got {list(values)}")
return v # type: ignore[return-value]
@dataclass(frozen=True)
class FRNetConfig:
"""One variant's parameters. COMPILE values (device shapes): ``frustum_h`` x ``frustum_w``, ``n_cap``,
``m_cap``, ``num_classes``; LOAD: the variant itself; RT-host: the node parameters (filter, crop box)."""
variant: str
version: str
fov_up_deg: float
fov_down_deg: float
frustum_w: int
frustum_h: int
interp_w: int
interp_h: int
class_names: Tuple[str, ...]
palette: Tuple[int, ...] # 3 x num_classes ints, RGB per class
num_points: Tuple[int, int, int] # [min, opt, max] after interpolation (and of the raw input)
num_unique: Tuple[int, int, int] # [min, opt, max] unique frustum cells
class_probability_threshold: float = NODE_PARAMS["class_probability_threshold"]
filter_classes: Tuple[str, ...] = NODE_PARAMS["filter_classes"]
crop_bounds: Tuple[float, ...] = tuple(NODE_PARAMS["ego_crop_box"][k] for k in CROP_BOUND_KEYS)
crop_reference_frame: str = NODE_PARAMS["ego_crop_box"]["reference_frame"]
model_dir: Optional[str] = field(default=None, compare=False)
# ---- construction --------------------------------------------------------------------------------------
@classmethod
def from_model_dir(cls, model_dir: Any, variant: str = DEFAULT_VARIANT, *,
check_version: bool = True) -> "FRNetConfig":
"""Read ``ml_package_frnet_<variant>.param.yaml`` (and check ``deploy_metadata.yaml``) of a weights
directory laid out as the HF repo."""
files = variant_files(model_dir, variant)
p = _ros_params(files["ml_package"])
version = DEPLOY_VERSION
if files["deploy_metadata"].is_file():
import yaml
meta = yaml.safe_load(files["deploy_metadata"].read_text()) or {}
version = str(meta.get("version", ""))
if check_version and version != DEPLOY_VERSION:
raise ValueError(f"deploy_metadata.yaml version {version!r} != the pinned {DEPLOY_VERSION!r}")
cfg = cls(variant=variant, version=version, fov_up_deg=float(p["fov_up_deg"]),
fov_down_deg=float(p["fov_down_deg"]), frustum_w=int(p["frustum_width"]),
frustum_h=int(p["frustum_height"]), interp_w=int(p["interpolation_width"]),
interp_h=int(p["interpolation_height"]), class_names=tuple(str(c) for c in p["class_names"]),
palette=tuple(int(v) for v in p["palette"]), num_points=_profile(p["num_points"], "num_points"),
num_unique=_profile(p["num_unique_coors"], "num_unique_coors"), model_dir=str(model_dir))
cfg.validate()
return cfg
def validate(self) -> None:
"""The node's own checks (utils.hpp ``Dims2d``, ``make_palette``, ``make_filter_class_indices``)."""
for name in ("frustum_w", "frustum_h", "interp_w", "interp_h"):
if getattr(self, name) <= 0:
raise ValueError(f"{name} must be positive")
n = len(self.class_names)
if n == 0 or n > POSTPROCESS_MAX_CLASSES:
raise ValueError(f"{n} classes; the node supports 1..{POSTPROCESS_MAX_CLASSES}")
if len(self.palette) % 3:
raise ValueError("Palette size must be a multiple of 3.")
if len(self.palette) != 3 * n:
raise ValueError("Palette size does not match class names size.")
if any(v < 0 or v > 255 for v in self.palette):
raise ValueError("Color values must be within 0-255 range.")
self.filter_class_indices()
if len(self.crop_bounds) != 6:
raise ValueError("ego crop box needs 6 bounds (min_x, min_y, min_z, max_x, max_y, max_z)")
def with_node_params(self, **overrides: Any) -> "FRNetConfig":
"""A copy with other node parameters (``class_probability_threshold``, ``filter_classes``,
``crop_bounds``); re-validated."""
cfg = replace(self, **overrides)
cfg.validate()
return cfg
# ---- derived values (each computed as the node computes it) ---------------------------------------------
@property
def num_classes(self) -> int:
return len(self.class_names)
@property
def fov(self) -> Tuple[np.float32, np.float32, np.float32]:
"""utils.hpp ``FieldOfView``: ``(up, down, total)`` radians, computed in double, stored as float32."""
up = np.float32(np.pi * self.fov_up_deg / 180.0)
down = np.float32(np.pi * self.fov_down_deg / 180.0)
total = np.float32(np.pi * abs(self.fov_up_deg - self.fov_down_deg) / 180.0)
return up, down, total
@property
def crop_bounds_f32(self) -> np.ndarray:
"""``static_cast<float>`` of the six bounds (lidar_frnet_node.cpp:53-58)."""
return np.asarray(self.crop_bounds, np.float64).astype(np.float32)
@property
def crop_enabled(self) -> bool:
"""Enabled iff any float32 bound is non-zero (lidar_frnet_node.cpp:59-66)."""
return bool(np.any(self.crop_bounds_f32 != 0.0))
def filter_class_indices(self, names: Optional[Sequence[str]] = None) -> Tuple[int, ...]:
"""Indices of ``names`` (default: the configured filter classes) in ``class_names``; an unknown name raises
``ValueError`` (the node throws ``Filter class '<name>' not found in class names.``)."""
names = self.filter_classes if names is None else tuple(names)
if len(names) > POSTPROCESS_MAX_FILTER_CLASSES:
raise ValueError(f"at most {POSTPROCESS_MAX_FILTER_CLASSES} filter classes")
out = []
for name in names:
if name not in self.class_names:
raise ValueError(f"Filter class '{name}' not found in class names.")
out.append(self.class_names.index(name))
return tuple(out)
@property
def palette_rgb(self) -> np.ndarray:
"""``(num_classes, 3)`` uint8."""
return np.asarray(self.palette, np.int64).reshape(-1, 3).astype(np.uint8)
@property
def palette_packed(self) -> np.ndarray:
"""utils.hpp ``make_palette``: ``(r << 16) | (g << 8) | b`` reinterpreted as float32 (the visualization
cloud's ``rgb`` field), ``(num_classes,)`` float32."""
rgb = self.palette_rgb.astype(np.uint32)
return ((rgb[:, 0] << 16) | (rgb[:, 1] << 8) | rgb[:, 2]).astype(np.uint32).view(np.float32)
@property
def n_cap(self) -> int:
"""Point capacity of the device graph (COMPILE): the profile maximum, so every frame Autoware accepts fits
(functional port; smaller buckets are optimization work)."""
return self.num_points[2]
@property
def m_cap(self) -> int:
"""Frustum-cell (voxel) capacity of the device graph (COMPILE); the gather tables add one zero row."""
return self.num_unique[2]
@property
def frustum_cells(self) -> int:
return self.frustum_h * self.frustum_w
def research_cfg(self) -> Dict[str, Any]:
"""The same values in the layout of research/frnet/scripts/frnet_preproc_ref.py ``CFGS[variant]``."""
return dict(name=self.variant, fov_up_deg=self.fov_up_deg, fov_down_deg=self.fov_down_deg,
frustum_w=self.frustum_w, frustum_h=self.frustum_h, interp_w=self.interp_w,
interp_h=self.interp_h, num_points=self.num_points, num_unique=self.num_unique)
def describe(self) -> Dict[str, Any]:
return {"variant": self.variant, "version": self.version, "fov_deg": [self.fov_up_deg, self.fov_down_deg],
"frustum_hw": [self.frustum_h, self.frustum_w], "interpolation_hw": [self.interp_h, self.interp_w],
"num_points": list(self.num_points), "num_unique_coors": list(self.num_unique),
"num_classes": self.num_classes, "class_probability_threshold": self.class_probability_threshold,
"filter_classes": list(self.filter_classes), "ego_crop_box": dict(zip(CROP_BOUND_KEYS,
self.crop_bounds)),
"ego_crop_reference_frame": self.crop_reference_frame}
def parse_crop_box(value: Any) -> Optional[Tuple[float, ...]]:
"""An ego crop box override: ``None`` / ``False`` (disabled = all-zero bounds, the node's own switch), six
numbers ``[min_x, min_y, min_z, max_x, max_y, max_z]``, or a mapping with those keys (missing keys keep
Autoware's defaults). Raises ``ValueError`` on anything else."""
if value is None or value is False:
return (0.0,) * 6
if isinstance(value, Mapping):
unknown = sorted(set(value) - set(CROP_BOUND_KEYS) - {"reference_frame"})
if unknown:
raise ValueError(f"ego_crop_box: unknown keys {unknown}")
if value.get("reference_frame", "base_link") != "base_link":
raise ValueError("ego_crop_box.reference_frame must be base_link (the transform's target frame)")
base = NODE_PARAMS["ego_crop_box"]
vals = [value.get(k, base[k]) for k in CROP_BOUND_KEYS]
else:
vals = list(value)
if len(vals) != 6:
raise ValueError("ego_crop_box needs 6 numbers [min_x, min_y, min_z, max_x, max_y, max_z]")
try:
out = tuple(float(v) for v in vals)
except (TypeError, ValueError):
raise ValueError("ego_crop_box bounds must be numbers") from None
if not all(np.isfinite(out)):
raise ValueError("ego_crop_box bounds must be finite")
return out
_IDENT = re.compile(r"^[A-Za-z0-9_+\-]+$")
def parse_filter_classes(value: Any) -> Tuple[str, ...]:
"""``filter_classes`` as a request gives it: a comma-separated string (``""`` = no filter class) or a list of
names. Names are checked against the class list by :meth:`FRNetConfig.filter_class_indices`."""
if value is None:
return ()
if isinstance(value, str):
names = [v.strip() for v in value.split(",") if v.strip()]
else:
names = [str(v).strip() for v in value]
bad = [n for n in names if not _IDENT.match(n)]
if bad:
raise ValueError(f"invalid filter class names {bad}")
return tuple(names)
|