From 93b11558988db7d675f8119924b438c05b5abd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D1=83=D1=80=D0=B0=D0=B4=D1=8F=D0=BD=20=D0=90=D1=80?= =?UTF-8?q?=D1=82=D1=91=D0=BC=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sat, 25 Jul 2026 19:33:16 +0300 Subject: [PATCH 1/7] Added utils for mesh assets generation --- opencda/core/attack/advcp/utils/__init__.py | 24 + .../core/attack/advcp/utils/asset_utils.py | 940 ++++++++++++++++++ .../attack/advcp/utils/generate_car_mesh.py | 93 ++ .../advcp/utils/generate_mesh_divide.py | 71 ++ .../utils/generate_remove_advshape_assets.py | 252 +++++ .../core/attack/advcp/utils/runtime_assets.py | 236 +++++ .../advcp/utils/validate_advcp_assets.py | 377 +++++++ 7 files changed, 1993 insertions(+) create mode 100644 opencda/core/attack/advcp/utils/__init__.py create mode 100644 opencda/core/attack/advcp/utils/asset_utils.py create mode 100644 opencda/core/attack/advcp/utils/generate_car_mesh.py create mode 100644 opencda/core/attack/advcp/utils/generate_mesh_divide.py create mode 100644 opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py create mode 100644 opencda/core/attack/advcp/utils/runtime_assets.py create mode 100644 opencda/core/attack/advcp/utils/validate_advcp_assets.py diff --git a/opencda/core/attack/advcp/utils/__init__.py b/opencda/core/attack/advcp/utils/__init__.py new file mode 100644 index 00000000..0c00b8d1 --- /dev/null +++ b/opencda/core/attack/advcp/utils/__init__.py @@ -0,0 +1,24 @@ +""" +AdvCP asset generation and validation utilities. + +Available modules +----------------- +generate_car_mesh + CLI for generating a vehicle mesh (.ply) from a CARLA blueprint or + external mesh input. +generate_mesh_divide + CLI for generating spoof or removal mesh-division metadata (.pkl). +generate_remove_advshape_assets + CLI for generating removal assets (.pkl and/or .npy) including + mesh-division metadata and adversarial-shape perturbation tensors. +validate_advcp_assets + CLI for validating .ply, .pkl, and .npy assets against the AdvCP + pipeline requirements. +runtime_assets + :class:`~AdvCPRuntimeAssetHelper` for on-demand asset generation + during simulation. +asset_utils + Core mesh, divide, and perturbation helpers (read, write, validate, + transform). +""" + diff --git a/opencda/core/attack/advcp/utils/asset_utils.py b/opencda/core/attack/advcp/utils/asset_utils.py new file mode 100644 index 00000000..addfca74 --- /dev/null +++ b/opencda/core/attack/advcp/utils/asset_utils.py @@ -0,0 +1,940 @@ +""" +Core mesh, divide, and perturbation helpers for AdvCP asset generation. + +This module provides the foundational data structures and operations used +by all AdvCP asset utilities: + +- :class:`MeshData` — lightweight container for vertices and faces. +- Mesh construction: :func:`box_mesh`, :func:`subdivide_midpoint`, + :func:`advshape_template_mesh`. +- Mesh I/O: :func:`read_mesh`, :func:`read_ply`, :func:`read_obj`, + :func:`write_ascii_ply`, :func:`write_mesh`. +- Mesh transforms: :func:`normalize_bottom_center`, + :func:`scale_to_dimensions`, :func:`copy_or_generate_mesh`. +- Divide-index generation and I/O: :func:`generate_divide_indices`, + :func:`dump_divide_pickle`, :func:`load_divide_pickle`. +- Validation: :func:`validate_mesh`, :func:`validate_mesh_frame_and_scale`, + :func:`validate_divide_indices`. +- Perturbation I/O: :func:`save_perturbation`, :func:`load_perturbation`. +- Blueprint dimension lookup: :func:`blueprint_dimensions_m`, + :func:`parse_dimensions_arg`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import pickle +from pathlib import Path +import struct +from typing import Any, Iterable + +import numpy as np +import numpy.typing as npt + + +_BLUEPRINT_DIMENSIONS_M = { + "vehicle.tesla.model3": (4.30, 1.91, 1.26), + "vehicle.audi.a2": (3.70, 1.70, 1.55), + "vehicle.lincoln.mkz_2017": (4.93, 1.86, 1.48), + "vehicle.mercedes.coupe_2020": (4.69, 1.83, 1.40), + "vehicle.dodge.charger_2020": (5.10, 1.90, 1.50), +} +_DEFAULT_DIMENSIONS_M = (4.30, 1.91, 1.26) + + +@dataclass(frozen=True) +class MeshData: + """Lightweight container for a triangle mesh. + + Attributes + ---------- + vertices : ndarray of float64, shape (N, 3) + Vertex coordinates in 3D space. + faces : ndarray of int32, shape (M, 3) + Triangle face indices into the vertex array. + """ + + vertices: npt.NDArray[np.float64] + faces: npt.NDArray[np.int32] + + +def blueprint_dimensions_m(blueprint: str | None) -> tuple[float, float, float]: + """Return the (length, width, height) in meters for a CARLA blueprint. + + Parameters + ---------- + blueprint : str or None + CARLA blueprint identifier (e.g. ``"vehicle.tesla.model3"``). + When ``None`` or unknown, default Tesla Model 3 dimensions are + returned. + + Returns + ------- + tuple of float + ``(length, width, height)`` in meters. + """ + if blueprint is None: + return _DEFAULT_DIMENSIONS_M + return _BLUEPRINT_DIMENSIONS_M.get(blueprint, _DEFAULT_DIMENSIONS_M) + + +def parse_dimensions_arg(raw_dimensions: Iterable[float] | None) -> tuple[float, float, float] | None: + """Parse and validate a user-supplied (length, width, height) triple. + + Parameters + ---------- + raw_dimensions : iterable of float or None + Raw dimension values, typically from a CLI argument. + + Returns + ------- + tuple of float or None + ``(length, width, height)`` when *raw_dimensions* is not ``None``, + otherwise ``None``. + + Raises + ------ + ValueError + If the iterable does not contain exactly 3 values, or if any + value is non-positive. + """ + if raw_dimensions is None: + return None + dims = tuple(float(value) for value in raw_dimensions) + if len(dims) != 3: + raise ValueError("Dimensions must contain exactly 3 floats: length width height.") + if any(value <= 0.0 for value in dims): + raise ValueError(f"Dimensions must be positive, got {dims}.") + return dims + + +def box_mesh(length: float, width: float, height: float) -> MeshData: + """Build an axis-aligned box mesh with the given dimensions. + + The box is centred in XY and sits on the Z=0 plane (bottom face at + Z=0, top face at Z=*height*). + + Parameters + ---------- + length : float + Size along the X axis (meters). + width : float + Size along the Y axis (meters). + height : float + Size along the Z axis (meters). + + Returns + ------- + MeshData + An 8-vertex, 12-face box mesh. + """ + x0, x1 = -length / 2.0, length / 2.0 + y0, y1 = -width / 2.0, width / 2.0 + z0, z1 = 0.0, height + vertices = np.asarray( + [ + [x0, y0, z0], + [x1, y0, z0], + [x1, y1, z0], + [x0, y1, z0], + [x0, y0, z1], + [x1, y0, z1], + [x1, y1, z1], + [x0, y1, z1], + ], + dtype=np.float64, + ) + faces = np.asarray( + [ + [0, 1, 2], + [0, 2, 3], # bottom + [4, 6, 5], + [4, 7, 6], # top + [0, 5, 1], + [0, 4, 5], # -y + [1, 6, 2], + [1, 5, 6], # +x + [2, 7, 3], + [2, 6, 7], # +y + [3, 4, 0], + [3, 7, 4], # -x + ], + dtype=np.int32, + ) + return MeshData(vertices=vertices, faces=faces) + + +def normalize_bottom_center(mesh: MeshData) -> MeshData: + """Translate the mesh so its bottom face sits on Z=0 and its XY + centroid is at the origin. + + Parameters + ---------- + mesh : MeshData + Input mesh. + + Returns + ------- + MeshData + A new mesh with the bottom-centre coordinate frame. + """ + vertices = np.asarray(mesh.vertices, dtype=np.float64).copy() + mins = vertices.min(axis=0) + maxs = vertices.max(axis=0) + center_xy = (mins[:2] + maxs[:2]) / 2.0 + vertices[:, 0] -= center_xy[0] + vertices[:, 1] -= center_xy[1] + vertices[:, 2] -= mins[2] + return MeshData(vertices=vertices, faces=np.asarray(mesh.faces, dtype=np.int32)) + + +def scale_to_dimensions(mesh: MeshData, dimensions: tuple[float, float, float], preserve_aspect: bool = False) -> MeshData: + """Scale a mesh to the target (length, width, height). + + The mesh is first normalised to a bottom-centre frame, then scaled + along each axis independently (or uniformly when *preserve_aspect* + is ``True``). + + Parameters + ---------- + mesh : MeshData + Input mesh. + dimensions : tuple of float + Target ``(length, width, height)`` in meters. + preserve_aspect : bool, optional + If ``True``, the smallest axis-aligned scale factor is applied + uniformly to all three axes, preserving the original proportions. + Defaults to ``False``. + + Returns + ------- + MeshData + Scaled mesh in bottom-centre frame. + + Raises + ------ + ValueError + If the mesh bounding box is degenerate (any axis span <= 1e-9). + """ + vertices = np.asarray(mesh.vertices, dtype=np.float64).copy() + mins = vertices.min(axis=0) + maxs = vertices.max(axis=0) + size = maxs - mins + if np.any(size <= 1e-9): + raise ValueError("Cannot scale mesh with degenerate bounding-box size.") + target = np.asarray(dimensions, dtype=np.float64) + if preserve_aspect: + scale = float(np.min(target / size)) + scales = np.asarray([scale, scale, scale], dtype=np.float64) + else: + scales = target / size + vertices = (vertices - mins) * scales + mins + return normalize_bottom_center(MeshData(vertices=vertices, faces=np.asarray(mesh.faces, dtype=np.int32))) + + +def subdivide_midpoint(mesh: MeshData, levels: int) -> MeshData: + """Subdivide a triangle mesh using midpoint insertion. + + Each subdivision level splits every triangle into 4 smaller + triangles by inserting a vertex at the midpoint of each edge. + Shared edges reuse the same midpoint vertex. + + Parameters + ---------- + mesh : MeshData + Input mesh. + levels : int + Number of subdivision passes. ``0`` returns the mesh unchanged. + + Returns + ------- + MeshData + Subdivided mesh with increased vertex and face density. + """ + if levels <= 0: + return mesh + + vertices = [tuple(vertex) for vertex in np.asarray(mesh.vertices, dtype=np.float64)] + faces = [tuple(int(index) for index in face) for face in np.asarray(mesh.faces, dtype=np.int32)] + + for _ in range(levels): + edge_midpoint_cache: dict[tuple[int, int], int] = {} + new_faces: list[tuple[int, int, int]] = [] + + def midpoint_index(a: int, b: int) -> int: + key = (a, b) if a <= b else (b, a) + if key in edge_midpoint_cache: + return edge_midpoint_cache[key] + midpoint = (np.asarray(vertices[a], dtype=np.float64) + np.asarray(vertices[b], dtype=np.float64)) / 2.0 + vertices.append((float(midpoint[0]), float(midpoint[1]), float(midpoint[2]))) + idx = len(vertices) - 1 + edge_midpoint_cache[key] = idx + return idx + + for i0, i1, i2 in faces: + m01 = midpoint_index(i0, i1) + m12 = midpoint_index(i1, i2) + m20 = midpoint_index(i2, i0) + new_faces.extend( + [ + (i0, m01, m20), + (i1, m12, m01), + (i2, m20, m12), + (m01, m12, m20), + ] + ) + faces = new_faces + + return MeshData(vertices=np.asarray(vertices, dtype=np.float64), faces=np.asarray(faces, dtype=np.int32)) + + +def advshape_template_mesh() -> MeshData: + """Return the default AdvCP adversarial-shape template mesh. + + The template is a 4.9 x 2.5 x 2.0 meter box subdivided twice via + midpoint insertion, producing a mesh with approximately 98 vertices + and 192 faces. This provides enough geometric resolution for + per-vertex adversarial perturbations. + + Returns + ------- + MeshData + The AdvCP template mesh. + """ + template = box_mesh(4.9, 2.5, 2.0) + return subdivide_midpoint(template, levels=2) + + +def generate_divide_indices(vertices: npt.NDArray[np.float64], mode: str) -> list[npt.NDArray[np.int32]]: + """Generate vertex-group indices for spoofing or removal attacks. + + The mesh bounding box is partitioned into spatial regions. Each + region is defined by thresholding vertex coordinates against the + bounding-box extents and midpoints. The number and layout of groups + depends on *mode*: + + - ``"spoof"`` — 8 groups (extremal faces and quadrants). + - ``"remove"`` — 10 groups (finer spatial partitioning). + + Parameters + ---------- + vertices : ndarray of float64, shape (N, 3) + Mesh vertex coordinates. + mode : {"spoof", "remove"} + Target divide mode. + + Returns + ------- + list of ndarray of int32 + Each element is a 1-D array of vertex indices belonging to one + spatial group. Empty groups are replaced with all vertex indices + as a fallback. + + Raises + ------ + ValueError + If *mode* is not one of ``"spoof"`` or ``"remove"``. + """ + mode_normalized = mode.strip().lower() + if mode_normalized not in {"spoof", "remove"}: + raise ValueError(f"Unsupported divide mode '{mode}'. Expected one of: spoof, remove.") + + coords = np.asarray(vertices, dtype=np.float64) + mins = coords.min(axis=0) + maxs = coords.max(axis=0) + mids = (mins + maxs) / 2.0 + eps = np.maximum((maxs - mins) * 0.02, 1e-3) + + x, y, z = coords[:, 0], coords[:, 1], coords[:, 2] + groups: list[npt.NDArray[np.int32]] + + if mode_normalized == "spoof": + groups = [ + np.argwhere(x >= maxs[0] - eps[0]).reshape(-1), + np.argwhere(x <= mins[0] + eps[0]).reshape(-1), + np.argwhere(y >= maxs[1] - eps[1]).reshape(-1), + np.argwhere(y <= mins[1] + eps[1]).reshape(-1), + np.argwhere(z >= maxs[2] - eps[2]).reshape(-1), + np.argwhere(z <= mins[2] + eps[2]).reshape(-1), + np.argwhere(np.logical_and(x >= mids[0], z >= mids[2])).reshape(-1), + np.argwhere(np.logical_and(x < mids[0], z >= mids[2])).reshape(-1), + ] + else: + groups = [ + np.argwhere(x >= maxs[0] - eps[0]).reshape(-1), + np.argwhere(x <= mins[0] + eps[0]).reshape(-1), + np.argwhere(np.logical_and(x >= mids[0], y >= maxs[1] - eps[1])).reshape(-1), + np.argwhere(np.logical_and(x < mids[0], y >= maxs[1] - eps[1])).reshape(-1), + np.argwhere(np.logical_and(x >= mids[0], y <= mins[1] + eps[1])).reshape(-1), + np.argwhere(np.logical_and(x < mids[0], y <= mins[1] + eps[1])).reshape(-1), + np.argwhere(np.logical_and(x >= mids[0], z >= maxs[2] - eps[2])).reshape(-1), + np.argwhere(np.logical_and(x < mids[0], z >= maxs[2] - eps[2])).reshape(-1), + np.argwhere(np.logical_and(x >= mids[0], z <= mins[2] + eps[2])).reshape(-1), + np.argwhere(np.logical_and(x < mids[0], z <= mins[2] + eps[2])).reshape(-1), + ] + + all_indices = np.arange(coords.shape[0], dtype=np.int32) + normalized_groups: list[npt.NDArray[np.int32]] = [] + for group in groups: + if group.size == 0: + normalized_groups.append(all_indices.copy()) + else: + normalized_groups.append(np.asarray(group, dtype=np.int32)) + return normalized_groups + + +def dump_divide_pickle(indices: list[npt.NDArray[np.int32]], output_path: Path) -> None: + """Serialize a list of vertex-index groups to a pickle file. + + The parent directory is created automatically if it does not exist. + + Parameters + ---------- + indices : list of ndarray of int32 + Vertex-group index arrays. + output_path : Path + Destination ``.pkl`` path. + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + payload = [np.asarray(group, dtype=np.int32) for group in indices] + with output_path.open("wb") as handle: + pickle.dump(payload, handle) + + +def load_divide_pickle(path: Path) -> list[npt.NDArray[np.int32]]: + """Load a list of vertex-index groups from a pickle file. + + Parameters + ---------- + path : Path + Source ``.pkl`` path. + + Returns + ------- + list of ndarray of int32 + Vertex-group index arrays. + + Raises + ------ + ValueError + If the loaded object is not a ``list``. + """ + with path.open("rb") as handle: + raw = pickle.load(handle) + if not isinstance(raw, list): + raise ValueError(f"Mesh divide asset must be a list, got {type(raw)!r}.") + return [np.asarray(group, dtype=np.int32).reshape(-1) for group in raw] + + +def validate_divide_indices(indices: list[npt.NDArray[np.int32]], vertex_count: int, name: str) -> None: + """Validate a list of vertex-index groups. + + Checks that every group is a non-empty 1-D array whose entries are + valid indices into a vertex array of size *vertex_count*. + + Parameters + ---------- + indices : list of ndarray of int32 + Vertex-group index arrays. + vertex_count : int + Number of vertices in the source mesh. + name : str + Human-readable name for error messages. + + Raises + ------ + ValueError + If any group is empty, not 1-D, or contains out-of-bounds indices. + """ + if len(indices) == 0: + raise ValueError(f"{name} must contain at least one mesh index group.") + for group_index, group in enumerate(indices): + if group.ndim != 1: + raise ValueError(f"{name}[{group_index}] must be a 1-D index array.") + if group.size == 0: + raise ValueError(f"{name}[{group_index}] is empty.") + if np.any(group < 0) or np.any(group >= vertex_count): + min_index = int(group.min()) if group.size else -1 + max_index = int(group.max()) if group.size else -1 + raise ValueError( + f"{name}[{group_index}] has invalid vertex indices: min={min_index}, max={max_index}, vertex_count={vertex_count}." + ) + + +def write_ascii_ply(mesh: MeshData, output_path: Path) -> None: + """Write a mesh to an ASCII PLY file. + + The parent directory is created automatically if it does not exist. + Vertices are stored as ``float`` properties and faces as + ``uchar int`` lists. + + Parameters + ---------- + mesh : MeshData + Mesh to write. + output_path : Path + Destination ``.ply`` path. + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + vertices = np.asarray(mesh.vertices, dtype=np.float64) + faces = np.asarray(mesh.faces, dtype=np.int32) + + with output_path.open("w", encoding="utf-8") as handle: + handle.write("ply\n") + handle.write("format ascii 1.0\n") + handle.write(f"element vertex {vertices.shape[0]}\n") + handle.write("property float x\n") + handle.write("property float y\n") + handle.write("property float z\n") + handle.write(f"element face {faces.shape[0]}\n") + handle.write("property list uchar int vertex_indices\n") + handle.write("end_header\n") + for x, y, z in vertices: + handle.write(f"{x:.8f} {y:.8f} {z:.8f}\n") + for i0, i1, i2 in faces: + handle.write(f"3 {int(i0)} {int(i1)} {int(i2)}\n") + + +def read_mesh(path: Path) -> MeshData: + """Read a mesh from a PLY or OBJ file. + + The format is inferred from the file extension. + + Parameters + ---------- + path : Path + Source file path (``.ply`` or ``.obj``). + + Returns + ------- + MeshData + Parsed mesh. + + Raises + ------ + ValueError + If the file extension is not ``.ply`` or ``.obj``. + """ + suffix = path.suffix.lower() + if suffix == ".ply": + return read_ply(path) + if suffix == ".obj": + return read_obj(path) + raise ValueError(f"Unsupported mesh format '{suffix}'. Expected .ply or .obj.") + + +def read_obj(path: Path) -> MeshData: + """Read a Wavefront OBJ file into a MeshData. + + Supports ``v`` (vertex) and ``f`` (face) lines. Face entries with + texture/normal indices (``v/t/n`` or ``v//n``) are handled by + extracting only the vertex index. Polygonal faces with more than 3 + vertices are triangulated via fan triangulation. + + Parameters + ---------- + path : Path + Source ``.obj`` path. + + Returns + ------- + MeshData + Parsed mesh. + + Raises + ------ + ValueError + If the mesh fails validation after parsing. + """ + vertices: list[tuple[float, float, float]] = [] + faces: list[tuple[int, int, int]] = [] + with path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("v "): + _, x, y, z, *_ = line.split() + vertices.append((float(x), float(y), float(z))) + elif line.startswith("f "): + parts = line.split()[1:] + if len(parts) < 3: + continue + raw_indices = [] + for part in parts: + token = part.split("/")[0] + raw_indices.append(int(token) - 1) + for idx in range(1, len(raw_indices) - 1): + faces.append((raw_indices[0], raw_indices[idx], raw_indices[idx + 1])) + mesh = MeshData(vertices=np.asarray(vertices, dtype=np.float64), faces=np.asarray(faces, dtype=np.int32)) + validate_mesh(mesh, f"OBJ mesh '{path}'") + return mesh + + +def _parse_ply_header(path: Path) -> tuple[list[str], str, int, int, int]: + """Parse the header of a PLY file. + + Reads header lines until ``end_header``, then returns the header + metadata and the byte offset where vertex data begins. + + Parameters + ---------- + path : Path + Source ``.ply`` path. + + Returns + ------- + tuple + ``(header_lines, format_line, vertex_count, face_count, data_offset)``. + + Raises + ------ + ValueError + If the file does not start with ``ply``, does not declare a + format, or ends before ``end_header``. + """ + header_lines: list[str] = [] + with path.open("rb") as handle: + while True: + line_bytes = handle.readline() + if not line_bytes: + raise ValueError(f"PLY file '{path}' ended before 'end_header'.") + line = line_bytes.decode("ascii", errors="strict").strip() + header_lines.append(line) + if line == "end_header": + break + + data_offset = handle.tell() + + if not header_lines or header_lines[0] != "ply": + raise ValueError(f"File '{path}' is not a valid PLY file (missing 'ply' magic).") + + format_line = next((line for line in header_lines if line.startswith("format ")), None) + if format_line is None: + raise ValueError(f"PLY file '{path}' does not declare a format.") + + vertex_count = 0 + face_count = 0 + for line in header_lines: + if line.startswith("element vertex "): + vertex_count = int(line.split()[-1]) + elif line.startswith("element face "): + face_count = int(line.split()[-1]) + return header_lines, format_line, vertex_count, face_count, data_offset + + +def read_ply(path: Path) -> MeshData: + """Read a PLY file (ASCII or binary little-endian) into a MeshData. + + Parameters + ---------- + path : Path + Source ``.ply`` path. + + Returns + ------- + MeshData + Parsed mesh. + + Raises + ------ + ValueError + If the file has no vertices/faces, uses an unsupported format, + or fails validation. + """ + header_lines, format_line, vertex_count, face_count, data_offset = _parse_ply_header(path) + if vertex_count <= 0 or face_count <= 0: + raise ValueError(f"PLY file '{path}' must contain non-empty vertex and face elements.") + + if format_line.startswith("format ascii"): + return _read_ascii_ply(path, vertex_count, face_count, header_lines) + if format_line.startswith("format binary_little_endian"): + return _read_binary_ply(path, vertex_count, face_count, data_offset) + raise ValueError(f"Unsupported PLY format in '{path}': {format_line}.") + + +def _read_ascii_ply(path: Path, vertex_count: int, face_count: int, header_lines: list[str]) -> MeshData: + """Read an ASCII-format PLY file body. + + Parameters + ---------- + path : Path + Source ``.ply`` path (used for error messages only). + vertex_count : int + Number of vertices declared in the header. + face_count : int + Number of faces declared in the header. + header_lines : list of str + Parsed header lines (used to skip past the header). + + Returns + ------- + MeshData + Parsed mesh. + """ + with path.open("r", encoding="utf-8") as handle: + for line in handle: + if line.strip() == "end_header": + break + + vertices: list[tuple[float, float, float]] = [] + for _ in range(vertex_count): + parts = handle.readline().split() + if len(parts) < 3: + raise ValueError(f"Malformed vertex entry in '{path}'.") + vertices.append((float(parts[0]), float(parts[1]), float(parts[2]))) + + faces: list[tuple[int, int, int]] = [] + for _ in range(face_count): + parts = handle.readline().split() + if len(parts) < 4: + raise ValueError(f"Malformed face entry in '{path}'.") + n = int(parts[0]) + if n < 3: + continue + raw_indices = [int(token) for token in parts[1 : n + 1]] + for idx in range(1, len(raw_indices) - 1): + faces.append((raw_indices[0], raw_indices[idx], raw_indices[idx + 1])) + + mesh = MeshData(vertices=np.asarray(vertices, dtype=np.float64), faces=np.asarray(faces, dtype=np.int32)) + validate_mesh(mesh, f"PLY mesh '{path}'") + return mesh + + +def _read_binary_ply(path: Path, vertex_count: int, face_count: int, data_offset: int) -> MeshData: + """Read a binary little-endian PLY file body. + + Vertices are expected as three consecutive ``double`` values per + vertex. Faces use a 1-byte vertex count followed by ``N * 4`` bytes + of ``uint32`` indices. + + Parameters + ---------- + path : Path + Source ``.ply`` path (used for error messages only). + vertex_count : int + Number of vertices declared in the header. + face_count : int + Number of faces declared in the header. + data_offset : int + Byte offset where vertex data begins (after ``end_header``). + + Returns + ------- + MeshData + Parsed mesh. + """ + vertices = np.empty((vertex_count, 3), dtype=np.float64) + faces: list[tuple[int, int, int]] = [] + + with path.open("rb") as handle: + handle.seek(data_offset) + vertex_struct = struct.Struct(" None: + """Validate the structural integrity of a mesh. + + Checks that vertices and faces have the correct shape, that there + are enough vertices/faces to form a valid mesh, that all vertex + coordinates are finite, and that face indices are within bounds. + + Parameters + ---------- + mesh : MeshData + Mesh to validate. + name : str + Human-readable name for error messages. + + Raises + ------ + ValueError + If any structural check fails. + """ + vertices = np.asarray(mesh.vertices, dtype=np.float64) + faces = np.asarray(mesh.faces, dtype=np.int32) + + if vertices.ndim != 2 or vertices.shape[1] != 3: + raise ValueError(f"{name} vertices must have shape (N, 3), got {vertices.shape}.") + if faces.ndim != 2 or faces.shape[1] != 3: + raise ValueError(f"{name} faces must have shape (M, 3), got {faces.shape}.") + if vertices.shape[0] < 4: + raise ValueError(f"{name} has too few vertices ({vertices.shape[0]}).") + if faces.shape[0] < 4: + raise ValueError(f"{name} has too few faces ({faces.shape[0]}).") + if np.any(~np.isfinite(vertices)): + raise ValueError(f"{name} contains non-finite vertices.") + if np.any(faces < 0) or np.any(faces >= vertices.shape[0]): + raise ValueError(f"{name} face indices are out of bounds for {vertices.shape[0]} vertices.") + + +def validate_mesh_frame_and_scale(mesh: MeshData, name: str) -> None: + """Validate that a mesh uses a compatible coordinate frame and scale. + + Checks that the bounding box is not degenerate, that the mesh is + not too large (incompatible scale), that the Z-origin is near the + bottom of the mesh (bottom-centred frame), and that the XY centroid + is near the origin. + + Parameters + ---------- + mesh : MeshData + Mesh to validate. + name : str + Human-readable name for error messages. + + Raises + ------ + ValueError + If any frame or scale check fails. + """ + vertices = np.asarray(mesh.vertices, dtype=np.float64) + mins = vertices.min(axis=0) + maxs = vertices.max(axis=0) + size = maxs - mins + + if np.any(size <= 0.05): + raise ValueError(f"{name} bounding-box is degenerate (size={size.tolist()}).") + if np.any(size > 20.0): + raise ValueError(f"{name} appears to use an incompatible scale (size={size.tolist()} meters).") + if abs(float(mins[2])) > 2.0: + raise ValueError(f"{name} z-origin is incompatible with bottom-centered frame (min_z={mins[2]:.3f}).") + center_xy = (mins[:2] + maxs[:2]) / 2.0 + if float(np.linalg.norm(center_xy)) > 5.0: + raise ValueError( + f"{name} XY center is far from origin (center=({center_xy[0]:.3f}, {center_xy[1]:.3f})); expected near bottom-center frame." + ) + + +def save_perturbation(path: Path, perturbation: npt.NDArray[np.float64]) -> None: + """Save a per-vertex perturbation tensor to a ``.npy`` file. + + The parent directory is created automatically if it does not exist. + The array is cast to ``float32`` before saving. + + Parameters + ---------- + path : Path + Destination ``.npy`` path. + perturbation : ndarray of float64 or float32, shape (N, 3) + Per-vertex displacement vectors. + """ + path.parent.mkdir(parents=True, exist_ok=True) + np.save(path, np.asarray(perturbation, dtype=np.float32)) + + +def load_perturbation(path: Path) -> npt.NDArray[np.float32]: + """Load a per-vertex perturbation tensor from a ``.npy`` file. + + Parameters + ---------- + path : Path + Source ``.npy`` path. + + Returns + ------- + ndarray of float32, shape (N, 3) + Per-vertex displacement vectors. + + Raises + ------ + ValueError + If the array does not have shape (N, 3) or contains non-finite + values. + """ + perturbation = np.load(path) + array = np.asarray(perturbation, dtype=np.float32) + if array.ndim != 2 or array.shape[1] != 3: + raise ValueError(f"Perturbation asset '{path}' must have shape (N, 3), got {array.shape}.") + if np.any(~np.isfinite(array)): + raise ValueError(f"Perturbation asset '{path}' contains non-finite values.") + return array + + +def write_mesh(path: Path, mesh: MeshData) -> None: + """Write a mesh to a PLY file (convenience wrapper). + + Delegates to :func:`write_ascii_ply`. + + Parameters + ---------- + path : Path + Destination ``.ply`` path. + mesh : MeshData + Mesh to write. + """ + write_ascii_ply(mesh, path) + + +def copy_or_generate_mesh(mesh_input_path: Path | None, dimensions: tuple[float, float, float], preserve_aspect: bool) -> MeshData: + """Return a mesh for the given dimensions, either by reading an + external file or by constructing a simple box. + + When *mesh_input_path* is ``None``, a box mesh with the target + dimensions is returned. Otherwise the external mesh is read, + normalised to a bottom-centre frame, and scaled to the target + dimensions. + + Parameters + ---------- + mesh_input_path : Path or None + Optional path to an external ``.ply`` or ``.obj`` mesh. + dimensions : tuple of float + Target ``(length, width, height)`` in meters. + preserve_aspect : bool + Whether to preserve the source mesh's aspect ratio during + scaling. + + Returns + ------- + MeshData + The resulting mesh in bottom-centre frame. + """ + if mesh_input_path is None: + return box_mesh(*dimensions) + mesh = read_mesh(mesh_input_path) + mesh = normalize_bottom_center(mesh) + return scale_to_dimensions(mesh, dimensions, preserve_aspect=preserve_aspect) + + +def dump_generation_metadata(path: Path, metadata: dict[str, Any]) -> None: + """Serialize generation metadata to a pickle file. + + The parent directory is created automatically if it does not exist. + This can be used to record the parameters used to generate an asset + (blueprint, dimensions, source file, timestamp, etc.) for + reproducibility. + + Parameters + ---------- + path : Path + Destination ``.pkl`` path. + metadata : dict + Arbitrary key-value metadata to persist. + """ + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as handle: + pickle.dump(metadata, handle) \ No newline at end of file diff --git a/opencda/core/attack/advcp/utils/generate_car_mesh.py b/opencda/core/attack/advcp/utils/generate_car_mesh.py new file mode 100644 index 00000000..b7566898 --- /dev/null +++ b/opencda/core/attack/advcp/utils/generate_car_mesh.py @@ -0,0 +1,93 @@ +""" +CLI for generating an AdvCP-compatible vehicle mesh (.ply). + +Generates a bottom-centred, axis-aligned triangle mesh that can be used +as the ``car_mesh_path`` asset in AdvCP spoofing and removal attack +configurations. + +The mesh can be constructed in two ways: + +1. **From a CARLA blueprint** — a simple box mesh is generated using the + blueprint's known dimensions (or user-supplied dimensions). +2. **From an external mesh file** — a ``.ply`` or ``.obj`` file is loaded, + normalised to a bottom-centre frame, and scaled to the target dimensions. + +Usage examples +-------------- + +Generate a mesh for the default Tesla Model 3 blueprint:: + + python -m opencda.core.attack.advcp.utils.generate_car_mesh \\ + --output opencda/core/attack/advcp/3d_models/car_mesh_0200.ply + +Generate a mesh for a specific blueprint with custom dimensions:: + + python -m opencda.core.attack.advcp.utils.generate_car_mesh \\ + --vehicle-blueprint vehicle.audi.a2 \\ + --dimensions 3.7 1.7 1.55 \\ + --output /tmp/car_mesh_audi.ply + +Generate a mesh from an external .obj file, preserving aspect ratio:: + + python -m opencda.core.attack.advcp.utils.generate_car_mesh \\ + --mesh-input /path/to/vehicle.obj \\ + --dimensions 4.5 2.0 1.6 \\ + --preserve-aspect \\ + --output /tmp/car_mesh_external.ply +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from opencda.core.attack.advcp.utils.asset_utils import ( + blueprint_dimensions_m, + copy_or_generate_mesh, + parse_dimensions_arg, + write_mesh, +) + + +def _build_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser for the car-mesh generation script. + + Returns + ------- + argparse.ArgumentParser + Configured parser with arguments for blueprint, mesh input, + dimensions, aspect-preservation flag, and output path. + """ + parser = argparse.ArgumentParser(description="Generate AdvCP-compatible vehicle mesh (.ply).") + parser.add_argument("--vehicle-blueprint", type=str, default="vehicle.tesla.model3", help="CARLA blueprint id.") + parser.add_argument("--mesh-input", type=Path, default=None, help="Optional external .ply/.obj mesh input.") + parser.add_argument( + "--dimensions", + type=float, + nargs=3, + metavar=("LENGTH", "WIDTH", "HEIGHT"), + default=None, + help="Vehicle dimensions in meters. Defaults to blueprint dimensions.", + ) + parser.add_argument("--preserve-aspect", action="store_true", help="Preserve source mesh proportions while scaling.") + parser.add_argument("--output", type=Path, required=True, help="Output .ply path.") + return parser + + +def main() -> None: + """Entry point for the car-mesh generation CLI. + + Parses arguments, resolves the target dimensions (from CLI override + or blueprint lookup), generates or copies the mesh, writes the + result to the output path, and prints a summary. + """ + args = _build_parser().parse_args() + dimensions = parse_dimensions_arg(args.dimensions) or blueprint_dimensions_m(args.vehicle_blueprint) + mesh = copy_or_generate_mesh(args.mesh_input, dimensions=dimensions, preserve_aspect=args.preserve_aspect) + write_mesh(args.output, mesh) + print(f"Generated car mesh: {args.output}") + print(f"Dimensions (m): length={dimensions[0]:.3f}, width={dimensions[1]:.3f}, height={dimensions[2]:.3f}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/opencda/core/attack/advcp/utils/generate_mesh_divide.py b/opencda/core/attack/advcp/utils/generate_mesh_divide.py new file mode 100644 index 00000000..03d8e81b --- /dev/null +++ b/opencda/core/attack/advcp/utils/generate_mesh_divide.py @@ -0,0 +1,71 @@ +""" +CLI for generating AdvCP mesh-division metadata (.pkl). + +Generates vertex-group index arrays that partition a vehicle mesh into +spatial regions. These index groups are used by the AdvCP attack +pipeline to apply per-region adversarial perturbations during spoofing +or removal attacks. + +Two modes are supported: + +- ``spoof`` — produces 8 vertex groups (extremal faces and quadrants). +- ``remove`` — produces 10 vertex groups (finer spatial partitioning). + +Usage examples +-------------- + +Generate spoof mesh-divide metadata:: + + python -m opencda.core.attack.advcp.utils.generate_mesh_divide \\ + --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + --mode spoof \\ + --output opencda/core/attack/advcp/3d_models/spoof/car_mesh_divide.pkl + +Generate removal mesh-divide metadata:: + + python -m opencda.core.attack.advcp.utils.generate_mesh_divide \\ + --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + --mode remove \\ + --output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from opencda.core.attack.advcp.utils.asset_utils import dump_divide_pickle, generate_divide_indices, read_mesh + + +def _build_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser for the mesh-divide generation script. + + Returns + ------- + argparse.ArgumentParser + Configured parser with arguments for mesh path, divide mode, + and output path. + """ + parser = argparse.ArgumentParser(description="Generate AdvCP mesh-division metadata (.pkl).") + parser.add_argument("--mesh", type=Path, required=True, help="Input mesh path (.ply or .obj).") + parser.add_argument("--mode", choices=("spoof", "remove"), required=True, help="Target divide mode.") + parser.add_argument("--output", type=Path, required=True, help="Output .pkl path.") + return parser + + +def main() -> None: + """Entry point for the mesh-divide generation CLI. + + Parses arguments, reads the input mesh, generates vertex-group + indices for the specified mode, serialises them to a pickle file, + and prints a summary. + """ + args = _build_parser().parse_args() + mesh = read_mesh(args.mesh) + divide = generate_divide_indices(mesh.vertices, mode=args.mode) + dump_divide_pickle(divide, args.output) + print(f"Generated {args.mode} mesh divide: {args.output} ({len(divide)} groups)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py b/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py new file mode 100644 index 00000000..ee532957 --- /dev/null +++ b/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py @@ -0,0 +1,252 @@ +""" +CLI for generating AdvCP removal mesh-division and adversarial-shape perturbation assets. + +Supports generation of: + +- ``mesh_divide.pkl`` (removal mode) — vertex-group indices derived from an + AdvCP-shaped template mesh, or from a user-supplied mesh. +- ``mesh_perturb.npy`` — optional zero-initialised or random perturbation + tensor that can be used as a warm-start or placeholder when ``advshape: true``. + +Usage examples +-------------- + +Generate removal mesh-divide from an existing car mesh:: + + python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + --mode divide \\ + --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + --output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl + +Generate removal mesh-divide from the built-in AdvCP template mesh:: + + python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + --mode divide \\ + --output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl + +Generate a zero-initialised perturbation tensor:: + + python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + --mode perturb \\ + --output opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy + +Generate a random perturbation tensor (for testing / warm-start):: + + python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + --mode perturb \\ + --random \\ + --seed 42 \\ + --output opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy + +Generate both divide and perturb in one call:: + + python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + --mode both \\ + --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + --divide-output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl \\ + --perturb-output opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np + +from opencda.core.attack.advcp.utils.asset_utils import ( + MeshData, + advshape_template_mesh, + dump_divide_pickle, + generate_divide_indices, + read_mesh, + save_perturbation, +) + + +def _build_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser for the removal-asset generation script. + + Returns + ------- + argparse.ArgumentParser + Configured parser with arguments for mode selection, mesh input, + output paths, and perturbation options (randomisation, seed, + scale). + """ + parser = argparse.ArgumentParser( + description="Generate AdvCP removal assets (mesh-divide .pkl and/or perturbation .npy)." + ) + parser.add_argument( + "--mode", + choices=("divide", "perturb", "both"), + required=True, + help=( + "'divide' – generate removal mesh-divide metadata (.pkl). " + "'perturb' – generate adversarial-shape perturbation (.npy). " + "'both' – generate both." + ), + ) + parser.add_argument( + "--mesh", + type=Path, + default=None, + help=( + "Input mesh path (.ply or .obj). When omitted, the built-in AdvCP " + "template mesh (subdivided box) is used." + ), + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help=( + "Output path. Used by 'divide' and 'perturb' modes when a single output " + "is produced. For 'both' mode, use --divide-output and --perturb-output instead." + ), + ) + parser.add_argument( + "--divide-output", + type=Path, + default=None, + help="Output path for removal mesh-divide .pkl (only used with --mode both).", + ) + parser.add_argument( + "--perturb-output", + type=Path, + default=None, + help="Output path for perturbation .npy (only used with --mode both).", + ) + parser.add_argument( + "--random", + action="store_true", + help="Generate a random perturbation instead of zeros.", + ) + parser.add_argument( + "--seed", + type=int, + default=None, + help="Random seed for deterministic perturbation generation.", + ) + parser.add_argument( + "--perturb-scale", + type=float, + default=0.5, + help="Maximum per-vertex displacement (meters) when --random is set. Default: 0.5.", + ) + return parser + + +def _resolve_mesh(mesh_input: Path | None) -> MeshData: + """Read or build the source mesh for removal divide generation. + + Parameters + ---------- + mesh_input : Path or None + Optional path to an external ``.ply`` or ``.obj`` mesh. When + ``None``, the built-in AdvCP template mesh is returned. + + Returns + ------- + MeshData + The source mesh to use for divide-index or perturbation generation. + """ + if mesh_input is not None: + return read_mesh(mesh_input) + return advshape_template_mesh() + + +def _generate_divide(mesh_input: Path | None, output: Path) -> None: + """Generate removal mesh-divide indices and write them to a pickle file. + + Parameters + ---------- + mesh_input : Path or None + Input mesh path, or ``None`` to use the built-in AdvCP template. + output : Path + Destination path for the ``.pkl`` file. + """ + mesh = _resolve_mesh(mesh_input) + divide = generate_divide_indices(mesh.vertices, mode="remove") + dump_divide_pickle(divide, output) + print(f"Generated removal mesh divide: {output} ({len(divide)} groups, {output.stat().st_size} bytes)") + + +def _generate_perturb( + mesh_input: Path | None, + output: Path, + randomize: bool, + seed: int | None, + scale: float, +) -> None: + """Generate a per-vertex perturbation tensor and write it to a ``.npy`` file. + + Parameters + ---------- + mesh_input : Path or None + Input mesh path, or ``None`` to use the built-in AdvCP template. + output : Path + Destination path for the ``.npy`` file. + randomize : bool + If ``True``, generate random perturbations in ``[-scale, scale]``. + If ``False``, generate a zero-initialised tensor. + seed : int or None + Random seed for deterministic generation (only used when + *randomize* is ``True``). + scale : float + Maximum per-vertex displacement in meters (only used when + *randomize* is ``True``). + """ + mesh = _resolve_mesh(mesh_input) + vertex_count = mesh.vertices.shape[0] + + if randomize: + rng = np.random.default_rng(seed) + perturbation = rng.uniform(-scale, scale, size=(vertex_count, 3)).astype(np.float32) + source_desc = f"random (scale={scale})" + else: + perturbation = np.zeros((vertex_count, 3), dtype=np.float32) + source_desc = "zero-initialised" + + save_perturbation(output, perturbation) + print( + f"Generated {source_desc} perturbation: {output} " + f"(shape={perturbation.shape}, {output.stat().st_size} bytes)" + ) + + +def main() -> None: + """Entry point for the removal-asset generation CLI. + + Parses arguments and dispatches to the appropriate generation + function based on the selected ``--mode``: + + - ``divide`` — generates only the removal mesh-divide ``.pkl``. + - ``perturb`` — generates only the perturbation ``.npy``. + - ``both`` — generates both assets in a single invocation. + """ + args = _build_parser().parse_args() + + if args.mode == "divide": + if args.output is None: + raise ValueError("--output is required when --mode=divide.") + _generate_divide(args.mesh, args.output) + + elif args.mode == "perturb": + if args.output is None: + raise ValueError("--output is required when --mode=perturb.") + _generate_perturb(args.mesh, args.output, args.random, args.seed, args.perturb_scale) + + elif args.mode == "both": + div_out = args.divide_output or args.output + pert_out = args.perturb_output or args.output + if div_out is None: + raise ValueError("Either --divide-output or --output must be provided when --mode=both.") + if pert_out is None: + raise ValueError("Either --perturb-output or --output must be provided when --mode=both.") + _generate_divide(args.mesh, div_out) + _generate_perturb(args.mesh, pert_out, args.random, args.seed, args.perturb_scale) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/opencda/core/attack/advcp/utils/runtime_assets.py b/opencda/core/attack/advcp/utils/runtime_assets.py new file mode 100644 index 00000000..bf428839 --- /dev/null +++ b/opencda/core/attack/advcp/utils/runtime_assets.py @@ -0,0 +1,236 @@ +""" +Runtime asset generation helper for AdvCP mesh-derived assets. + +Provides :class:`AdvCPRuntimeAssetHelper`, which is used by the AdvCP +attack pipeline to generate spoof and removal assets on demand during +simulation. This avoids requiring pre-generated ``.ply``, ``.pkl``, and +``.npy`` files to be committed to the repository. + +The helper supports two asset categories: + +1. **Spoof assets** — car mesh (``.ply``) and spoof mesh-divide (``.pkl``). +2. **Removal advshape assets** — removal mesh-divide (``.pkl``) and + optional perturbation tensor (``.npy``). + +Assets are generated once and cached on disk. Subsequent runs reuse the +cached files. The cache location is configurable via the +``asset_cache_dir`` config key. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Mapping + +import numpy as np + +from opencda.core.attack.advcp.types import AdvCPConfig +from opencda.core.attack.advcp.utils.asset_utils import ( + advshape_template_mesh, + blueprint_dimensions_m, + copy_or_generate_mesh, + dump_divide_pickle, + generate_divide_indices, + parse_dimensions_arg, + save_perturbation, + write_mesh, +) + +logger = logging.getLogger("cavise.opencda.opencda.core.attack.advcp.advcp_manager") + + +class AdvCPRuntimeAssetHelper: + """Runtime generation helper for AdvCP mesh-derived assets. + + Generates and caches the 3D assets required by the AdvCP attack + pipeline (car mesh, mesh-divide metadata, and adversarial-shape + perturbation tensors) on demand during simulation. + + Assets are written to the paths specified in the AdvCP config, or + to a configurable cache directory when ``asset_cache_dir`` is set. + Generation is skipped when the target files already exist. + + Typical usage:: + + car_mesh_path, divide_path = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) + remove_paths = AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) + """ + + @staticmethod + def _resolve_absolute(path_value: Any) -> Path: + """Resolve a config value to an absolute :class:`Path`. + + Expands the user home directory (``~``) and resolves relative + paths against the current working directory. + + Parameters + ---------- + path_value : Any + Path value from the AdvCP config (typically a ``str`` or + ``Path``). + + Returns + ------- + Path + Absolute, resolved path. + """ + path = Path(str(path_value)).expanduser() + if not path.is_absolute(): + path = (Path.cwd() / path).resolve() + return path + + @classmethod + def _resolve_generation_roots(cls, config: Mapping[str, Any]) -> tuple[Path, Path]: + """Resolve the output paths for the car mesh and spoof mesh-divide. + + When ``asset_cache_dir`` is set in the config, the paths are + derived from the cache directory and the vehicle blueprint name. + Otherwise, the paths from ``car_mesh_path`` and + ``car_mesh_divide_path`` in the config are used directly. + + Parameters + ---------- + config : Mapping + AdvCP configuration mapping. + + Returns + ------- + tuple of Path + ``(car_mesh_path, car_mesh_divide_path)``. + """ + car_mesh_path = cls._resolve_absolute(config.get("car_mesh_path")) + car_mesh_divide_path = cls._resolve_absolute(config.get("car_mesh_divide_path")) + cache_dir_value = config.get("asset_cache_dir") + if cache_dir_value is None: + return car_mesh_path, car_mesh_divide_path + + cache_dir = cls._resolve_absolute(cache_dir_value) + blueprint = str(config.get("vehicle_blueprint") or "vehicle.default").replace(".", "_") + mesh_name = f"car_mesh_{blueprint}.ply" + divide_name = f"car_mesh_divide_{blueprint}.pkl" + return cache_dir / mesh_name, cache_dir / "spoof" / divide_name + + @staticmethod + def _resolve_dimensions(config: Mapping[str, Any]) -> tuple[float, float, float]: + """Resolve the target vehicle dimensions from the AdvCP config. + + Prefers an explicit ``car_mesh_dimensions`` override. Falls back + to the blueprint dimensions from :func:`blueprint_dimensions_m`. + + Parameters + ---------- + config : Mapping + AdvCP configuration mapping. + + Returns + ------- + tuple of float + ``(length, width, height)`` in meters. + """ + dimensions_override = parse_dimensions_arg(config.get("car_mesh_dimensions")) + if dimensions_override is not None: + return dimensions_override + return blueprint_dimensions_m(config.get("vehicle_blueprint")) + + @classmethod + def ensure_spoof_assets(cls, config: AdvCPConfig) -> tuple[Path, Path]: + """Ensure that spoof assets (car mesh and mesh-divide) exist. + + If the assets already exist on disk, or if runtime generation is + disabled (``asset_runtime_generation: false``), the configured + paths are returned without modification. + + Otherwise, the car mesh is generated (from a source mesh or as a + box), the spoof mesh-divide indices are computed, and both are + written to disk. + + Parameters + ---------- + config : AdvCPConfig + Resolved AdvCP configuration. Relevant keys: + ``car_mesh_path``, ``car_mesh_divide_path``, + ``asset_cache_dir``, ``asset_runtime_generation``, + ``vehicle_blueprint``, ``car_mesh_dimensions``, + ``car_mesh_source_path``, ``car_mesh_preserve_aspect``. + + Returns + ------- + tuple of Path + ``(car_mesh_path, car_mesh_divide_path)`` — the paths to the + generated or existing assets. + """ + car_mesh_path, car_mesh_divide_path = cls._resolve_generation_roots(config) + runtime_enabled = bool(config.get("asset_runtime_generation", True)) + if (car_mesh_path.exists() and car_mesh_divide_path.exists()) or not runtime_enabled: + return car_mesh_path, car_mesh_divide_path + + dimensions = cls._resolve_dimensions(config) + source_path_value = config.get("car_mesh_source_path") + source_path = cls._resolve_absolute(source_path_value) if source_path_value else None + preserve_aspect = bool(config.get("car_mesh_preserve_aspect", True)) + + mesh = copy_or_generate_mesh(source_path, dimensions=dimensions, preserve_aspect=preserve_aspect) + write_mesh(car_mesh_path, mesh) + divide = generate_divide_indices(mesh.vertices, mode="spoof") + dump_divide_pickle(divide, car_mesh_divide_path) + logger.info( + "Generated AdvCP runtime spoof assets at mesh='%s', divide='%s'.", + car_mesh_path, + car_mesh_divide_path, + ) + return car_mesh_path, car_mesh_divide_path + + @classmethod + def ensure_remove_advshape_assets(cls, config: AdvCPConfig) -> tuple[Path, Path] | None: + """Ensure that removal advshape assets (mesh-divide and perturbation) exist. + + Generates the removal mesh-divide from the AdvCP template mesh + and, optionally, a zero-initialised perturbation tensor when + ``remove_adv_shape_generate_zero_perturb`` is ``True``. + + Assets are written to the ``remove/`` subdirectory of the + configured ``asset_cache_dir``. Returns ``None`` when runtime + generation is disabled or no cache directory is configured. + + Parameters + ---------- + config : AdvCPConfig + Resolved AdvCP configuration. Relevant keys: + ``asset_runtime_generation``, ``asset_cache_dir``, + ``remove_adv_shape_generate_zero_perturb``. + + Returns + ------- + tuple of Path or None + ``(perturb_path, divide_path)`` when assets were generated + or already exist, or ``None`` when runtime generation is + disabled or no cache directory is configured. + """ + runtime_enabled = bool(config.get("asset_runtime_generation", True)) + if not runtime_enabled: + return None + + cache_dir_value = config.get("asset_cache_dir") + if cache_dir_value is None: + return None + + cache_dir = cls._resolve_absolute(cache_dir_value) + divide_path = cache_dir / "remove" / "mesh_divide.pkl" + perturb_path = cache_dir / "remove" / "mesh_perturb.npy" + + template = advshape_template_mesh() + if not divide_path.exists(): + divide = generate_divide_indices(template.vertices, mode="remove") + dump_divide_pickle(divide, divide_path) + + if bool(config.get("remove_adv_shape_generate_zero_perturb", False)) and not perturb_path.exists(): + zero_perturb = np.zeros(template.vertices.shape, dtype=np.float32) + save_perturbation(perturb_path, zero_perturb) + + if "remove_adv_shape_divide_path" not in config: + config["remove_adv_shape_divide_path"] = str(divide_path) + if "remove_adv_shape_perturb_path" not in config and perturb_path.exists(): + config["remove_adv_shape_perturb_path"] = str(perturb_path) + + return perturb_path, divide_path \ No newline at end of file diff --git a/opencda/core/attack/advcp/utils/validate_advcp_assets.py b/opencda/core/attack/advcp/utils/validate_advcp_assets.py new file mode 100644 index 00000000..213665f5 --- /dev/null +++ b/opencda/core/attack/advcp/utils/validate_advcp_assets.py @@ -0,0 +1,377 @@ +""" +CLI for validating AdvCP 3D assets required by spoofing and removal attacks. + +Checks: + +- File existence and non-empty size. +- Mesh (.ply) format, vertex/face counts, coordinate frame, and scale. +- Spoof mesh-divide (.pkl) structure, index bounds, and compatibility with + the car mesh vertex count. +- Removal mesh-divide (.pkl) structure, index bounds, and compatibility with + the car mesh or AdvCP template mesh vertex count. +- Removal perturbation (.npy) shape, dtype, and finite values. + +Exits with code 0 when all checks pass, or 1 with actionable error messages +when any check fails. + +Usage examples +-------------- + +Validate all assets:: + + python -m opencda.core.attack.advcp.utils.validate_advcp_assets \\ + --car-mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + --spoof-divide opencda/core/attack/advcp/3d_models/spoof/car_mesh_divide.pkl \\ + --remove-divide opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl \\ + --remove-perturb opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy + +Validate only the car mesh:: + + python -m opencda.core.attack.advcp.utils.validate_advcp_assets \\ + --car-mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply + +Validate with an explicit expected vertex count:: + + python -m opencda.core.attack.advcp.utils.validate_advcp_assets \\ + --car-mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + --expected-vertices 148755 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np + +from opencda.core.attack.advcp.utils.asset_utils import ( + load_divide_pickle, + load_perturbation, + read_mesh, + validate_divide_indices, + validate_mesh, + validate_mesh_frame_and_scale, +) + + +def _build_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser for the asset validation script. + + Returns + ------- + argparse.ArgumentParser + Configured parser with arguments for each asset type (car mesh, + spoof divide, remove divide, remove perturb) and optional + expected vertex counts. + """ + parser = argparse.ArgumentParser( + description="Validate AdvCP 3D assets for spoofing and removal attacks." + ) + parser.add_argument( + "--car-mesh", + type=Path, + default=None, + help="Path to the car mesh .ply file.", + ) + parser.add_argument( + "--spoof-divide", + type=Path, + default=None, + help="Path to the spoof mesh-divide .pkl file.", + ) + parser.add_argument( + "--remove-divide", + type=Path, + default=None, + help="Path to the removal mesh-divide .pkl file.", + ) + parser.add_argument( + "--remove-perturb", + type=Path, + default=None, + help="Path to the removal perturbation .npy file.", + ) + parser.add_argument( + "--expected-vertices", + type=int, + default=None, + help=( + "Expected vertex count for the car mesh. When provided, the car mesh " + "vertex count is checked against this value. When omitted, the check " + "is skipped." + ), + ) + parser.add_argument( + "--advshape-template-vertices", + type=int, + default=None, + help=( + "Expected vertex count for the AdvCP template mesh used by removal " + "divide. When omitted, the removal divide is validated against the " + "car mesh vertex count (if --car-mesh is provided), or the check is " + "skipped." + ), + ) + return parser + + +def _check_file_exists(path: Path, label: str) -> None: + """Assert that a file exists and is non-empty. + + Parameters + ---------- + path : Path + File path to check. + label : str + Human-readable label for the file (used in error messages). + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If the file is empty (0 bytes). + """ + if not path.exists(): + raise FileNotFoundError(f"{label}: file not found at '{path}'.") + if path.stat().st_size == 0: + raise ValueError(f"{label}: file is empty (0 bytes).") + + +def _validate_car_mesh(path: Path, expected_vertices: int | None) -> int: + """Validate a car mesh ``.ply`` file. + + Checks file existence, structural integrity, coordinate frame, and + scale. Returns the vertex count for use in divide-index validation. + + Parameters + ---------- + path : Path + Path to the ``.ply`` file. + expected_vertices : int or None + Optional expected vertex count. When provided, the actual vertex + count must match exactly. + + Returns + ------- + int + The number of vertices in the mesh. + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If the file is empty, fails structural validation, frame/scale + validation, or the vertex count does not match *expected_vertices*. + """ + print(f"Validating car mesh: {path}") + _check_file_exists(path, "Car mesh") + mesh = read_mesh(path) + validate_mesh(mesh, f"Car mesh '{path}'") + validate_mesh_frame_and_scale(mesh, f"Car mesh '{path}'") + vertex_count = mesh.vertices.shape[0] + print(f" Vertices: {vertex_count}") + print(f" Faces: {mesh.faces.shape[0]}") + print(f" Bounds: x=[{mesh.vertices[:, 0].min():.3f}, {mesh.vertices[:, 0].max():.3f}], " + f"y=[{mesh.vertices[:, 1].min():.3f}, {mesh.vertices[:, 1].max():.3f}], " + f"z=[{mesh.vertices[:, 2].min():.3f}, {mesh.vertices[:, 2].max():.3f}]") + if expected_vertices is not None and vertex_count != expected_vertices: + raise ValueError( + f"Car mesh vertex count mismatch: expected {expected_vertices}, got {vertex_count}." + ) + print(" [PASS]") + return vertex_count + + +def _validate_spoof_divide(path: Path, car_vertex_count: int | None) -> None: + """Validate a spoof mesh-divide ``.pkl`` file. + + Checks file existence, that the divide contains exactly 8 groups, + and that all indices are valid for the given car mesh vertex count. + + Parameters + ---------- + path : Path + Path to the ``.pkl`` file. + car_vertex_count : int or None + Vertex count of the corresponding car mesh. When ``None``, + index-bound validation is skipped. + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If the file is empty, contains the wrong number of groups, or + has out-of-bounds indices. + """ + print(f"Validating spoof mesh divide: {path}") + _check_file_exists(path, "Spoof mesh divide") + indices = load_divide_pickle(path) + if len(indices) != 8: + raise ValueError( + f"Spoof mesh divide must contain exactly 8 index groups, got {len(indices)}." + ) + if car_vertex_count is not None: + validate_divide_indices(indices, car_vertex_count, "Spoof mesh divide") + print(f" Groups: {len(indices)}") + for i, g in enumerate(indices): + print(f" Group {i}: {g.shape[0]} indices, range [{g.min()}, {g.max()}]") + print(" [PASS]") + + +def _validate_remove_divide( + path: Path, + car_vertex_count: int | None, + template_vertex_count: int | None, +) -> None: + """Validate a removal mesh-divide ``.pkl`` file. + + Checks file existence, that the divide contains exactly 10 groups, + and that all indices are valid. The expected vertex count is + resolved from *car_vertex_count* first, then *template_vertex_count*. + + Parameters + ---------- + path : Path + Path to the ``.pkl`` file. + car_vertex_count : int or None + Vertex count of the car mesh (used when available). + template_vertex_count : int or None + Expected vertex count for the AdvCP template mesh (used as + fallback when *car_vertex_count* is ``None``). + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If the file is empty, contains the wrong number of groups, or + has out-of-bounds indices. + """ + print(f"Validating removal mesh divide: {path}") + _check_file_exists(path, "Removal mesh divide") + indices = load_divide_pickle(path) + if len(indices) != 10: + raise ValueError( + f"Removal mesh divide must contain exactly 10 index groups, got {len(indices)}." + ) + # Determine the expected vertex count for validation + expected_count = car_vertex_count or template_vertex_count + if expected_count is not None: + validate_divide_indices(indices, expected_count, "Removal mesh divide") + print(f" Groups: {len(indices)}") + for i, g in enumerate(indices): + print(f" Group {i}: {g.shape[0]} indices, range [{g.min()}, {g.max()}]") + print(" [PASS]") + + +def _validate_remove_perturb(path: Path, car_vertex_count: int | None) -> None: + """Validate a removal perturbation ``.npy`` file. + + Checks file existence, that the perturbation has shape (N, 3), + contains only finite values, and its vertex count matches the car + mesh (when *car_vertex_count* is provided). + + Parameters + ---------- + path : Path + Path to the ``.npy`` file. + car_vertex_count : int or None + Vertex count of the corresponding car mesh. When provided, the + perturbation's first dimension must match. + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If the file is empty, has the wrong shape, contains non-finite + values, or the vertex count does not match. + """ + print(f"Validating removal perturbation: {path}") + _check_file_exists(path, "Removal perturbation") + perturbation = load_perturbation(path) + print(f" Shape: {perturbation.shape}") + print(f" Dtype: {perturbation.dtype}") + print(f" Range: [{perturbation.min():.6f}, {perturbation.max():.6f}]") + if car_vertex_count is not None and perturbation.shape[0] != car_vertex_count: + raise ValueError( + f"Removal perturbation vertex count mismatch: car mesh has {car_vertex_count} " + f"vertices, perturbation has {perturbation.shape[0]}." + ) + print(" [PASS]") + + +def main() -> None: + """Entry point for the AdvCP asset validation CLI. + + Parses command-line arguments, validates each specified asset in + order (car mesh, spoof divide, removal divide, removal perturbation), + and exits with code 0 on success or code 1 with error messages on + failure. + + Validation is additive: all specified assets are checked, and + all errors are reported before exiting. + """ + args = _build_parser().parse_args() + + has_any_asset = any([ + args.car_mesh is not None, + args.spoof_divide is not None, + args.remove_divide is not None, + args.remove_perturb is not None, + ]) + if not has_any_asset: + print("No assets specified. Use --car-mesh, --spoof-divide, --remove-divide, " + "and/or --remove-perturb to select assets for validation.") + sys.exit(1) + + errors: list[str] = [] + car_vertex_count: int | None = None + + # 1. Validate car mesh (if provided) + if args.car_mesh is not None: + try: + car_vertex_count = _validate_car_mesh(args.car_mesh, args.expected_vertices) + except (FileNotFoundError, ValueError) as exc: + errors.append(str(exc)) + + # 2. Validate spoof mesh divide (if provided) + if args.spoof_divide is not None: + try: + _validate_spoof_divide(args.spoof_divide, car_vertex_count) + except (FileNotFoundError, ValueError) as exc: + errors.append(str(exc)) + + # 3. Validate removal mesh divide (if provided) + if args.remove_divide is not None: + try: + _validate_remove_divide( + args.remove_divide, + car_vertex_count, + args.advshape_template_vertices, + ) + except (FileNotFoundError, ValueError) as exc: + errors.append(str(exc)) + + # 4. Validate removal perturbation (if provided) + if args.remove_perturb is not None: + try: + _validate_remove_perturb(args.remove_perturb, car_vertex_count) + except (FileNotFoundError, ValueError) as exc: + errors.append(str(exc)) + + if errors: + print("\nValidation FAILED with the following errors:") + for error in errors: + print(f" - {error}") + sys.exit(1) + + print("\nAll specified assets passed validation.") + + +if __name__ == "__main__": + main() \ No newline at end of file From 34ba500aa67f1b8148dba672965911eadffc48ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D1=83=D1=80=D0=B0=D0=B4=D1=8F=D0=BD=20=D0=90=D1=80?= =?UTF-8?q?=D1=82=D1=91=D0=BC=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sat, 25 Jul 2026 19:34:12 +0300 Subject: [PATCH 2/7] Added tests for mesh assets creation utils --- .../advcp/test/test_advcp_asset_utils.py | 1017 +++++++++++++++++ 1 file changed, 1017 insertions(+) create mode 100644 opencda/core/attack/advcp/test/test_advcp_asset_utils.py diff --git a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py new file mode 100644 index 00000000..d31b0f44 --- /dev/null +++ b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py @@ -0,0 +1,1017 @@ +""" +Unit tests for the AdvCP asset generation and validation utilities. + +Tests cover: + +- :class:`MeshData` construction and properties. +- Mesh generation: :func:`box_mesh`, :func:`subdivide_midpoint`, + :func:`advshape_template_mesh`. +- Mesh transforms: :func:`normalize_bottom_center`, + :func:`scale_to_dimensions`, :func:`copy_or_generate_mesh`. +- Divide-index generation: :func:`generate_divide_indices` for both + ``"spoof"`` and ``"remove"`` modes. +- Mesh I/O round-trips: :func:`write_ascii_ply` / :func:`read_mesh` + for ASCII PLY, binary PLY, and OBJ formats. +- Pickle I/O round-trips: :func:`dump_divide_pickle` / + :func:`load_divide_pickle`. +- Perturbation I/O round-trips: :func:`save_perturbation` / + :func:`load_perturbation`. +- Validation: :func:`validate_mesh`, :func:`validate_mesh_frame_and_scale`, + :func:`validate_divide_indices` (pass and fail cases). +- Blueprint dimension lookup: :func:`blueprint_dimensions_m`, + :func:`parse_dimensions_arg`. +- CLI integration smoke tests for ``generate_car_mesh``, + ``generate_mesh_divide``, ``generate_remove_advshape_assets``, + and ``validate_advcp_assets``. +- Runtime asset helper: :class:`AdvCPRuntimeAssetHelper` spoof and + removal asset generation (skipped on Python < 3.10 due to + ``TypeAlias`` dependency in ``types.py``). + +All tests use temporary directories and do not modify the repository. +""" + +from __future__ import annotations + +import pickle +import struct +import sys +import tempfile +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from opencda.core.attack.advcp.utils.asset_utils import ( + MeshData, + advshape_template_mesh, + blueprint_dimensions_m, + box_mesh, + copy_or_generate_mesh, + dump_divide_pickle, + dump_generation_metadata, + generate_divide_indices, + load_divide_pickle, + load_perturbation, + normalize_bottom_center, + parse_dimensions_arg, + read_mesh, + read_obj, + read_ply, + save_perturbation, + scale_to_dimensions, + subdivide_midpoint, + validate_divide_indices, + validate_mesh, + validate_mesh_frame_and_scale, + write_ascii_ply, + write_mesh, +) + + +# ========================================================================= +# Fixtures +# ========================================================================= + + +@pytest.fixture +def tmp_output() -> Path: + """Yield a temporary directory path for test outputs.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def sample_box_mesh() -> MeshData: + """A standard 4.3 x 1.91 x 1.26 m box mesh (Tesla Model 3).""" + return box_mesh(4.3, 1.91, 1.26) + + +@pytest.fixture +def sample_vertices() -> np.ndarray: + """8 vertices of a unit cube at the origin.""" + return np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1.0, 1.0], + [0.0, 1.0, 1.0], + ], + dtype=np.float64, + ) + + +@pytest.fixture +def minimal_valid_mesh() -> MeshData: + """A tetrahedron with 4 vertices and 4 faces — the minimum for validation.""" + vertices = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]], dtype=np.int32) + return MeshData(vertices=vertices, faces=faces) + + +# ========================================================================= +# MeshData +# ========================================================================= + + +class TestMeshData: + """MeshData construction and basic properties.""" + + def test_construct(self, sample_box_mesh: MeshData) -> None: + assert sample_box_mesh.vertices.shape == (8, 3) + assert sample_box_mesh.faces.shape == (12, 3) + assert sample_box_mesh.vertices.dtype == np.float64 + assert sample_box_mesh.faces.dtype == np.int32 + + def test_immutable(self, sample_box_mesh: MeshData) -> None: + with pytest.raises((TypeError, AttributeError)): + sample_box_mesh.vertices = np.zeros((4, 3)) # type: ignore[misc] + + +# ========================================================================= +# Blueprint dimensions +# ========================================================================= + + +class TestBlueprintDimensions: + """CARLA blueprint dimension lookup.""" + + def test_known_blueprint(self) -> None: + assert blueprint_dimensions_m("vehicle.tesla.model3") == (4.30, 1.91, 1.26) + assert blueprint_dimensions_m("vehicle.audi.a2") == (3.70, 1.70, 1.55) + + def test_unknown_blueprint_falls_back_to_default(self) -> None: + dims = blueprint_dimensions_m("vehicle.unknown.foo") + assert dims == (4.30, 1.91, 1.26) + + def test_none_blueprint_falls_back_to_default(self) -> None: + dims = blueprint_dimensions_m(None) + assert dims == (4.30, 1.91, 1.26) + + +class TestParseDimensionsArg: + """Dimension argument parsing.""" + + def test_valid(self) -> None: + assert parse_dimensions_arg([4.0, 2.0, 1.5]) == (4.0, 2.0, 1.5) + + def test_none(self) -> None: + assert parse_dimensions_arg(None) is None + + def test_wrong_length(self) -> None: + with pytest.raises(ValueError, match="exactly 3"): + parse_dimensions_arg([1.0, 2.0]) + + def test_non_positive(self) -> None: + with pytest.raises(ValueError, match="positive"): + parse_dimensions_arg([4.0, -1.0, 1.5]) + + +# ========================================================================= +# Mesh generation +# ========================================================================= + + +class TestBoxMesh: + """Box mesh construction.""" + + def test_dimensions(self) -> None: + mesh = box_mesh(4.0, 2.0, 1.5) + mins = mesh.vertices.min(axis=0) + maxs = mesh.vertices.max(axis=0) + size = maxs - mins + np.testing.assert_allclose(size, [4.0, 2.0, 1.5], atol=1e-10) + + def test_bottom_at_zero(self) -> None: + mesh = box_mesh(4.0, 2.0, 1.5) + assert mesh.vertices[:, 2].min() == 0.0 + + def test_xy_centered(self) -> None: + mesh = box_mesh(4.0, 2.0, 1.5) + center_xy = mesh.vertices.mean(axis=0)[:2] + np.testing.assert_allclose(center_xy, [0.0, 0.0], atol=1e-10) + + def test_vertex_count(self, sample_box_mesh: MeshData) -> None: + assert sample_box_mesh.vertices.shape[0] == 8 + + def test_face_count(self, sample_box_mesh: MeshData) -> None: + assert sample_box_mesh.faces.shape[0] == 12 + + +class TestSubdivideMidpoint: + """Midpoint subdivision.""" + + def test_zero_levels_returns_original(self, sample_box_mesh: MeshData) -> None: + result = subdivide_midpoint(sample_box_mesh, levels=0) + assert result.vertices.shape == sample_box_mesh.vertices.shape + assert result.faces.shape == sample_box_mesh.faces.shape + + def test_one_level_quadruples_faces(self, sample_box_mesh: MeshData) -> None: + result = subdivide_midpoint(sample_box_mesh, levels=1) + # 12 faces * 4 = 48 + assert result.faces.shape[0] == 48 + + def test_two_levels(self, sample_box_mesh: MeshData) -> None: + result = subdivide_midpoint(sample_box_mesh, levels=2) + # 12 * 4 * 4 = 192 + assert result.faces.shape[0] == 192 + + def test_vertices_are_finite(self) -> None: + mesh = box_mesh(1.0, 1.0, 1.0) + result = subdivide_midpoint(mesh, levels=3) + assert np.all(np.isfinite(result.vertices)) + + +class TestAdvShapeTemplateMesh: + """AdvCP adversarial-shape template mesh.""" + + def test_returns_valid_mesh(self) -> None: + mesh = advshape_template_mesh() + validate_mesh(mesh, "advshape_template") + + def test_vertex_count(self) -> None: + mesh = advshape_template_mesh() + assert 50 < mesh.vertices.shape[0] < 500 + + def test_face_count(self) -> None: + mesh = advshape_template_mesh() + assert mesh.faces.shape[0] == 192 # 12 * 4 * 4 + + +# ========================================================================= +# Mesh transforms +# ========================================================================= + + +class TestNormalizeBottomCenter: + """Bottom-centre normalisation.""" + + def test_centers_xy(self) -> None: + vertices = np.array([[-1.0, -2.0, 0.0], [3.0, 4.0, 5.0]], dtype=np.float64) + faces = np.array([[0, 1, 0]], dtype=np.int32) + mesh = MeshData(vertices=vertices, faces=faces) + result = normalize_bottom_center(mesh) + # XY center should be at origin + center_xy = result.vertices.mean(axis=0)[:2] + np.testing.assert_allclose(center_xy, [0.0, 0.0], atol=1e-10) + + def test_bottom_at_zero(self) -> None: + vertices = np.array([[0.0, 0.0, 2.0], [1.0, 1.0, 5.0]], dtype=np.float64) + faces = np.array([[0, 1, 0]], dtype=np.int32) + mesh = MeshData(vertices=vertices, faces=faces) + result = normalize_bottom_center(mesh) + assert result.vertices[:, 2].min() == 0.0 + + +class TestScaleToDimensions: + """Mesh scaling.""" + + def test_scale_to_target(self, sample_box_mesh: MeshData) -> None: + target = (5.0, 2.0, 1.8) + scaled = scale_to_dimensions(sample_box_mesh, target, preserve_aspect=False) + size = scaled.vertices.max(axis=0) - scaled.vertices.min(axis=0) + np.testing.assert_allclose(size, target, atol=1e-10) + + def test_preserve_aspect(self, sample_box_mesh: MeshData) -> None: + """When preserve_aspect=True, the smallest scale factor is applied uniformly. + + For a 4.3 x 1.91 x 1.26 box scaled to (10, 10, 10): + target/size = [10/4.3, 10/1.91, 10/1.26] ≈ [2.326, 5.236, 7.937] + min = 2.326, so result ≈ [10.0, 4.44, 2.93]. + """ + target = (10.0, 10.0, 10.0) + scaled = scale_to_dimensions(sample_box_mesh, target, preserve_aspect=True) + size = scaled.vertices.max(axis=0) - scaled.vertices.min(axis=0) + # The smallest original axis (Z=1.26) determines the uniform scale + # scale = min(10/4.3, 10/1.91, 10/1.26) = 10/4.3 ≈ 2.326 + expected_scale = 10.0 / 4.3 + np.testing.assert_allclose(size[0], 4.3 * expected_scale, atol=1e-10) + np.testing.assert_allclose(size[1], 1.91 * expected_scale, atol=1e-10) + np.testing.assert_allclose(size[2], 1.26 * expected_scale, atol=1e-10) + + def test_degenerate_mesh_raises(self) -> None: + vertices = np.array([[0.0, 0.0, 0.0], [1e-12, 0.0, 0.0]], dtype=np.float64) + faces = np.array([[0, 1, 0]], dtype=np.int32) + mesh = MeshData(vertices=vertices, faces=faces) + with pytest.raises(ValueError, match="degenerate"): + scale_to_dimensions(mesh, (4.0, 2.0, 1.5)) + + +class TestCopyOrGenerateMesh: + """Mesh copy-or-generate logic.""" + + def test_no_input_creates_box(self) -> None: + mesh = copy_or_generate_mesh(None, dimensions=(4.0, 2.0, 1.5), preserve_aspect=False) + size = mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0) + np.testing.assert_allclose(size, [4.0, 2.0, 1.5], atol=1e-10) + + def test_with_ply_input(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + src = tmp_output / "source.ply" + write_ascii_ply(sample_box_mesh, src) + mesh = copy_or_generate_mesh(src, dimensions=(5.0, 2.5, 2.0), preserve_aspect=False) + size = mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0) + np.testing.assert_allclose(size, [5.0, 2.5, 2.0], atol=1e-10) + + +# ========================================================================= +# Divide-index generation +# ========================================================================= + + +class TestGenerateDivideIndices: + """Vertex-group index generation.""" + + def test_spoof_returns_8_groups(self, sample_vertices: np.ndarray) -> None: + groups = generate_divide_indices(sample_vertices, "spoof") + assert len(groups) == 8 + + def test_remove_returns_10_groups(self, sample_vertices: np.ndarray) -> None: + groups = generate_divide_indices(sample_vertices, "remove") + assert len(groups) == 10 + + def test_all_indices_are_valid(self, sample_vertices: np.ndarray) -> None: + groups = generate_divide_indices(sample_vertices, "spoof") + n = sample_vertices.shape[0] + for g in groups: + assert g.dtype == np.int32 + assert g.ndim == 1 + assert g.min() >= 0 + assert g.max() < n + + def test_invalid_mode_raises(self, sample_vertices: np.ndarray) -> None: + with pytest.raises(ValueError, match="Unsupported divide mode"): + generate_divide_indices(sample_vertices, "invalid") + + def test_spoof_groups_cover_all_vertices(self, sample_vertices: np.ndarray) -> None: + groups = generate_divide_indices(sample_vertices, "spoof") + all_idx = np.unique(np.concatenate(groups)) + assert all_idx.shape[0] == sample_vertices.shape[0] + + def test_remove_groups_cover_all_vertices(self, sample_vertices: np.ndarray) -> None: + groups = generate_divide_indices(sample_vertices, "remove") + all_idx = np.unique(np.concatenate(groups)) + assert all_idx.shape[0] == sample_vertices.shape[0] + + def test_empty_group_fallback(self) -> None: + """When a group is empty, it should fall back to all indices.""" + # Single vertex at origin — most groups will be empty + vertices = np.array([[0.0, 0.0, 0.0]], dtype=np.float64) + groups = generate_divide_indices(vertices, "spoof") + for g in groups: + assert g.shape[0] == 1 + assert g[0] == 0 + + +# ========================================================================= +# Mesh I/O +# ========================================================================= + + +class TestWriteAsciiPly: + """ASCII PLY writing.""" + + def test_writes_valid_ply(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + path = tmp_output / "test.ply" + write_ascii_ply(sample_box_mesh, path) + assert path.exists() + assert path.stat().st_size > 0 + + def test_ply_header(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + path = tmp_output / "test.ply" + write_ascii_ply(sample_box_mesh, path) + with open(path, "r") as f: + header = f.read(500) + assert header.startswith("ply") + assert "format ascii 1.0" in header + assert "element vertex 8" in header + assert "element face 12" in header + + def test_round_trip(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + path = tmp_output / "test.ply" + write_ascii_ply(sample_box_mesh, path) + mesh = read_mesh(path) + assert mesh.vertices.shape == sample_box_mesh.vertices.shape + assert mesh.faces.shape == sample_box_mesh.faces.shape + np.testing.assert_allclose(mesh.vertices, sample_box_mesh.vertices, atol=1e-8) + + +class TestReadPly: + """PLY reading (ASCII and binary).""" + + def test_read_ascii_ply(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + path = tmp_output / "ascii.ply" + write_ascii_ply(sample_box_mesh, path) + mesh = read_ply(path) + assert mesh.vertices.shape == (8, 3) + assert mesh.faces.shape == (12, 3) + + def test_read_binary_ply(self, tmp_output: Path) -> None: + """Write a minimal binary PLY with 4 vertices and 4 faces, then read it back.""" + path = tmp_output / "binary.ply" + # A tetrahedron: 4 vertices, 4 faces + vertices = np.array( + [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float64 + ) + faces = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]], dtype=np.int32) + with open(path, "wb") as f: + f.write(b"ply\n") + f.write(b"format binary_little_endian 1.0\n") + f.write(b"element vertex 4\n") + f.write(b"property double x\n") + f.write(b"property double y\n") + f.write(b"property double z\n") + f.write(b"element face 4\n") + f.write(b"property list uchar uint vertex_indices\n") + f.write(b"end_header\n") + for v in vertices: + f.write(struct.pack(" None: + path = tmp_output / "bad.ply" + with open(path, "w") as f: + f.write("ply\nformat ascii 1.0\nend_header\n") + with pytest.raises(ValueError, match="non-empty"): + read_ply(path) + + +class TestReadObj: + """OBJ file reading.""" + + def test_read_simple_obj(self, tmp_output: Path) -> None: + """A tetrahedron with 4 vertices and 4 faces.""" + path = tmp_output / "test.obj" + with open(path, "w") as f: + f.write("v 0 0 0\n") + f.write("v 1 0 0\n") + f.write("v 0 1 0\n") + f.write("v 0 0 1\n") + f.write("f 1 2 3\n") + f.write("f 1 3 4\n") + f.write("f 1 4 2\n") + f.write("f 2 3 4\n") + mesh = read_obj(path) + assert mesh.vertices.shape == (4, 3) + assert mesh.faces.shape == (4, 3) + + def test_read_obj_with_texture_coords(self, tmp_output: Path) -> None: + """Face entries with texture coordinates (v/t) should be handled.""" + path = tmp_output / "tex.obj" + with open(path, "w") as f: + f.write("v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 0 1\n") + f.write("f 1/1 2/2 3/3\n") + f.write("f 1/1 3/3 4/4\n") + f.write("f 1/1 4/4 2/2\n") + f.write("f 2/2 3/3 4/4\n") + mesh = read_obj(path) + assert mesh.vertices.shape == (4, 3) + assert mesh.faces.shape == (4, 3) + + def test_read_obj_with_normals(self, tmp_output: Path) -> None: + """Face entries with texture and normal (v/t/n) should be handled.""" + path = tmp_output / "norm.obj" + with open(path, "w") as f: + f.write("v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 0 1\n") + f.write("f 1/1/1 2/2/2 3/3/3\n") + f.write("f 1/1/1 3/3/3 4/4/4\n") + f.write("f 1/1/1 4/4/4 2/2/2\n") + f.write("f 2/2/2 3/3/3 4/4/4\n") + mesh = read_obj(path) + assert mesh.vertices.shape == (4, 3) + assert mesh.faces.shape == (4, 3) + + def test_read_obj_triangulates_quads(self, tmp_output: Path) -> None: + """Quad faces should be triangulated into two triangles. + + A hexahedron (box) with 8 vertices and 6 quad faces → 12 triangles. + """ + path = tmp_output / "quad.obj" + with open(path, "w") as f: + f.write("v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\n") + f.write("v 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n") + f.write("f 1 2 3 4\n") # bottom + f.write("f 5 8 7 6\n") # top + f.write("f 1 5 6 2\n") # front + f.write("f 2 6 7 3\n") # right + f.write("f 3 7 8 4\n") # back + f.write("f 4 8 5 1\n") # left + mesh = read_obj(path) + assert mesh.vertices.shape == (8, 3) + # 6 quads → 12 triangles + assert mesh.faces.shape[0] == 12 + + +class TestReadMesh: + """Format-dispatching mesh reader.""" + + def test_unsupported_format_raises(self, tmp_output: Path) -> None: + path = tmp_output / "test.fbx" + path.touch() + with pytest.raises(ValueError, match="Unsupported mesh format"): + read_mesh(path) + + +# ========================================================================= +# Pickle I/O +# ========================================================================= + + +class TestDividePickle: + """Mesh-divide pickle serialisation.""" + + def test_round_trip(self, sample_vertices: np.ndarray, tmp_output: Path) -> None: + groups = generate_divide_indices(sample_vertices, "spoof") + path = tmp_output / "divide.pkl" + dump_divide_pickle(groups, path) + loaded = load_divide_pickle(path) + assert len(loaded) == len(groups) + for a, b in zip(loaded, groups): + np.testing.assert_array_equal(a, b) + + def test_invalid_pickle_raises(self, tmp_output: Path) -> None: + path = tmp_output / "bad.pkl" + with open(path, "wb") as f: + pickle.dump("not_a_list", f) + with pytest.raises(ValueError, match="must be a list"): + load_divide_pickle(path) + + +# ========================================================================= +# Perturbation I/O +# ========================================================================= + + +class TestPerturbation: + """Perturbation tensor save/load.""" + + def test_round_trip(self, tmp_output: Path) -> None: + path = tmp_output / "perturb.npy" + original = np.random.randn(100, 3).astype(np.float32) + save_perturbation(path, original) + loaded = load_perturbation(path) + np.testing.assert_allclose(loaded, original, atol=1e-6) + + def test_wrong_shape_raises(self, tmp_output: Path) -> None: + path = tmp_output / "bad.npy" + bad = np.zeros((100, 4), dtype=np.float32) + np.save(path, bad) + with pytest.raises(ValueError, match="must have shape"): + load_perturbation(path) + + def test_non_finite_raises(self, tmp_output: Path) -> None: + path = tmp_output / "nan.npy" + bad = np.full((10, 3), np.nan, dtype=np.float32) + np.save(path, bad) + with pytest.raises(ValueError, match="non-finite"): + load_perturbation(path) + + +# ========================================================================= +# Validation +# ========================================================================= + + +class TestValidateMesh: + """Mesh structural validation.""" + + def test_valid_mesh_passes(self, sample_box_mesh: MeshData) -> None: + validate_mesh(sample_box_mesh, "test") # should not raise + + def test_wrong_vertex_shape_raises(self) -> None: + mesh = MeshData(vertices=np.zeros((10, 2)), faces=np.zeros((5, 3), dtype=np.int32)) + with pytest.raises(ValueError, match="must have shape"): + validate_mesh(mesh, "test") + + def test_wrong_face_shape_raises(self) -> None: + mesh = MeshData(vertices=np.zeros((10, 3)), faces=np.zeros((5, 4), dtype=np.int32)) + with pytest.raises(ValueError, match="must have shape"): + validate_mesh(mesh, "test") + + def test_too_few_vertices_raises(self) -> None: + mesh = MeshData(vertices=np.zeros((2, 3)), faces=np.zeros((4, 3), dtype=np.int32)) + with pytest.raises(ValueError, match="too few vertices"): + validate_mesh(mesh, "test") + + def test_too_few_faces_raises(self) -> None: + mesh = MeshData(vertices=np.zeros((10, 3)), faces=np.zeros((2, 3), dtype=np.int32)) + with pytest.raises(ValueError, match="too few faces"): + validate_mesh(mesh, "test") + + def test_non_finite_vertices_raises(self, minimal_valid_mesh: MeshData) -> None: + """Replace one vertex with NaN; should trigger the non-finite check.""" + vertices = minimal_valid_mesh.vertices.copy() + vertices[0, 0] = np.nan + mesh = MeshData(vertices=vertices, faces=minimal_valid_mesh.faces) + with pytest.raises(ValueError, match="non-finite"): + validate_mesh(mesh, "test") + + def test_out_of_bounds_faces_raises(self, minimal_valid_mesh: MeshData) -> None: + """Face index 10 is out of bounds for 4 vertices.""" + faces = minimal_valid_mesh.faces.copy() + faces[0, 0] = 10 + mesh = MeshData(vertices=minimal_valid_mesh.vertices, faces=faces) + with pytest.raises(ValueError, match="out of bounds"): + validate_mesh(mesh, "test") + + +class TestValidateMeshFrameAndScale: + """Mesh coordinate frame and scale validation.""" + + def test_valid_mesh_passes(self, sample_box_mesh: MeshData) -> None: + validate_mesh_frame_and_scale(sample_box_mesh, "test") + + def test_degenerate_raises(self) -> None: + vertices = np.array([[0, 0, 0], [0.01, 0, 0], [0, 0.01, 0], [0, 0, 0.01]], dtype=np.float64) + faces = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]], dtype=np.int32) + mesh = MeshData(vertices=vertices, faces=faces) + with pytest.raises(ValueError, match="degenerate"): + validate_mesh_frame_and_scale(mesh, "test") + + def test_too_large_raises(self) -> None: + vertices = np.array([[0, 0, 0], [50, 0, 0], [0, 50, 0], [0, 0, 50]], dtype=np.float64) + faces = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]], dtype=np.int32) + mesh = MeshData(vertices=vertices, faces=faces) + with pytest.raises(ValueError, match="incompatible scale"): + validate_mesh_frame_and_scale(mesh, "test") + + def test_z_origin_not_bottom_raises(self) -> None: + vertices = np.array([[0, 0, 5], [1, 0, 5], [0, 1, 5], [0, 0, 6]], dtype=np.float64) + faces = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]], dtype=np.int32) + mesh = MeshData(vertices=vertices, faces=faces) + with pytest.raises(ValueError, match="z-origin"): + validate_mesh_frame_and_scale(mesh, "test") + + +class TestValidateDivideIndices: + """Divide-index validation.""" + + def test_valid_indices_passes(self, sample_vertices: np.ndarray) -> None: + groups = generate_divide_indices(sample_vertices, "spoof") + validate_divide_indices(groups, sample_vertices.shape[0], "test") + + def test_empty_list_raises(self) -> None: + with pytest.raises(ValueError, match="at least one"): + validate_divide_indices([], 10, "test") + + def test_out_of_bounds_raises(self) -> None: + groups = [np.array([0, 1, 100], dtype=np.int32)] + with pytest.raises(ValueError, match="invalid vertex indices"): + validate_divide_indices(groups, 10, "test") + + def test_negative_index_raises(self) -> None: + groups = [np.array([0, -1, 2], dtype=np.int32)] + with pytest.raises(ValueError, match="invalid vertex indices"): + validate_divide_indices(groups, 10, "test") + + def test_empty_group_raises(self) -> None: + groups = [np.array([], dtype=np.int32)] + with pytest.raises(ValueError, match="empty"): + validate_divide_indices(groups, 10, "test") + + +# ========================================================================= +# Generation metadata +# ========================================================================= + + +class TestDumpGenerationMetadata: + """Generation metadata serialisation.""" + + def test_round_trip(self, tmp_output: Path) -> None: + path = tmp_output / "meta.pkl" + meta = {"blueprint": "vehicle.tesla.model3", "dimensions": (4.3, 1.91, 1.26)} + dump_generation_metadata(path, meta) + with open(path, "rb") as f: + loaded = pickle.load(f) + assert loaded == meta + + +# ========================================================================= +# CLI integration smoke tests +# ========================================================================= + + +class TestGenerateCarMeshCLI: + """Smoke tests for generate_car_mesh CLI.""" + + def test_generates_ply(self, tmp_output: Path) -> None: + out = tmp_output / "car.ply" + from opencda.core.attack.advcp.utils.generate_car_mesh import main + sys.argv = ["generate_car_mesh", "--vehicle-blueprint", "vehicle.tesla.model3", "--output", str(out)] + main() + assert out.exists() + mesh = read_mesh(out) + assert mesh.vertices.shape[0] > 0 + + def test_with_custom_dimensions(self, tmp_output: Path) -> None: + out = tmp_output / "car.ply" + sys.argv = [ + "generate_car_mesh", + "--dimensions", "5.0", "2.0", "1.8", + "--output", str(out), + ] + from opencda.core.attack.advcp.utils.generate_car_mesh import main + main() + mesh = read_mesh(out) + size = mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0) + np.testing.assert_allclose(size, [5.0, 2.0, 1.8], atol=1e-6) + + +class TestGenerateMeshDivideCLI: + """Smoke tests for generate_mesh_divide CLI.""" + + def test_generates_spoof_pkl(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + out = tmp_output / "divide.pkl" + sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "spoof", "--output", str(out)] + from opencda.core.attack.advcp.utils.generate_mesh_divide import main + main() + assert out.exists() + loaded = load_divide_pickle(out) + assert len(loaded) == 8 + + def test_generates_remove_pkl(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + out = tmp_output / "divide.pkl" + sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "remove", "--output", str(out)] + from opencda.core.attack.advcp.utils.generate_mesh_divide import main + main() + loaded = load_divide_pickle(out) + assert len(loaded) == 10 + + +class TestGenerateRemoveAdvshapeAssetsCLI: + """Smoke tests for generate_remove_advshape_assets CLI.""" + + def test_generates_divide(self, tmp_output: Path) -> None: + out = tmp_output / "divide.pkl" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", "divide", + "--output", str(out), + ] + from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() + assert out.exists() + loaded = load_divide_pickle(out) + assert len(loaded) == 10 + + def test_generates_perturb(self, tmp_output: Path) -> None: + out = tmp_output / "perturb.npy" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", "perturb", + "--output", str(out), + ] + from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() + assert out.exists() + loaded = np.load(out) + assert loaded.shape[1] == 3 + + def test_generates_random_perturb(self, tmp_output: Path) -> None: + out = tmp_output / "perturb.npy" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", "perturb", + "--random", + "--seed", "42", + "--perturb-scale", "0.3", + "--output", str(out), + ] + from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() + loaded = np.load(out) + assert loaded.min() >= -0.3 + assert loaded.max() <= 0.3 + + def test_generates_both(self, tmp_output: Path) -> None: + div_out = tmp_output / "divide.pkl" + pert_out = tmp_output / "perturb.npy" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", "both", + "--divide-output", str(div_out), + "--perturb-output", str(pert_out), + ] + from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() + assert div_out.exists() + assert pert_out.exists() + + +class TestValidateAdvcpAssetsCLI: + """Smoke tests for validate_advcp_assets CLI.""" + + def test_validate_car_mesh_only(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", str(mesh_path), + ] + from opencda.core.attack.advcp.utils.validate_advcp_assets import main + main() # should not raise + + def test_validate_all_assets(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + spoof_path = tmp_output / "spoof.pkl" + dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "spoof"), spoof_path) + remove_path = tmp_output / "remove.pkl" + dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "remove"), remove_path) + perturb_path = tmp_output / "perturb.npy" + save_perturbation(perturb_path, np.zeros((sample_box_mesh.vertices.shape[0], 3), dtype=np.float32)) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", str(mesh_path), + "--spoof-divide", str(spoof_path), + "--remove-divide", str(remove_path), + "--remove-perturb", str(perturb_path), + ] + from opencda.core.attack.advcp.utils.validate_advcp_assets import main + main() + + def test_validate_fails_on_missing_file(self, tmp_output: Path) -> None: + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", str(tmp_output / "nonexistent.ply"), + ] + from opencda.core.attack.advcp.utils.validate_advcp_assets import main + with pytest.raises(SystemExit): + main() + + def test_validate_fails_on_wrong_vertex_count(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", str(mesh_path), + "--expected-vertices", "9999", + ] + from opencda.core.attack.advcp.utils.validate_advcp_assets import main + with pytest.raises(SystemExit): + main() + + +# ========================================================================= +# Runtime asset helper (Python >= 3.10 only due to TypeAlias in types.py) +# ========================================================================= + +_RUNTIME_ASSETS_AVAILABLE: bool = True +try: + from opencda.core.attack.advcp.utils.runtime_assets import AdvCPRuntimeAssetHelper +except ImportError: + _RUNTIME_ASSETS_AVAILABLE = False + + +@pytest.mark.skipif( + not _RUNTIME_ASSETS_AVAILABLE, + reason="AdvCPRuntimeAssetHelper requires Python >= 3.10 (TypeAlias in types.py)", +) +class TestAdvCPRuntimeAssetHelper: + """Runtime asset generation helper.""" + + def test_ensure_spoof_assets_generates_missing(self, tmp_output: Path) -> None: + config: dict[str, Any] = { + "car_mesh_path": str(tmp_output / "car_mesh.ply"), + "car_mesh_divide_path": str(tmp_output / "spoof" / "car_mesh_divide.pkl"), + "vehicle_blueprint": "vehicle.tesla.model3", + "asset_runtime_generation": True, + } + mesh_path, divide_path = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) + assert mesh_path.exists() + assert divide_path.exists() + divide = load_divide_pickle(divide_path) + assert len(divide) == 8 + + def test_ensure_spoof_assets_uses_cache(self, tmp_output: Path) -> None: + mesh_path = tmp_output / "car_mesh.ply" + divide_path = tmp_output / "spoof" / "car_mesh_divide.pkl" + mesh_path.parent.mkdir(parents=True, exist_ok=True) + box = box_mesh(4.3, 1.91, 1.26) + write_ascii_ply(box, mesh_path) + dump_divide_pickle(generate_divide_indices(box.vertices, "spoof"), divide_path) + mtime_before = mesh_path.stat().st_mtime + config: dict[str, Any] = { + "car_mesh_path": str(mesh_path), + "car_mesh_divide_path": str(divide_path), + "asset_runtime_generation": True, + } + AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) + assert mesh_path.stat().st_mtime == mtime_before + + def test_ensure_spoof_assets_disabled(self, tmp_output: Path) -> None: + config: dict[str, Any] = { + "car_mesh_path": str(tmp_output / "car_mesh.ply"), + "car_mesh_divide_path": str(tmp_output / "spoof" / "car_mesh_divide.pkl"), + "asset_runtime_generation": False, + } + mesh_path, divide_path = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) + assert not mesh_path.exists() + assert not divide_path.exists() + + def test_ensure_remove_advshape_assets_no_cache_dir(self) -> None: + config: dict[str, Any] = { + "asset_runtime_generation": True, + } + result = AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) + assert result is None + + def test_ensure_remove_advshape_assets_generates(self, tmp_output: Path) -> None: + cache_dir = tmp_output / "cache" + config: dict[str, Any] = { + "asset_runtime_generation": True, + "asset_cache_dir": str(cache_dir), + "remove_adv_shape_generate_zero_perturb": True, + } + result = AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) + assert result is not None + perturb_path, divide_path = result + assert divide_path.exists() + assert perturb_path.exists() + divide = load_divide_pickle(divide_path) + assert len(divide) == 10 + + def test_ensure_remove_advshape_assets_updates_config(self, tmp_output: Path) -> None: + cache_dir = tmp_output / "cache" + config: dict[str, Any] = { + "asset_runtime_generation": True, + "asset_cache_dir": str(cache_dir), + } + AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) + assert "remove_adv_shape_divide_path" in config + assert Path(config["remove_adv_shape_divide_path"]).exists() + + +# ========================================================================= +# Edge cases +# ========================================================================= + + +class TestEdgeCases: + """Edge cases and error handling.""" + + def test_empty_vertices_divide(self) -> None: + vertices = np.empty((0, 3), dtype=np.float64) + with pytest.raises(ValueError): + generate_divide_indices(vertices, "spoof") + + def test_single_vertex_divide(self) -> None: + vertices = np.array([[0.0, 0.0, 0.0]], dtype=np.float64) + groups = generate_divide_indices(vertices, "spoof") + assert len(groups) == 8 + for g in groups: + assert g.shape[0] == 1 + + def test_ply_with_extra_properties(self, tmp_output: Path) -> None: + """PLY with extra vertex properties (e.g. normal) should still be readable.""" + path = tmp_output / "extra.ply" + with open(path, "w") as f: + f.write("ply\n") + f.write("format ascii 1.0\n") + f.write("element vertex 4\n") + f.write("property float x\n") + f.write("property float y\n") + f.write("property float z\n") + f.write("property float nx\n") + f.write("property float ny\n") + f.write("property float nz\n") + f.write("element face 4\n") + f.write("property list uchar int vertex_indices\n") + f.write("end_header\n") + f.write("0 0 0 1 0 0\n") + f.write("1 0 0 0 1 0\n") + f.write("0 1 0 0 0 1\n") + f.write("0 0 1 0 0 1\n") + f.write("3 0 1 2\n") + f.write("3 0 2 3\n") + f.write("3 0 3 1\n") + f.write("3 1 2 3\n") + mesh = read_ply(path) + assert mesh.vertices.shape == (4, 3) + assert mesh.faces.shape == (4, 3) + + def test_scale_to_dimensions_preserve_aspect_smaller(self) -> None: + """When preserve_aspect is True, the smallest axis determines the scale.""" + mesh = box_mesh(4.0, 2.0, 1.0) + target = (8.0, 8.0, 8.0) + scaled = scale_to_dimensions(mesh, target, preserve_aspect=True) + size = scaled.vertices.max(axis=0) - scaled.vertices.min(axis=0) + # target/size = [8/4, 8/2, 8/1] = [2, 4, 8], min = 2 + # uniform scale = 2, result = [8, 4, 2] + np.testing.assert_allclose(size, [8.0, 4.0, 2.0], atol=1e-10) \ No newline at end of file From d493eefdb5354fd0f5e7a176ae8d3031871a6152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D1=83=D1=80=D0=B0=D0=B4=D1=8F=D0=BD=20=D0=90=D1=80?= =?UTF-8?q?=D1=82=D1=91=D0=BC=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sat, 3 Oct 2026 14:55:50 +0300 Subject: [PATCH 3/7] [AdvCP] Make generated assets index-compatible with the runtime - Build the adv-shape template with Open3D's exact vertex/triangle ordering (create_box + subdivide_midpoint) and use it as the single source of truth in early_fusion_attack; share the default removal partition (advshape_default_divide) between generator and runtime. - Subdivide the default generated car mesh and validate that every divide group yields a non-empty mesh piece (generators, runtime helper and validator), preventing empty RaycastingScene inputs. - Replace the hardcoded binary PLY reader with a header-driven parser (float/double, extra properties, both byte orders, polygons). - Measure the reference bbox of car meshes missing from CAR_MESH_3D_EXAMPLES instead of borrowing car_mesh_0200's. - Add regression tests pinned to Open3D 0.18/0.19 output; apply ruff format to the asset utilities. --- opencda/core/attack/advcp/attack_helper.py | 25 +- .../core/attack/advcp/early_fusion_attack.py | 35 +- .../advcp/test/test_advcp_asset_utils.py | 352 ++++++++- opencda/core/attack/advcp/utils/__init__.py | 1 - .../core/attack/advcp/utils/asset_utils.py | 686 ++++++++++++++---- .../attack/advcp/utils/generate_car_mesh.py | 2 +- .../advcp/utils/generate_mesh_divide.py | 13 +- .../utils/generate_remove_advshape_assets.py | 18 +- .../core/attack/advcp/utils/runtime_assets.py | 5 +- .../advcp/utils/validate_advcp_assets.py | 85 ++- 10 files changed, 949 insertions(+), 273 deletions(-) diff --git a/opencda/core/attack/advcp/attack_helper.py b/opencda/core/attack/advcp/attack_helper.py index 9465ede9..219563e0 100644 --- a/opencda/core/attack/advcp/attack_helper.py +++ b/opencda/core/attack/advcp/attack_helper.py @@ -1224,10 +1224,22 @@ def build_real_car_mesh_pieces(cls, spoof_box: BoxLwhBottomCenter, advcp_config: car_mesh_pieces = [car_mesh.select_by_index(vertex_indices) for vertex_indices in cls._CAR_MESH_DIVIDE_CACHE[car_mesh_divide_path]] else: car_mesh_pieces = [car_mesh] - return cls.post_process_car_meshes(car_mesh_pieces, spoof_box, car_mesh_name) + car_mesh_bbox = cls.CAR_MESH_3D_EXAMPLES.get(car_mesh_name) + if car_mesh_bbox is None: + # Unknown (e.g. generated) mesh: measure its own extents instead + # of borrowing the reference box of a different car model. + extent = np.asarray(car_mesh.get_max_bound(), dtype=np.float64) - np.asarray(car_mesh.get_min_bound(), dtype=np.float64) + car_mesh_bbox = np.array([0.0, 0.0, 0.0, *extent, 0.0], dtype=np.float32) + return cls.post_process_car_meshes(car_mesh_pieces, spoof_box, car_mesh_name, car_mesh_bbox) @classmethod - def post_process_car_meshes(cls, car_mesh_pieces: list[Any], spoof_box: BoxLwhBottomCenter, car_mesh_name: str) -> list[Any]: + def post_process_car_meshes( + cls, + car_mesh_pieces: list[Any], + spoof_box: BoxLwhBottomCenter, + car_mesh_name: str, + car_mesh_bbox: npt.NDArray | None = None, + ) -> list[Any]: """ Fit each mesh piece into the configured target box. @@ -1242,7 +1254,11 @@ def post_process_car_meshes(cls, car_mesh_pieces: list[Any], spoof_box: BoxLwhBo Target box. car_mesh_name : str Stem of the mesh file; used to look up the reference - bounding box in ``CAR_MESH_3D_EXAMPLES``. + bounding box in ``CAR_MESH_3D_EXAMPLES`` when + ``car_mesh_bbox`` is not given. + car_mesh_bbox : Optional[npt.NDArray] + Reference bounding box ``[x, y, z, l, w, h, yaw]`` of the + source mesh. Takes precedence over the name lookup. Returns ------- @@ -1250,7 +1266,8 @@ def post_process_car_meshes(cls, car_mesh_pieces: list[Any], spoof_box: BoxLwhBo Transformed mesh pieces ready for ray tracing. """ processed_meshes = [] - car_mesh_bbox = cls.CAR_MESH_3D_EXAMPLES.get(car_mesh_name, cls.CAR_MESH_3D_EXAMPLES["car_mesh_0200"]) + if car_mesh_bbox is None: + car_mesh_bbox = cls.CAR_MESH_3D_EXAMPLES.get(car_mesh_name, cls.CAR_MESH_3D_EXAMPLES["car_mesh_0200"]) # Use the smallest per-axis scale ratio to preserve aspect. scale = float(np.min(spoof_box[3:6] / car_mesh_bbox[3:6])) rotation = np.array( diff --git a/opencda/core/attack/advcp/early_fusion_attack.py b/opencda/core/attack/advcp/early_fusion_attack.py index 8c245088..0de1af30 100644 --- a/opencda/core/attack/advcp/early_fusion_attack.py +++ b/opencda/core/attack/advcp/early_fusion_attack.py @@ -55,6 +55,11 @@ from opencood.utils.transformation_utils import x_to_world from opencda.core.attack.advcp.attack_helper import AdvCPAttackHelper, AdvCPCarMeshHelper +from opencda.core.attack.advcp.utils.asset_utils import ( + ADVSHAPE_TEMPLATE_DIMENSIONS_M, + advshape_default_divide, + advshape_template_mesh, +) from opencda.core.attack.advcp.types import ( AdvCPAttackResult, AdvCPConfig, @@ -1013,14 +1018,13 @@ def _resolve_optional_advshape_path(path_value: Any) -> Path | None: def _create_adv_shape_template_mesh(cls, advcp_config: AdvCPConfig) -> Any: import open3d as o3d - # Matches AdvCP canonical adversarial shape template dimensions. - template_box = np.array([0.0, 0.0, 0.0, 4.9, 2.5, 2.0, 0.0], dtype=np.float32) - mesh = AdvCPCarMeshHelper.build_box_piece_mesh( - template_box, - (float(template_box[3]), float(template_box[4]), float(template_box[5])), - (0.0, 0.0, float(template_box[5]) / 2.0), + # Shared with the asset generators so that positional perturbation + # and divide assets address the same vertices. + template = advshape_template_mesh() + mesh = o3d.geometry.TriangleMesh( + o3d.utility.Vector3dVector(template.vertices), + o3d.utility.Vector3iVector(template.faces), ) - mesh = mesh.subdivide_midpoint(2) perturb_path = cls._resolve_optional_advshape_path(advcp_config.get("remove_adv_shape_perturb_path")) if perturb_path is not None and perturb_path.exists(): @@ -1049,25 +1053,12 @@ def _resolve_remove_adv_shape_divide( @staticmethod def _default_adv_shape_divide(template_mesh: Any) -> list[npt.NDArray]: - vertices = np.asarray(template_mesh.vertices, dtype=np.float64) - bbox = np.array([4.9, 2.5, 2.0], dtype=np.float64) - return [ - np.argwhere(vertices[:, 0] > bbox[0] / 2.0 - 0.01).reshape(-1), - np.argwhere(vertices[:, 0] < -bbox[0] / 2.0 + 0.01).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] >= 0.0, vertices[:, 1] > bbox[1] / 2.0 - 0.01)).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] <= 0.0, vertices[:, 1] > bbox[1] / 2.0 - 0.01)).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] >= 0.0, vertices[:, 1] < -bbox[1] / 2.0 + 0.01)).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] <= 0.0, vertices[:, 1] < -bbox[1] / 2.0 + 0.01)).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] >= 0.0, vertices[:, 2] > bbox[2] - 0.01)).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] <= 0.0, vertices[:, 2] > bbox[2] - 0.01)).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] >= 0.0, vertices[:, 2] < 0.01)).reshape(-1), - np.argwhere(np.logical_and(vertices[:, 0] <= 0.0, vertices[:, 2] < 0.01)).reshape(-1), - ] + return advshape_default_divide(np.asarray(template_mesh.vertices, dtype=np.float64)) @staticmethod def _apply_adv_shape_transform(mesh_pieces: list[Any], removal_box: npt.NDArray) -> list[Any]: transformed_meshes = [] - reference_size = np.array([4.9, 2.5, 2.0], dtype=np.float64) + reference_size = np.asarray(ADVSHAPE_TEMPLATE_DIMENSIONS_M, dtype=np.float64) scale = float(np.max((np.asarray(removal_box[3:6], dtype=np.float64) + 0.6) / reference_size)) rotation = np.array( [ diff --git a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py index d31b0f44..63928f1c 100644 --- a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py +++ b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py @@ -32,6 +32,7 @@ from __future__ import annotations +import hashlib import pickle import struct import sys @@ -44,10 +45,12 @@ from opencda.core.attack.advcp.utils.asset_utils import ( MeshData, + advshape_default_divide, advshape_template_mesh, blueprint_dimensions_m, box_mesh, copy_or_generate_mesh, + divide_piece_face_counts, dump_divide_pickle, dump_generation_metadata, generate_divide_indices, @@ -62,10 +65,10 @@ scale_to_dimensions, subdivide_midpoint, validate_divide_indices, + validate_divide_pieces, validate_mesh, validate_mesh_frame_and_scale, write_ascii_ply, - write_mesh, ) @@ -87,6 +90,12 @@ def sample_box_mesh() -> MeshData: return box_mesh(4.3, 1.91, 1.26) +@pytest.fixture +def sample_car_mesh() -> MeshData: + """The default generated (subdivided) car mesh for Tesla Model 3 dimensions.""" + return copy_or_generate_mesh(None, dimensions=(4.3, 1.91, 1.26), preserve_aspect=False) + + @pytest.fixture def sample_vertices() -> np.ndarray: """8 vertices of a unit cube at the origin.""" @@ -245,6 +254,82 @@ def test_face_count(self) -> None: assert mesh.faces.shape[0] == 192 # 12 * 4 * 4 +class TestOpen3DCompatibility: + """Index compatibility with the Open3D meshes built by the AdvCP runtime. + + Open3D is mocked in the unit-test environment, so the expected values + below were captured from ``open3d`` 0.18.0 and 0.19.0 + (``TriangleMesh.create_box`` + ``subdivide_midpoint``). + """ + + # Open3D ``create_box(1, 1, 1)`` vertex and triangle layout. + O3D_UNIT_BOX_VERTICES = [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 0.0, 1.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + ] + O3D_UNIT_BOX_TRIANGLES = [ + [4, 7, 5], + [4, 6, 7], + [0, 2, 4], + [2, 6, 4], + [0, 1, 2], + [1, 3, 2], + [1, 5, 7], + [1, 7, 3], + [2, 3, 7], + [2, 7, 6], + [0, 4, 1], + [1, 4, 5], + ] + # First vertices / triangles created by Open3D ``subdivide_midpoint(1)`` on the unit box. + O3D_UNIT_BOX_SUBDIV1_NEW_VERTICES = [[0.5, 1.0, 0.5], [1.0, 1.0, 0.5], [0.5, 1.0, 0.0], [0.0, 1.0, 0.5], [0.5, 1.0, 1.0], [0.0, 0.0, 0.5]] + O3D_UNIT_BOX_SUBDIV1_TRIANGLES = [[4, 8, 10], [8, 7, 9], [9, 5, 10], [8, 9, 10], [4, 11, 8], [11, 6, 12], [12, 7, 8], [11, 12, 8]] + # sha256 of the Open3D adv-shape template (create_box(4.9, 2.5, 2.0) centred in XY, subdivide_midpoint(2)). + O3D_ADVSHAPE_TEMPLATE_SHA256 = "18eadf9af6766859aa88b9ef1f51472aaac6dd7eb2aabf110d919d3d528cc113" + + @staticmethod + def _shift_to_origin_corner(mesh: MeshData, length: float, width: float) -> np.ndarray: + return mesh.vertices + np.array([length / 2.0, width / 2.0, 0.0]) + + def test_box_layout_matches_open3d(self) -> None: + mesh = box_mesh(1.0, 1.0, 1.0) + np.testing.assert_allclose(self._shift_to_origin_corner(mesh, 1.0, 1.0), self.O3D_UNIT_BOX_VERTICES) + np.testing.assert_array_equal(mesh.faces, self.O3D_UNIT_BOX_TRIANGLES) + + def test_box_normals_point_outward(self, sample_box_mesh: MeshData) -> None: + tri = sample_box_mesh.vertices[sample_box_mesh.faces] + normals = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]) + center = np.array([0.0, 0.0, sample_box_mesh.vertices[:, 2].max() / 2.0]) + outward = tri.mean(axis=1) - center + assert np.all(np.sum(normals * outward, axis=1) > 0) + + def test_subdivision_order_matches_open3d(self) -> None: + mesh = subdivide_midpoint(box_mesh(1.0, 1.0, 1.0), levels=1) + vertices = self._shift_to_origin_corner(mesh, 1.0, 1.0) + np.testing.assert_allclose(vertices[8:14], self.O3D_UNIT_BOX_SUBDIV1_NEW_VERTICES) + np.testing.assert_array_equal(mesh.faces[:8], self.O3D_UNIT_BOX_SUBDIV1_TRIANGLES) + + def test_advshape_template_matches_open3d(self) -> None: + template = advshape_template_mesh() + digest = hashlib.sha256(np.round(template.vertices, 6).astype(" None: + """Generated removal divide must equal the partition the runtime uses by default.""" + template = advshape_template_mesh() + generated = generate_divide_indices(template.vertices, "remove") + runtime_default = advshape_default_divide(template.vertices) + assert len(generated) == len(runtime_default) == 10 + for generated_group, runtime_group in zip(generated, runtime_default): + np.testing.assert_array_equal(generated_group, runtime_group) + + # ========================================================================= # Mesh transforms # ========================================================================= @@ -369,6 +454,34 @@ def test_empty_group_fallback(self) -> None: assert g[0] == 0 +class TestDividePieces: + """Every divide group must yield a non-empty mesh piece.""" + + def test_default_car_mesh_spoof_pieces_non_empty(self, sample_car_mesh: MeshData) -> None: + counts = divide_piece_face_counts(generate_divide_indices(sample_car_mesh.vertices, "spoof"), sample_car_mesh.faces) + assert len(counts) == 8 + assert all(count > 0 for count in counts) + + def test_template_remove_pieces_non_empty(self) -> None: + template = advshape_template_mesh() + counts = divide_piece_face_counts(generate_divide_indices(template.vertices, "remove"), template.faces) + assert counts == [32, 32, 16, 16, 16, 16, 16, 16, 16, 16] + + def test_bare_box_spoof_pieces_rejected(self, sample_box_mesh: MeshData) -> None: + groups = generate_divide_indices(sample_box_mesh.vertices, "spoof") + assert divide_piece_face_counts(groups, sample_box_mesh.faces)[6:] == [0, 0] + with pytest.raises(ValueError, match=r"groups \[6, 7\] contain no complete triangles"): + validate_divide_pieces(groups, sample_box_mesh.faces, "Spoof divide") + + def test_validate_passes(self, sample_car_mesh: MeshData) -> None: + groups = generate_divide_indices(sample_car_mesh.vertices, "spoof") + validate_divide_pieces(groups, sample_car_mesh.faces, "Spoof divide") + + def test_default_generated_mesh_is_subdivided(self, sample_car_mesh: MeshData) -> None: + assert sample_car_mesh.vertices.shape == (98, 3) + assert sample_car_mesh.faces.shape == (192, 3) + + # ========================================================================= # Mesh I/O # ========================================================================= @@ -416,9 +529,7 @@ def test_read_binary_ply(self, tmp_output: Path) -> None: """Write a minimal binary PLY with 4 vertices and 4 faces, then read it back.""" path = tmp_output / "binary.ply" # A tetrahedron: 4 vertices, 4 faces - vertices = np.array( - [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float64 - ) + vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float64) faces = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]], dtype=np.int32) with open(path, "wb") as f: f.write(b"ply\n") @@ -447,6 +558,113 @@ def test_invalid_format_raises(self, tmp_output: Path) -> None: read_ply(path) +class TestReadPlyHeaderDriven: + """PLY parsing driven by the declared property types and order.""" + + @staticmethod + def _write_binary_ply( + path: Path, + vertex_props: list[tuple[str, str]], + vertex_rows: list[tuple[Any, ...]], + faces: list[list[int]], + byte_order: str = "<", + count_type: str = "uchar", + index_type: str = "int", + face_extra: tuple[str, str] | None = None, + ) -> None: + codes = {"float": "f", "double": "d", "uchar": "B", "int": "i", "uint": "I", "ushort": "H", "short": "h"} + fmt_name = "binary_little_endian" if byte_order == "<" else "binary_big_endian" + header = ["ply", f"format {fmt_name} 1.0", "comment test", f"element vertex {len(vertex_rows)}"] + header += [f"property {ptype} {pname}" for pname, ptype in vertex_props] + header += [f"element face {len(faces)}", f"property list {count_type} {index_type} vertex_indices"] + if face_extra is not None: + header.append(f"property {face_extra[1]} {face_extra[0]}") + header.append("end_header") + with open(path, "wb") as handle: + handle.write(("\n".join(header) + "\n").encode("ascii")) + vertex_fmt = byte_order + "".join(codes[ptype] for _, ptype in vertex_props) + for row in vertex_rows: + handle.write(struct.pack(vertex_fmt, *row)) + for face in faces: + handle.write(struct.pack(byte_order + codes[count_type], len(face))) + handle.write(struct.pack(byte_order + codes[index_type] * len(face), *face)) + if face_extra is not None: + handle.write(struct.pack(byte_order + codes[face_extra[1]], 7)) + + TETRA_XYZ = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)] + TETRA_FACES = [[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]] + + def test_binary_float32_with_normals_and_colors(self, tmp_output: Path) -> None: + path = tmp_output / "float_normals_colors.ply" + props = [("x", "float"), ("y", "float"), ("z", "float"), ("nx", "float"), ("ny", "float"), ("nz", "float")] + props += [("red", "uchar"), ("green", "uchar"), ("blue", "uchar")] + rows = [(*xyz, 0.0, 0.0, 1.0, 255, 128, 0) for xyz in self.TETRA_XYZ] + self._write_binary_ply(path, props, rows, self.TETRA_FACES) + mesh = read_ply(path) + np.testing.assert_allclose(mesh.vertices, self.TETRA_XYZ) + np.testing.assert_array_equal(mesh.faces, self.TETRA_FACES) + + def test_binary_big_endian(self, tmp_output: Path) -> None: + path = tmp_output / "big_endian.ply" + props = [("x", "double"), ("y", "double"), ("z", "double")] + self._write_binary_ply(path, props, self.TETRA_XYZ, self.TETRA_FACES, byte_order=">", index_type="uint") + mesh = read_ply(path) + np.testing.assert_allclose(mesh.vertices, self.TETRA_XYZ) + np.testing.assert_array_equal(mesh.faces, self.TETRA_FACES) + + def test_binary_property_order_and_face_extras(self, tmp_output: Path) -> None: + """Coordinates declared out of order, extra face property after the index list.""" + path = tmp_output / "reordered.ply" + props = [("z", "float"), ("confidence", "float"), ("x", "float"), ("y", "float")] + rows = [(z, 0.5, x, y) for x, y, z in self.TETRA_XYZ] + self._write_binary_ply(path, props, rows, self.TETRA_FACES, count_type="ushort", face_extra=("material", "uchar")) + mesh = read_ply(path) + np.testing.assert_allclose(mesh.vertices, self.TETRA_XYZ) + np.testing.assert_array_equal(mesh.faces, self.TETRA_FACES) + + def test_binary_mixed_polygons_are_triangulated(self, tmp_output: Path) -> None: + path = tmp_output / "quads.ply" + props = [("x", "float"), ("y", "float"), ("z", "float")] + xyz = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)] + faces = [[0, 1, 2, 3], [0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]] + self._write_binary_ply(path, props, xyz, faces) + mesh = read_ply(path) + assert mesh.faces.shape == (6, 3) + np.testing.assert_array_equal(mesh.faces[:2], [[0, 1, 2], [0, 2, 3]]) + + def test_ascii_polygons_and_extra_properties(self, tmp_output: Path) -> None: + path = tmp_output / "ascii_quads.ply" + with open(path, "w") as handle: + handle.write("ply\nformat ascii 1.0\nelement vertex 5\nproperty float x\nproperty float y\nproperty float z\n") + handle.write("property uchar red\nelement face 5\nproperty list uchar int vertex_indices\nproperty int flags\nend_header\n") + handle.write("0 0 0 1\n1 0 0 2\n1 1 0 3\n0 1 0 4\n0.5 0.5 1 5\n") + handle.write("4 0 1 2 3 9\n3 0 1 4 9\n3 1 2 4 9\n3 2 3 4 9\n3 3 0 4 9\n") + mesh = read_ply(path) + assert mesh.vertices.shape == (5, 3) + assert mesh.faces.shape == (6, 3) + + def test_truncated_binary_raises(self, tmp_output: Path) -> None: + path = tmp_output / "truncated.ply" + props = [("x", "float"), ("y", "float"), ("z", "float")] + self._write_binary_ply(path, props, self.TETRA_XYZ, self.TETRA_FACES) + path.write_bytes(path.read_bytes()[:-6]) + with pytest.raises(ValueError, match="Unexpected EOF"): + read_ply(path) + + def test_missing_coordinates_raises(self, tmp_output: Path) -> None: + path = tmp_output / "no_z.ply" + props = [("x", "float"), ("y", "float"), ("w", "float")] + self._write_binary_ply(path, props, self.TETRA_XYZ, self.TETRA_FACES) + with pytest.raises(ValueError, match="x, y and z"): + read_ply(path) + + def test_unknown_property_type_raises(self, tmp_output: Path) -> None: + path = tmp_output / "bad_type.ply" + path.write_text("ply\nformat ascii 1.0\nelement vertex 1\nproperty quad x\nend_header\n0\n") + with pytest.raises(ValueError, match="unsupported property type"): + read_ply(path) + + class TestReadObj: """OBJ file reading.""" @@ -709,6 +927,7 @@ class TestGenerateCarMeshCLI: def test_generates_ply(self, tmp_output: Path) -> None: out = tmp_output / "car.ply" from opencda.core.attack.advcp.utils.generate_car_mesh import main + sys.argv = ["generate_car_mesh", "--vehicle-blueprint", "vehicle.tesla.model3", "--output", str(out)] main() assert out.exists() @@ -719,10 +938,15 @@ def test_with_custom_dimensions(self, tmp_output: Path) -> None: out = tmp_output / "car.ply" sys.argv = [ "generate_car_mesh", - "--dimensions", "5.0", "2.0", "1.8", - "--output", str(out), + "--dimensions", + "5.0", + "2.0", + "1.8", + "--output", + str(out), ] from opencda.core.attack.advcp.utils.generate_car_mesh import main + main() mesh = read_mesh(out) size = mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0) @@ -732,27 +956,41 @@ def test_with_custom_dimensions(self, tmp_output: Path) -> None: class TestGenerateMeshDivideCLI: """Smoke tests for generate_mesh_divide CLI.""" - def test_generates_spoof_pkl(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + def test_generates_spoof_pkl(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_box_mesh, mesh_path) + write_ascii_ply(sample_car_mesh, mesh_path) out = tmp_output / "divide.pkl" sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "spoof", "--output", str(out)] from opencda.core.attack.advcp.utils.generate_mesh_divide import main + main() assert out.exists() loaded = load_divide_pickle(out) assert len(loaded) == 8 - def test_generates_remove_pkl(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + def test_generates_remove_pkl(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_box_mesh, mesh_path) + write_ascii_ply(sample_car_mesh, mesh_path) out = tmp_output / "divide.pkl" sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "remove", "--output", str(out)] from opencda.core.attack.advcp.utils.generate_mesh_divide import main + main() loaded = load_divide_pickle(out) assert len(loaded) == 10 + def test_rejects_divide_with_empty_pieces(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + """A bare 8-vertex box cannot be split into 8 non-empty spoof pieces.""" + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + out = tmp_output / "divide.pkl" + sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "spoof", "--output", str(out)] + from opencda.core.attack.advcp.utils.generate_mesh_divide import main + + with pytest.raises(ValueError, match="no complete triangles"): + main() + assert not out.exists() + class TestGenerateRemoveAdvshapeAssetsCLI: """Smoke tests for generate_remove_advshape_assets CLI.""" @@ -761,10 +999,13 @@ def test_generates_divide(self, tmp_output: Path) -> None: out = tmp_output / "divide.pkl" sys.argv = [ "generate_remove_advshape_assets", - "--mode", "divide", - "--output", str(out), + "--mode", + "divide", + "--output", + str(out), ] from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() assert out.exists() loaded = load_divide_pickle(out) @@ -774,10 +1015,13 @@ def test_generates_perturb(self, tmp_output: Path) -> None: out = tmp_output / "perturb.npy" sys.argv = [ "generate_remove_advshape_assets", - "--mode", "perturb", - "--output", str(out), + "--mode", + "perturb", + "--output", + str(out), ] from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() assert out.exists() loaded = np.load(out) @@ -787,13 +1031,18 @@ def test_generates_random_perturb(self, tmp_output: Path) -> None: out = tmp_output / "perturb.npy" sys.argv = [ "generate_remove_advshape_assets", - "--mode", "perturb", + "--mode", + "perturb", "--random", - "--seed", "42", - "--perturb-scale", "0.3", - "--output", str(out), + "--seed", + "42", + "--perturb-scale", + "0.3", + "--output", + str(out), ] from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() loaded = np.load(out) assert loaded.min() >= -0.3 @@ -804,11 +1053,15 @@ def test_generates_both(self, tmp_output: Path) -> None: pert_out = tmp_output / "perturb.npy" sys.argv = [ "generate_remove_advshape_assets", - "--mode", "both", - "--divide-output", str(div_out), - "--perturb-output", str(pert_out), + "--mode", + "both", + "--divide-output", + str(div_out), + "--perturb-output", + str(pert_out), ] from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main + main() assert div_out.exists() assert pert_out.exists() @@ -822,36 +1075,62 @@ def test_validate_car_mesh_only(self, sample_box_mesh: MeshData, tmp_output: Pat write_ascii_ply(sample_box_mesh, mesh_path) sys.argv = [ "validate_advcp_assets", - "--car-mesh", str(mesh_path), + "--car-mesh", + str(mesh_path), ] from opencda.core.attack.advcp.utils.validate_advcp_assets import main + main() # should not raise - def test_validate_all_assets(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + def test_validate_all_assets(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_box_mesh, mesh_path) + write_ascii_ply(sample_car_mesh, mesh_path) spoof_path = tmp_output / "spoof.pkl" - dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "spoof"), spoof_path) + dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "spoof"), spoof_path) remove_path = tmp_output / "remove.pkl" - dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "remove"), remove_path) + dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "remove"), remove_path) perturb_path = tmp_output / "perturb.npy" - save_perturbation(perturb_path, np.zeros((sample_box_mesh.vertices.shape[0], 3), dtype=np.float32)) + save_perturbation(perturb_path, np.zeros((sample_car_mesh.vertices.shape[0], 3), dtype=np.float32)) sys.argv = [ "validate_advcp_assets", - "--car-mesh", str(mesh_path), - "--spoof-divide", str(spoof_path), - "--remove-divide", str(remove_path), - "--remove-perturb", str(perturb_path), + "--car-mesh", + str(mesh_path), + "--spoof-divide", + str(spoof_path), + "--remove-divide", + str(remove_path), + "--remove-perturb", + str(perturb_path), ] from opencda.core.attack.advcp.utils.validate_advcp_assets import main + main() + def test_validate_fails_on_empty_spoof_pieces(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + spoof_path = tmp_output / "spoof.pkl" + dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "spoof"), spoof_path) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", + str(mesh_path), + "--spoof-divide", + str(spoof_path), + ] + from opencda.core.attack.advcp.utils.validate_advcp_assets import main + + with pytest.raises(SystemExit): + main() + def test_validate_fails_on_missing_file(self, tmp_output: Path) -> None: sys.argv = [ "validate_advcp_assets", - "--car-mesh", str(tmp_output / "nonexistent.ply"), + "--car-mesh", + str(tmp_output / "nonexistent.ply"), ] from opencda.core.attack.advcp.utils.validate_advcp_assets import main + with pytest.raises(SystemExit): main() @@ -860,10 +1139,13 @@ def test_validate_fails_on_wrong_vertex_count(self, sample_box_mesh: MeshData, t write_ascii_ply(sample_box_mesh, mesh_path) sys.argv = [ "validate_advcp_assets", - "--car-mesh", str(mesh_path), - "--expected-vertices", "9999", + "--car-mesh", + str(mesh_path), + "--expected-vertices", + "9999", ] from opencda.core.attack.advcp.utils.validate_advcp_assets import main + with pytest.raises(SystemExit): main() @@ -1014,4 +1296,4 @@ def test_scale_to_dimensions_preserve_aspect_smaller(self) -> None: size = scaled.vertices.max(axis=0) - scaled.vertices.min(axis=0) # target/size = [8/4, 8/2, 8/1] = [2, 4, 8], min = 2 # uniform scale = 2, result = [8, 4, 2] - np.testing.assert_allclose(size, [8.0, 4.0, 2.0], atol=1e-10) \ No newline at end of file + np.testing.assert_allclose(size, [8.0, 4.0, 2.0], atol=1e-10) diff --git a/opencda/core/attack/advcp/utils/__init__.py b/opencda/core/attack/advcp/utils/__init__.py index 0c00b8d1..20daeb70 100644 --- a/opencda/core/attack/advcp/utils/__init__.py +++ b/opencda/core/attack/advcp/utils/__init__.py @@ -21,4 +21,3 @@ Core mesh, divide, and perturbation helpers (read, write, validate, transform). """ - diff --git a/opencda/core/attack/advcp/utils/asset_utils.py b/opencda/core/attack/advcp/utils/asset_utils.py index addfca74..906d7600 100644 --- a/opencda/core/attack/advcp/utils/asset_utils.py +++ b/opencda/core/attack/advcp/utils/asset_utils.py @@ -6,7 +6,7 @@ - :class:`MeshData` — lightweight container for vertices and faces. - Mesh construction: :func:`box_mesh`, :func:`subdivide_midpoint`, - :func:`advshape_template_mesh`. + :func:`advshape_template_mesh`, :func:`advshape_default_divide`. - Mesh I/O: :func:`read_mesh`, :func:`read_ply`, :func:`read_obj`, :func:`write_ascii_ply`, :func:`write_mesh`. - Mesh transforms: :func:`normalize_bottom_center`, @@ -14,7 +14,7 @@ - Divide-index generation and I/O: :func:`generate_divide_indices`, :func:`dump_divide_pickle`, :func:`load_divide_pickle`. - Validation: :func:`validate_mesh`, :func:`validate_mesh_frame_and_scale`, - :func:`validate_divide_indices`. + :func:`validate_divide_indices`, :func:`validate_divide_pieces`. - Perturbation I/O: :func:`save_perturbation`, :func:`load_perturbation`. - Blueprint dimension lookup: :func:`blueprint_dimensions_m`, :func:`parse_dimensions_arg`. @@ -25,7 +25,6 @@ from dataclasses import dataclass import pickle from pathlib import Path -import struct from typing import Any, Iterable import numpy as np @@ -41,6 +40,20 @@ } _DEFAULT_DIMENSIONS_M = (4.30, 1.91, 1.26) +# Canonical AdvCP adversarial-shape template (shared with the runtime +# consumer in ``early_fusion_attack``; perturbation and divide assets are +# indexed against this exact vertex ordering). +ADVSHAPE_TEMPLATE_DIMENSIONS_M = (4.9, 2.5, 2.0) +ADVSHAPE_TEMPLATE_SUBDIVISION_LEVELS = 2 + +# Subdivision applied to generated box car meshes so that every divide +# group keeps whole triangles (a bare 8-vertex box yields empty pieces). +DEFAULT_CAR_MESH_SUBDIVISION_LEVELS = 2 + +# Absolute tolerance (meters) of the removal divide rule; matches the +# AdvCP runtime default partition. +_REMOVE_DIVIDE_TOLERANCE_M = 0.01 + @dataclass(frozen=True) class MeshData: @@ -111,6 +124,12 @@ def parse_dimensions_arg(raw_dimensions: Iterable[float] | None) -> tuple[float, def box_mesh(length: float, width: float, height: float) -> MeshData: """Build an axis-aligned box mesh with the given dimensions. + Vertex and triangle ordering follow Open3D's + ``TriangleMesh.create_box`` so that meshes built here are + index-compatible with meshes built by the AdvCP runtime. Triangles + are wound counter-clockwise when viewed from outside (outward + normals). + The box is centred in XY and sits on the Z=0 plane (bottom face at Z=0, top face at Z=*height*). @@ -135,29 +154,29 @@ def box_mesh(length: float, width: float, height: float) -> MeshData: [ [x0, y0, z0], [x1, y0, z0], - [x1, y1, z0], - [x0, y1, z0], [x0, y0, z1], [x1, y0, z1], - [x1, y1, z1], + [x0, y1, z0], + [x1, y1, z0], [x0, y1, z1], + [x1, y1, z1], ], dtype=np.float64, ) faces = np.asarray( [ + [4, 7, 5], + [4, 6, 7], # +y + [0, 2, 4], + [2, 6, 4], # -x [0, 1, 2], - [0, 2, 3], # bottom - [4, 6, 5], - [4, 7, 6], # top - [0, 5, 1], - [0, 4, 5], # -y - [1, 6, 2], - [1, 5, 6], # +x - [2, 7, 3], - [2, 6, 7], # +y - [3, 4, 0], - [3, 7, 4], # -x + [1, 3, 2], # -y + [1, 5, 7], + [1, 7, 3], # +x + [2, 3, 7], + [2, 7, 6], # +z (top) + [0, 4, 1], + [1, 4, 5], # -z (bottom) ], dtype=np.int32, ) @@ -237,7 +256,9 @@ def subdivide_midpoint(mesh: MeshData, levels: int) -> MeshData: Each subdivision level splits every triangle into 4 smaller triangles by inserting a vertex at the midpoint of each edge. - Shared edges reuse the same midpoint vertex. + Shared edges reuse the same midpoint vertex. New vertices and + triangles are emitted in the same order as Open3D's + ``TriangleMesh.subdivide_midpoint``. Parameters ---------- @@ -255,7 +276,7 @@ def subdivide_midpoint(mesh: MeshData, levels: int) -> MeshData: return mesh vertices = [tuple(vertex) for vertex in np.asarray(mesh.vertices, dtype=np.float64)] - faces = [tuple(int(index) for index in face) for face in np.asarray(mesh.faces, dtype=np.int32)] + faces: list[tuple[int, int, int]] = [(int(i0), int(i1), int(i2)) for i0, i1, i2 in np.asarray(mesh.faces, dtype=np.int32)] for _ in range(levels): edge_midpoint_cache: dict[tuple[int, int], int] = {} @@ -278,8 +299,8 @@ def midpoint_index(a: int, b: int) -> int: new_faces.extend( [ (i0, m01, m20), - (i1, m12, m01), - (i2, m20, m12), + (m01, i1, m12), + (m12, i2, m20), (m01, m12, m20), ] ) @@ -292,17 +313,93 @@ def advshape_template_mesh() -> MeshData: """Return the default AdvCP adversarial-shape template mesh. The template is a 4.9 x 2.5 x 2.0 meter box subdivided twice via - midpoint insertion, producing a mesh with approximately 98 vertices - and 192 faces. This provides enough geometric resolution for - per-vertex adversarial perturbations. + midpoint insertion, producing a mesh with 98 vertices and 192 + faces. This provides enough geometric resolution for per-vertex + adversarial perturbations. + + This is the single source of truth for the template: the AdvCP + runtime builds its removal mesh from this function, so the + positional ``.npy`` perturbations and ``.pkl`` divide indices + generated against it address the same vertices at runtime. The + vertex ordering matches Open3D's ``create_box`` followed by + ``subdivide_midpoint(2)``. Returns ------- MeshData The AdvCP template mesh. """ - template = box_mesh(4.9, 2.5, 2.0) - return subdivide_midpoint(template, levels=2) + template = box_mesh(*ADVSHAPE_TEMPLATE_DIMENSIONS_M) + return subdivide_midpoint(template, levels=ADVSHAPE_TEMPLATE_SUBDIVISION_LEVELS) + + +def advshape_default_divide(vertices: npt.NDArray[np.float64]) -> list[npt.NDArray[np.int32]]: + """Return the default removal partition of the adv-shape template. + + Groups are computed against the fixed template bounding box (not + the vertices' own extents), so the partition is stable when a + perturbation has already been applied to *vertices*. This is the + rule the AdvCP runtime uses when no ``remove_adv_shape_divide_path`` + is configured. + + Parameters + ---------- + vertices : ndarray of float64, shape (N, 3) + Template vertex coordinates (optionally perturbed). + + Returns + ------- + list of ndarray of int32 + 10 vertex-index groups (see :func:`generate_divide_indices`). + """ + length, width, height = ADVSHAPE_TEMPLATE_DIMENSIONS_M + mins = np.array([-length / 2.0, -width / 2.0, 0.0], dtype=np.float64) + maxs = np.array([length / 2.0, width / 2.0, height], dtype=np.float64) + return _remove_divide_groups(np.asarray(vertices, dtype=np.float64), mins, maxs) + + +def _remove_divide_groups( + coords: npt.NDArray[np.float64], + mins: npt.NDArray[np.float64], + maxs: npt.NDArray[np.float64], +) -> list[npt.NDArray[np.int32]]: + """Partition vertices into the 10 removal groups for a bounding box. + + Groups are: front face, back face, then the front/back halves of + the left side, right side, top and bottom faces. Vertices on the + ``x = mid`` plane belong to both halves. + + Parameters + ---------- + coords : ndarray of float64, shape (N, 3) + Vertex coordinates. + mins, maxs : ndarray of float64, shape (3,) + Bounding box used for the face thresholds. + + Returns + ------- + list of ndarray of int32 + 10 (possibly empty) vertex-index groups. + """ + tol = _REMOVE_DIVIDE_TOLERANCE_M + x, y, z = coords[:, 0], coords[:, 1], coords[:, 2] + mid_x = (mins[0] + maxs[0]) / 2.0 + front, back = x >= mid_x, x <= mid_x + left, right = y > maxs[1] - tol, y < mins[1] + tol + top, bottom = z > maxs[2] - tol, z < mins[2] + tol + masks = [ + x > maxs[0] - tol, + x < mins[0] + tol, + front & left, + back & left, + front & right, + back & right, + front & top, + back & top, + front & bottom, + back & bottom, + ] + return [np.argwhere(mask).reshape(-1).astype(np.int32) for mask in masks] def generate_divide_indices(vertices: npt.NDArray[np.float64], mode: str) -> list[npt.NDArray[np.int32]]: @@ -314,7 +411,14 @@ def generate_divide_indices(vertices: npt.NDArray[np.float64], mode: str) -> lis depends on *mode*: - ``"spoof"`` — 8 groups (extremal faces and quadrants). - - ``"remove"`` — 10 groups (finer spatial partitioning). + - ``"remove"`` — 10 groups (front/back faces and front/back halves + of the side, top and bottom faces). For the adv-shape template + this is identical to :func:`advshape_default_divide`, i.e. the + partition the AdvCP runtime uses by default. + + The groups only reference vertices; use + :func:`validate_divide_pieces` to check that every group also + retains whole triangles of the source mesh. Parameters ---------- @@ -360,18 +464,7 @@ def generate_divide_indices(vertices: npt.NDArray[np.float64], mode: str) -> lis np.argwhere(np.logical_and(x < mids[0], z >= mids[2])).reshape(-1), ] else: - groups = [ - np.argwhere(x >= maxs[0] - eps[0]).reshape(-1), - np.argwhere(x <= mins[0] + eps[0]).reshape(-1), - np.argwhere(np.logical_and(x >= mids[0], y >= maxs[1] - eps[1])).reshape(-1), - np.argwhere(np.logical_and(x < mids[0], y >= maxs[1] - eps[1])).reshape(-1), - np.argwhere(np.logical_and(x >= mids[0], y <= mins[1] + eps[1])).reshape(-1), - np.argwhere(np.logical_and(x < mids[0], y <= mins[1] + eps[1])).reshape(-1), - np.argwhere(np.logical_and(x >= mids[0], z >= maxs[2] - eps[2])).reshape(-1), - np.argwhere(np.logical_and(x < mids[0], z >= maxs[2] - eps[2])).reshape(-1), - np.argwhere(np.logical_and(x >= mids[0], z <= mins[2] + eps[2])).reshape(-1), - np.argwhere(np.logical_and(x < mids[0], z <= mins[2] + eps[2])).reshape(-1), - ] + groups = _remove_divide_groups(coords, mins, maxs) all_indices = np.arange(coords.shape[0], dtype=np.int32) normalized_groups: list[npt.NDArray[np.int32]] = [] @@ -456,9 +549,68 @@ def validate_divide_indices(indices: list[npt.NDArray[np.int32]], vertex_count: if np.any(group < 0) or np.any(group >= vertex_count): min_index = int(group.min()) if group.size else -1 max_index = int(group.max()) if group.size else -1 - raise ValueError( - f"{name}[{group_index}] has invalid vertex indices: min={min_index}, max={max_index}, vertex_count={vertex_count}." - ) + raise ValueError(f"{name}[{group_index}] has invalid vertex indices: min={min_index}, max={max_index}, vertex_count={vertex_count}.") + + +def divide_piece_face_counts(indices: list[npt.NDArray[np.int32]], faces: npt.NDArray[np.int32]) -> list[int]: + """Count the triangles each divide group retains. + + A triangle belongs to a group when all three of its vertices are in + the group; this mirrors Open3D's ``TriangleMesh.select_by_index``, + which the AdvCP runtime uses to cut the mesh into pieces. + + Parameters + ---------- + indices : list of ndarray of int32 + Vertex-group index arrays. + faces : ndarray of int32, shape (M, 3) + Triangle indices of the mesh the groups refer to. + + Returns + ------- + list of int + Number of whole triangles per group. + """ + faces = np.asarray(faces, dtype=np.int64) + vertex_count = int(faces.max()) + 1 if faces.size else 0 + counts: list[int] = [] + for group in indices: + group = np.asarray(group, dtype=np.int64) + member = np.zeros(max(vertex_count, int(group.max()) + 1 if group.size else 0), dtype=bool) + member[group] = True + counts.append(int(np.all(member[faces], axis=1).sum()) if faces.size else 0) + return counts + + +def validate_divide_pieces(indices: list[npt.NDArray[np.int32]], faces: npt.NDArray[np.int32], name: str) -> None: + """Validate that every divide group yields a non-empty mesh piece. + + The AdvCP runtime builds one ray-casting mesh per group; a group + without whole triangles produces an empty mesh that Open3D's + ``RaycastingScene`` rejects. + + Parameters + ---------- + indices : list of ndarray of int32 + Vertex-group index arrays (already bounds-checked, see + :func:`validate_divide_indices`). + faces : ndarray of int32, shape (M, 3) + Triangle indices of the mesh the groups refer to. + name : str + Human-readable name for error messages. + + Raises + ------ + ValueError + If any group retains no triangles. + """ + counts = divide_piece_face_counts(indices, faces) + empty_groups = [group_index for group_index, count in enumerate(counts) if count == 0] + if empty_groups: + raise ValueError( + f"{name} groups {empty_groups} contain no complete triangles (faces per group: {counts}); " + "the resulting mesh pieces would be empty. Use a denser mesh (e.g. subdivide it) or a different partition." + ) def write_ascii_ply(mesh: MeshData, output_path: Path) -> None: @@ -571,11 +723,97 @@ def read_obj(path: Path) -> MeshData: return mesh -def _parse_ply_header(path: Path) -> tuple[list[str], str, int, int, int]: +_PLY_SCALAR_TYPES = { + "char": "i1", + "int8": "i1", + "uchar": "u1", + "uint8": "u1", + "short": "i2", + "int16": "i2", + "ushort": "u2", + "uint16": "u2", + "int": "i4", + "int32": "i4", + "uint": "u4", + "uint32": "u4", + "float": "f4", + "float32": "f4", + "double": "f8", + "float64": "f8", +} +_PLY_FACE_INDEX_PROPERTIES = ("vertex_indices", "vertex_index") + + +@dataclass(frozen=True) +class _PlyProperty: + """A single property declaration from a PLY header. + + Attributes + ---------- + name : str + Property name (e.g. ``"x"`` or ``"vertex_indices"``). + value_type : str + NumPy type code of the (list item) value, without byte order. + count_type : str or None + NumPy type code of the list length prefix for list properties, + ``None`` for scalar properties. + """ + + name: str + value_type: str + count_type: str | None = None + + +@dataclass(frozen=True) +class _PlyElement: + """An element declaration (``vertex``, ``face``, ...) from a PLY header. + + Attributes + ---------- + name : str + Element name. + count : int + Number of rows declared for the element. + properties : tuple of _PlyProperty + Property declarations in header order. + """ + + name: str + count: int + properties: tuple[_PlyProperty, ...] + + +def _ply_type(raw_type: str, path: Path) -> str: + """Map a PLY type name to a NumPy type code. + + Parameters + ---------- + raw_type : str + PLY type name (``float``, ``uchar``, ``int32``, ...). + path : Path + Source path (used for error messages only). + + Returns + ------- + str + NumPy type code without byte order (e.g. ``"f4"``). + + Raises + ------ + ValueError + If the type name is unknown. + """ + try: + return _PLY_SCALAR_TYPES[raw_type] + except KeyError: + raise ValueError(f"PLY file '{path}' uses unsupported property type '{raw_type}'.") from None + + +def _parse_ply_header(path: Path) -> tuple[str, list[_PlyElement], int]: """Parse the header of a PLY file. - Reads header lines until ``end_header``, then returns the header - metadata and the byte offset where vertex data begins. + Reads header lines until ``end_header`` and collects the declared + elements together with their property names and types. Parameters ---------- @@ -585,13 +823,16 @@ def _parse_ply_header(path: Path) -> tuple[list[str], str, int, int, int]: Returns ------- tuple - ``(header_lines, format_line, vertex_count, face_count, data_offset)``. + ``(format_name, elements, data_offset)`` where *format_name* is + ``ascii``, ``binary_little_endian`` or ``binary_big_endian`` and + *data_offset* is the byte offset where element data begins. Raises ------ ValueError If the file does not start with ``ply``, does not declare a - format, or ends before ``end_header``. + format, ends before ``end_header``, or contains malformed + element/property declarations. """ header_lines: list[str] = [] with path.open("rb") as handle: @@ -609,22 +850,50 @@ def _parse_ply_header(path: Path) -> tuple[list[str], str, int, int, int]: if not header_lines or header_lines[0] != "ply": raise ValueError(f"File '{path}' is not a valid PLY file (missing 'ply' magic).") - format_line = next((line for line in header_lines if line.startswith("format ")), None) - if format_line is None: - raise ValueError(f"PLY file '{path}' does not declare a format.") + format_name: str | None = None + elements: list[_PlyElement] = [] + current_name: str | None = None + current_count = 0 + current_properties: list[_PlyProperty] = [] + + def flush_element() -> None: + if current_name is not None: + elements.append(_PlyElement(current_name, current_count, tuple(current_properties))) + + for line in header_lines[1:-1]: + tokens = line.split() + if not tokens or tokens[0] in {"comment", "obj_info"}: + continue + if tokens[0] == "format" and len(tokens) >= 2: + format_name = tokens[1] + elif tokens[0] == "element" and len(tokens) == 3: + flush_element() + current_name, current_count, current_properties = tokens[1], int(tokens[2]), [] + elif tokens[0] == "property" and current_name is not None: + if len(tokens) == 5 and tokens[1] == "list": + current_properties.append(_PlyProperty(tokens[4], _ply_type(tokens[3], path), _ply_type(tokens[2], path))) + elif len(tokens) == 3: + current_properties.append(_PlyProperty(tokens[2], _ply_type(tokens[1], path))) + else: + raise ValueError(f"Malformed property declaration in '{path}': '{line}'.") + else: + raise ValueError(f"Malformed PLY header line in '{path}': '{line}'.") + flush_element() - vertex_count = 0 - face_count = 0 - for line in header_lines: - if line.startswith("element vertex "): - vertex_count = int(line.split()[-1]) - elif line.startswith("element face "): - face_count = int(line.split()[-1]) - return header_lines, format_line, vertex_count, face_count, data_offset + if format_name is None: + raise ValueError(f"PLY file '{path}' does not declare a format.") + if format_name not in {"ascii", "binary_little_endian", "binary_big_endian"}: + raise ValueError(f"Unsupported PLY format in '{path}': {format_name}.") + return format_name, elements, data_offset def read_ply(path: Path) -> MeshData: - """Read a PLY file (ASCII or binary little-endian) into a MeshData. + """Read a PLY file (ASCII or binary, either byte order) into a MeshData. + + Property types and order are taken from the header, so files with + ``float`` or ``double`` coordinates and extra vertex/face properties + (normals, colours, texture coordinates, ...) are supported. Polygon + faces are fan-triangulated. Parameters ---------- @@ -639,120 +908,235 @@ def read_ply(path: Path) -> MeshData: Raises ------ ValueError - If the file has no vertices/faces, uses an unsupported format, - or fails validation. + If the file has no vertices/faces, lacks ``x``/``y``/``z`` or face + index properties, is truncated, uses an unsupported format, or + fails validation. """ - header_lines, format_line, vertex_count, face_count, data_offset = _parse_ply_header(path) - if vertex_count <= 0 or face_count <= 0: + format_name, elements, data_offset = _parse_ply_header(path) + elements_by_name = {element.name: element for element in elements} + vertex_element = elements_by_name.get("vertex") + face_element = elements_by_name.get("face") + if vertex_element is None or face_element is None or vertex_element.count <= 0 or face_element.count <= 0: raise ValueError(f"PLY file '{path}' must contain non-empty vertex and face elements.") - if format_line.startswith("format ascii"): - return _read_ascii_ply(path, vertex_count, face_count, header_lines) - if format_line.startswith("format binary_little_endian"): - return _read_binary_ply(path, vertex_count, face_count, data_offset) - raise ValueError(f"Unsupported PLY format in '{path}': {format_line}.") + vertex_property_names = [prop.name for prop in vertex_element.properties] + if not {"x", "y", "z"}.issubset(vertex_property_names): + raise ValueError(f"PLY file '{path}' vertex element must declare x, y and z properties.") + face_index_property = next((prop.name for prop in face_element.properties if prop.name in _PLY_FACE_INDEX_PROPERTIES), None) + if face_index_property is None: + raise ValueError(f"PLY file '{path}' face element must declare a 'vertex_indices' list property.") + if format_name == "ascii": + data = _read_ascii_ply_elements(path, elements) + else: + byte_order = "<" if format_name == "binary_little_endian" else ">" + data = _read_binary_ply_elements(path, elements, data_offset, byte_order) + + vertex_data = data["vertex"] + vertices = np.stack([np.asarray(vertex_data[axis], dtype=np.float64) for axis in ("x", "y", "z")], axis=1) + faces = _triangulate_ply_faces(data["face"][face_index_property]) + + mesh = MeshData(vertices=vertices, faces=faces) + validate_mesh(mesh, f"PLY mesh '{path}'") + return mesh -def _read_ascii_ply(path: Path, vertex_count: int, face_count: int, header_lines: list[str]) -> MeshData: - """Read an ASCII-format PLY file body. + +def _triangulate_ply_faces(face_lists: list[npt.NDArray[Any]] | npt.NDArray[Any]) -> npt.NDArray[np.int32]: + """Fan-triangulate PLY polygon index lists. Parameters ---------- - path : Path - Source ``.ply`` path (used for error messages only). - vertex_count : int - Number of vertices declared in the header. - face_count : int - Number of faces declared in the header. - header_lines : list of str - Parsed header lines (used to skip past the header). + face_lists : ndarray of shape (M, K) or list of 1-D ndarray + Per-face vertex index lists. A 2-D array is used when every face + has the same vertex count. Returns ------- - MeshData - Parsed mesh. + ndarray of int32, shape (T, 3) + Triangle indices. Faces with fewer than 3 vertices are skipped. """ - with path.open("r", encoding="utf-8") as handle: - for line in handle: - if line.strip() == "end_header": - break + if isinstance(face_lists, np.ndarray) and face_lists.ndim == 2: + polygon = face_lists.astype(np.int64) + if polygon.shape[1] < 3: + return np.empty((0, 3), dtype=np.int32) + triangles = [np.stack([polygon[:, 0], polygon[:, idx], polygon[:, idx + 1]], axis=1) for idx in range(1, polygon.shape[1] - 1)] + return np.stack(triangles, axis=1).reshape(-1, 3).astype(np.int32) - vertices: list[tuple[float, float, float]] = [] - for _ in range(vertex_count): - parts = handle.readline().split() - if len(parts) < 3: - raise ValueError(f"Malformed vertex entry in '{path}'.") - vertices.append((float(parts[0]), float(parts[1]), float(parts[2]))) - - faces: list[tuple[int, int, int]] = [] - for _ in range(face_count): - parts = handle.readline().split() - if len(parts) < 4: - raise ValueError(f"Malformed face entry in '{path}'.") - n = int(parts[0]) - if n < 3: - continue - raw_indices = [int(token) for token in parts[1 : n + 1]] - for idx in range(1, len(raw_indices) - 1): - faces.append((raw_indices[0], raw_indices[idx], raw_indices[idx + 1])) + faces: list[tuple[int, int, int]] = [] + for indices in face_lists: + for idx in range(1, len(indices) - 1): + faces.append((int(indices[0]), int(indices[idx]), int(indices[idx + 1]))) + return np.asarray(faces, dtype=np.int32).reshape(-1, 3) - mesh = MeshData(vertices=np.asarray(vertices, dtype=np.float64), faces=np.asarray(faces, dtype=np.int32)) - validate_mesh(mesh, f"PLY mesh '{path}'") - return mesh +def _read_ascii_ply_elements(path: Path, elements: list[_PlyElement]) -> dict[str, dict[str, Any]]: + """Read the body of an ASCII PLY file. + + Parameters + ---------- + path : Path + Source ``.ply`` path. + elements : list of _PlyElement + Element declarations from :func:`_parse_ply_header`. -def _read_binary_ply(path: Path, vertex_count: int, face_count: int, data_offset: int) -> MeshData: - """Read a binary little-endian PLY file body. + Returns + ------- + dict + ``{element_name: {property_name: values}}``. Scalar properties + are 1-D arrays; list properties are lists of 1-D arrays. - Vertices are expected as three consecutive ``double`` values per - vertex. Faces use a 1-byte vertex count followed by ``N * 4`` bytes - of ``uint32`` indices. + Raises + ------ + ValueError + If the body is truncated or contains malformed values. + """ + with path.open("r", encoding="ascii") as handle: + for line in handle: + if line.strip() == "end_header": + break + tokens = handle.read().split() + + position = 0 + data: dict[str, dict[str, Any]] = {} + try: + for element in elements: + columns: dict[str, list[Any]] = {prop.name: [] for prop in element.properties} + for _ in range(element.count): + for prop in element.properties: + if prop.count_type is None: + columns[prop.name].append(float(tokens[position])) + position += 1 + else: + count = int(tokens[position]) + values = np.asarray(tokens[position + 1 : position + 1 + count], dtype=np.float64) + if values.shape[0] != count: + raise IndexError + columns[prop.name].append(values.astype(np.int64)) + position += 1 + count + data[element.name] = { + prop.name: columns[prop.name] if prop.count_type is not None else np.asarray(columns[prop.name], dtype=np.float64) + for prop in element.properties + } + except IndexError: + raise ValueError(f"Unexpected end of data while reading ASCII PLY '{path}'.") from None + except ValueError as exc: + raise ValueError(f"Malformed value in ASCII PLY '{path}': {exc}.") from None + return data + + +def _read_binary_ply_elements( + path: Path, + elements: list[_PlyElement], + data_offset: int, + byte_order: str, +) -> dict[str, dict[str, Any]]: + """Read the body of a binary PLY file. + + Elements without list properties are decoded in one shot with a + structured dtype. Elements with list properties use a vectorised + fast path when every row has the same list lengths (e.g. an + all-triangle face element) and fall back to row-by-row decoding + otherwise. Parameters ---------- path : Path - Source ``.ply`` path (used for error messages only). - vertex_count : int - Number of vertices declared in the header. - face_count : int - Number of faces declared in the header. + Source ``.ply`` path. + elements : list of _PlyElement + Element declarations from :func:`_parse_ply_header`. data_offset : int - Byte offset where vertex data begins (after ``end_header``). + Byte offset where element data begins. + byte_order : {"<", ">"} + Byte order of the binary payload. Returns ------- - MeshData - Parsed mesh. - """ - vertices = np.empty((vertex_count, 3), dtype=np.float64) - faces: list[tuple[int, int, int]] = [] + dict + ``{element_name: {property_name: values}}``. Scalar properties + are 1-D arrays; list properties are 2-D arrays (uniform lengths) + or lists of 1-D arrays. - with path.open("rb") as handle: - handle.seek(data_offset) - vertex_struct = struct.Struct(" None: + if position + size > len(buffer): + raise ValueError(f"Unexpected EOF while reading binary PLY '{path}'.") + + for element in elements: + if element.count == 0: + data[element.name] = {prop.name: np.empty(0) for prop in element.properties} + continue + + if all(prop.count_type is None for prop in element.properties): + dtype = np.dtype([(prop.name, byte_order + prop.value_type) for prop in element.properties]) + require(dtype.itemsize * element.count) + rows = np.frombuffer(buffer, dtype=dtype, count=element.count, offset=position) + position += dtype.itemsize * element.count + data[element.name] = {prop.name: rows[prop.name] for prop in element.properties} + continue + + # Fast path: assume the list lengths of the first row repeat for every row. + first_row_lengths: list[int] = [] + probe = position + for prop in element.properties: + if prop.count_type is None: + probe += np.dtype(prop.value_type).itemsize + continue + count_dtype = np.dtype(byte_order + prop.count_type) + require(probe - position + count_dtype.itemsize) + length = int(np.frombuffer(buffer, dtype=count_dtype, count=1, offset=probe)[0]) + first_row_lengths.append(length) + probe += count_dtype.itemsize + length * np.dtype(prop.value_type).itemsize + + fields: list[tuple[Any, ...]] = [] + list_lengths = iter(first_row_lengths) + for prop in element.properties: + if prop.count_type is None: + fields.append((prop.name, byte_order + prop.value_type)) + else: + fields.append((f"__count_{prop.name}", byte_order + prop.count_type)) + fields.append((prop.name, byte_order + prop.value_type, (next(list_lengths),))) + dtype = np.dtype(fields) + if position + dtype.itemsize * element.count <= len(buffer): + rows = np.frombuffer(buffer, dtype=dtype, count=element.count, offset=position) + uniform = all( + np.all(rows[f"__count_{prop.name}"] == length) + for prop, length in zip((p for p in element.properties if p.count_type is not None), first_row_lengths) + ) + if uniform: + position += dtype.itemsize * element.count + data[element.name] = {prop.name: rows[prop.name] for prop in element.properties} continue - for idx in range(1, n - 1): - faces.append((int(indices[0]), int(indices[idx]), int(indices[idx + 1]))) - mesh = MeshData(vertices=vertices, faces=np.asarray(faces, dtype=np.int32)) - validate_mesh(mesh, f"PLY mesh '{path}'") - return mesh + # Slow path: variable-length lists. + columns: dict[str, list[Any]] = {prop.name: [] for prop in element.properties} + for _ in range(element.count): + for prop in element.properties: + if prop.count_type is None: + value_dtype = np.dtype(byte_order + prop.value_type) + require(value_dtype.itemsize) + columns[prop.name].append(np.frombuffer(buffer, dtype=value_dtype, count=1, offset=position)[0]) + position += value_dtype.itemsize + else: + count_dtype = np.dtype(byte_order + prop.count_type) + value_dtype = np.dtype(byte_order + prop.value_type) + require(count_dtype.itemsize) + length = int(np.frombuffer(buffer, dtype=count_dtype, count=1, offset=position)[0]) + position += count_dtype.itemsize + require(length * value_dtype.itemsize) + columns[prop.name].append(np.frombuffer(buffer, dtype=value_dtype, count=length, offset=position).astype(np.int64)) + position += length * value_dtype.itemsize + data[element.name] = { + prop.name: columns[prop.name] if prop.count_type is not None else np.asarray(columns[prop.name]) for prop in element.properties + } + return data def validate_mesh(mesh: MeshData, name: str) -> None: @@ -824,9 +1208,7 @@ def validate_mesh_frame_and_scale(mesh: MeshData, name: str) -> None: raise ValueError(f"{name} z-origin is incompatible with bottom-centered frame (min_z={mins[2]:.3f}).") center_xy = (mins[:2] + maxs[:2]) / 2.0 if float(np.linalg.norm(center_xy)) > 5.0: - raise ValueError( - f"{name} XY center is far from origin (center=({center_xy[0]:.3f}, {center_xy[1]:.3f})); expected near bottom-center frame." - ) + raise ValueError(f"{name} XY center is far from origin (center=({center_xy[0]:.3f}, {center_xy[1]:.3f})); expected near bottom-center frame.") def save_perturbation(path: Path, perturbation: npt.NDArray[np.float64]) -> None: @@ -894,7 +1276,9 @@ def copy_or_generate_mesh(mesh_input_path: Path | None, dimensions: tuple[float, external file or by constructing a simple box. When *mesh_input_path* is ``None``, a box mesh with the target - dimensions is returned. Otherwise the external mesh is read, + dimensions is returned, midpoint-subdivided + ``DEFAULT_CAR_MESH_SUBDIVISION_LEVELS`` times so that every divide + group retains whole triangles. Otherwise the external mesh is read, normalised to a bottom-centre frame, and scaled to the target dimensions. @@ -914,7 +1298,7 @@ def copy_or_generate_mesh(mesh_input_path: Path | None, dimensions: tuple[float, The resulting mesh in bottom-centre frame. """ if mesh_input_path is None: - return box_mesh(*dimensions) + return subdivide_midpoint(box_mesh(*dimensions), levels=DEFAULT_CAR_MESH_SUBDIVISION_LEVELS) mesh = read_mesh(mesh_input_path) mesh = normalize_bottom_center(mesh) return scale_to_dimensions(mesh, dimensions, preserve_aspect=preserve_aspect) @@ -937,4 +1321,4 @@ def dump_generation_metadata(path: Path, metadata: dict[str, Any]) -> None: """ path.parent.mkdir(parents=True, exist_ok=True) with path.open("wb") as handle: - pickle.dump(metadata, handle) \ No newline at end of file + pickle.dump(metadata, handle) diff --git a/opencda/core/attack/advcp/utils/generate_car_mesh.py b/opencda/core/attack/advcp/utils/generate_car_mesh.py index b7566898..5aca4c83 100644 --- a/opencda/core/attack/advcp/utils/generate_car_mesh.py +++ b/opencda/core/attack/advcp/utils/generate_car_mesh.py @@ -90,4 +90,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/opencda/core/attack/advcp/utils/generate_mesh_divide.py b/opencda/core/attack/advcp/utils/generate_mesh_divide.py index 03d8e81b..b29260ae 100644 --- a/opencda/core/attack/advcp/utils/generate_mesh_divide.py +++ b/opencda/core/attack/advcp/utils/generate_mesh_divide.py @@ -34,7 +34,12 @@ import argparse from pathlib import Path -from opencda.core.attack.advcp.utils.asset_utils import dump_divide_pickle, generate_divide_indices, read_mesh +from opencda.core.attack.advcp.utils.asset_utils import ( + dump_divide_pickle, + generate_divide_indices, + read_mesh, + validate_divide_pieces, +) def _build_parser() -> argparse.ArgumentParser: @@ -58,14 +63,16 @@ def main() -> None: Parses arguments, reads the input mesh, generates vertex-group indices for the specified mode, serialises them to a pickle file, - and prints a summary. + and prints a summary. Fails before writing when a group would + produce an empty mesh piece. """ args = _build_parser().parse_args() mesh = read_mesh(args.mesh) divide = generate_divide_indices(mesh.vertices, mode=args.mode) + validate_divide_pieces(divide, mesh.faces, f"Generated {args.mode} mesh divide") dump_divide_pickle(divide, args.output) print(f"Generated {args.mode} mesh divide: {args.output} ({len(divide)} groups)") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py b/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py index ee532957..1f8eb350 100644 --- a/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py +++ b/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py @@ -61,6 +61,7 @@ generate_divide_indices, read_mesh, save_perturbation, + validate_divide_pieces, ) @@ -74,9 +75,7 @@ def _build_parser() -> argparse.ArgumentParser: output paths, and perturbation options (randomisation, seed, scale). """ - parser = argparse.ArgumentParser( - description="Generate AdvCP removal assets (mesh-divide .pkl and/or perturbation .npy)." - ) + parser = argparse.ArgumentParser(description="Generate AdvCP removal assets (mesh-divide .pkl and/or perturbation .npy).") parser.add_argument( "--mode", choices=("divide", "perturb", "both"), @@ -91,10 +90,7 @@ def _build_parser() -> argparse.ArgumentParser: "--mesh", type=Path, default=None, - help=( - "Input mesh path (.ply or .obj). When omitted, the built-in AdvCP " - "template mesh (subdivided box) is used." - ), + help=("Input mesh path (.ply or .obj). When omitted, the built-in AdvCP template mesh (subdivided box) is used."), ) parser.add_argument( "--output", @@ -168,6 +164,7 @@ def _generate_divide(mesh_input: Path | None, output: Path) -> None: """ mesh = _resolve_mesh(mesh_input) divide = generate_divide_indices(mesh.vertices, mode="remove") + validate_divide_pieces(divide, mesh.faces, "Generated removal mesh divide") dump_divide_pickle(divide, output) print(f"Generated removal mesh divide: {output} ({len(divide)} groups, {output.stat().st_size} bytes)") @@ -209,10 +206,7 @@ def _generate_perturb( source_desc = "zero-initialised" save_perturbation(output, perturbation) - print( - f"Generated {source_desc} perturbation: {output} " - f"(shape={perturbation.shape}, {output.stat().st_size} bytes)" - ) + print(f"Generated {source_desc} perturbation: {output} (shape={perturbation.shape}, {output.stat().st_size} bytes)") def main() -> None: @@ -249,4 +243,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/opencda/core/attack/advcp/utils/runtime_assets.py b/opencda/core/attack/advcp/utils/runtime_assets.py index bf428839..43bbee55 100644 --- a/opencda/core/attack/advcp/utils/runtime_assets.py +++ b/opencda/core/attack/advcp/utils/runtime_assets.py @@ -34,6 +34,7 @@ generate_divide_indices, parse_dimensions_arg, save_perturbation, + validate_divide_pieces, write_mesh, ) @@ -173,6 +174,7 @@ def ensure_spoof_assets(cls, config: AdvCPConfig) -> tuple[Path, Path]: mesh = copy_or_generate_mesh(source_path, dimensions=dimensions, preserve_aspect=preserve_aspect) write_mesh(car_mesh_path, mesh) divide = generate_divide_indices(mesh.vertices, mode="spoof") + validate_divide_pieces(divide, mesh.faces, "Generated spoof mesh divide") dump_divide_pickle(divide, car_mesh_divide_path) logger.info( "Generated AdvCP runtime spoof assets at mesh='%s', divide='%s'.", @@ -222,6 +224,7 @@ def ensure_remove_advshape_assets(cls, config: AdvCPConfig) -> tuple[Path, Path] template = advshape_template_mesh() if not divide_path.exists(): divide = generate_divide_indices(template.vertices, mode="remove") + validate_divide_pieces(divide, template.faces, "Generated removal mesh divide") dump_divide_pickle(divide, divide_path) if bool(config.get("remove_adv_shape_generate_zero_perturb", False)) and not perturb_path.exists(): @@ -233,4 +236,4 @@ def ensure_remove_advshape_assets(cls, config: AdvCPConfig) -> tuple[Path, Path] if "remove_adv_shape_perturb_path" not in config and perturb_path.exists(): config["remove_adv_shape_perturb_path"] = str(perturb_path) - return perturb_path, divide_path \ No newline at end of file + return perturb_path, divide_path diff --git a/opencda/core/attack/advcp/utils/validate_advcp_assets.py b/opencda/core/attack/advcp/utils/validate_advcp_assets.py index 213665f5..b432cba1 100644 --- a/opencda/core/attack/advcp/utils/validate_advcp_assets.py +++ b/opencda/core/attack/advcp/utils/validate_advcp_assets.py @@ -43,13 +43,14 @@ import sys from pathlib import Path -import numpy as np from opencda.core.attack.advcp.utils.asset_utils import ( + MeshData, load_divide_pickle, load_perturbation, read_mesh, validate_divide_indices, + validate_divide_pieces, validate_mesh, validate_mesh_frame_and_scale, ) @@ -65,9 +66,7 @@ def _build_parser() -> argparse.ArgumentParser: spoof divide, remove divide, remove perturb) and optional expected vertex counts. """ - parser = argparse.ArgumentParser( - description="Validate AdvCP 3D assets for spoofing and removal attacks." - ) + parser = argparse.ArgumentParser(description="Validate AdvCP 3D assets for spoofing and removal attacks.") parser.add_argument( "--car-mesh", type=Path, @@ -139,11 +138,11 @@ def _check_file_exists(path: Path, label: str) -> None: raise ValueError(f"{label}: file is empty (0 bytes).") -def _validate_car_mesh(path: Path, expected_vertices: int | None) -> int: +def _validate_car_mesh(path: Path, expected_vertices: int | None) -> MeshData: """Validate a car mesh ``.ply`` file. Checks file existence, structural integrity, coordinate frame, and - scale. Returns the vertex count for use in divide-index validation. + scale. Returns the parsed mesh for use in divide validation. Parameters ---------- @@ -155,8 +154,8 @@ def _validate_car_mesh(path: Path, expected_vertices: int | None) -> int: Returns ------- - int - The number of vertices in the mesh. + MeshData + The parsed mesh. Raises ------ @@ -174,48 +173,48 @@ def _validate_car_mesh(path: Path, expected_vertices: int | None) -> int: vertex_count = mesh.vertices.shape[0] print(f" Vertices: {vertex_count}") print(f" Faces: {mesh.faces.shape[0]}") - print(f" Bounds: x=[{mesh.vertices[:, 0].min():.3f}, {mesh.vertices[:, 0].max():.3f}], " - f"y=[{mesh.vertices[:, 1].min():.3f}, {mesh.vertices[:, 1].max():.3f}], " - f"z=[{mesh.vertices[:, 2].min():.3f}, {mesh.vertices[:, 2].max():.3f}]") + print( + f" Bounds: x=[{mesh.vertices[:, 0].min():.3f}, {mesh.vertices[:, 0].max():.3f}], " + f"y=[{mesh.vertices[:, 1].min():.3f}, {mesh.vertices[:, 1].max():.3f}], " + f"z=[{mesh.vertices[:, 2].min():.3f}, {mesh.vertices[:, 2].max():.3f}]" + ) if expected_vertices is not None and vertex_count != expected_vertices: - raise ValueError( - f"Car mesh vertex count mismatch: expected {expected_vertices}, got {vertex_count}." - ) + raise ValueError(f"Car mesh vertex count mismatch: expected {expected_vertices}, got {vertex_count}.") print(" [PASS]") - return vertex_count + return mesh -def _validate_spoof_divide(path: Path, car_vertex_count: int | None) -> None: +def _validate_spoof_divide(path: Path, car_mesh: MeshData | None) -> None: """Validate a spoof mesh-divide ``.pkl`` file. Checks file existence, that the divide contains exactly 8 groups, - and that all indices are valid for the given car mesh vertex count. + that all indices are valid for the given car mesh, and that every + group yields a non-empty mesh piece. Parameters ---------- path : Path Path to the ``.pkl`` file. - car_vertex_count : int or None - Vertex count of the corresponding car mesh. When ``None``, - index-bound validation is skipped. + car_mesh : MeshData or None + The corresponding car mesh. When ``None``, index-bound and + mesh-piece validation are skipped. Raises ------ FileNotFoundError If the file does not exist. ValueError - If the file is empty, contains the wrong number of groups, or - has out-of-bounds indices. + If the file is empty, contains the wrong number of groups, has + out-of-bounds indices, or a group retains no triangles. """ print(f"Validating spoof mesh divide: {path}") _check_file_exists(path, "Spoof mesh divide") indices = load_divide_pickle(path) if len(indices) != 8: - raise ValueError( - f"Spoof mesh divide must contain exactly 8 index groups, got {len(indices)}." - ) - if car_vertex_count is not None: - validate_divide_indices(indices, car_vertex_count, "Spoof mesh divide") + raise ValueError(f"Spoof mesh divide must contain exactly 8 index groups, got {len(indices)}.") + if car_mesh is not None: + validate_divide_indices(indices, car_mesh.vertices.shape[0], "Spoof mesh divide") + validate_divide_pieces(indices, car_mesh.faces, "Spoof mesh divide") print(f" Groups: {len(indices)}") for i, g in enumerate(indices): print(f" Group {i}: {g.shape[0]} indices, range [{g.min()}, {g.max()}]") @@ -255,9 +254,7 @@ def _validate_remove_divide( _check_file_exists(path, "Removal mesh divide") indices = load_divide_pickle(path) if len(indices) != 10: - raise ValueError( - f"Removal mesh divide must contain exactly 10 index groups, got {len(indices)}." - ) + raise ValueError(f"Removal mesh divide must contain exactly 10 index groups, got {len(indices)}.") # Determine the expected vertex count for validation expected_count = car_vertex_count or template_vertex_count if expected_count is not None: @@ -299,8 +296,7 @@ def _validate_remove_perturb(path: Path, car_vertex_count: int | None) -> None: print(f" Range: [{perturbation.min():.6f}, {perturbation.max():.6f}]") if car_vertex_count is not None and perturbation.shape[0] != car_vertex_count: raise ValueError( - f"Removal perturbation vertex count mismatch: car mesh has {car_vertex_count} " - f"vertices, perturbation has {perturbation.shape[0]}." + f"Removal perturbation vertex count mismatch: car mesh has {car_vertex_count} vertices, perturbation has {perturbation.shape[0]}." ) print(" [PASS]") @@ -318,31 +314,34 @@ def main() -> None: """ args = _build_parser().parse_args() - has_any_asset = any([ - args.car_mesh is not None, - args.spoof_divide is not None, - args.remove_divide is not None, - args.remove_perturb is not None, - ]) + has_any_asset = any( + [ + args.car_mesh is not None, + args.spoof_divide is not None, + args.remove_divide is not None, + args.remove_perturb is not None, + ] + ) if not has_any_asset: - print("No assets specified. Use --car-mesh, --spoof-divide, --remove-divide, " - "and/or --remove-perturb to select assets for validation.") + print("No assets specified. Use --car-mesh, --spoof-divide, --remove-divide, and/or --remove-perturb to select assets for validation.") sys.exit(1) errors: list[str] = [] + car_mesh: MeshData | None = None car_vertex_count: int | None = None # 1. Validate car mesh (if provided) if args.car_mesh is not None: try: - car_vertex_count = _validate_car_mesh(args.car_mesh, args.expected_vertices) + car_mesh = _validate_car_mesh(args.car_mesh, args.expected_vertices) + car_vertex_count = car_mesh.vertices.shape[0] except (FileNotFoundError, ValueError) as exc: errors.append(str(exc)) # 2. Validate spoof mesh divide (if provided) if args.spoof_divide is not None: try: - _validate_spoof_divide(args.spoof_divide, car_vertex_count) + _validate_spoof_divide(args.spoof_divide, car_mesh) except (FileNotFoundError, ValueError) as exc: errors.append(str(exc)) @@ -374,4 +373,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() From ec14f566a48e148e8f5d821caa47011408b65ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D1=83=D1=80=D0=B0=D0=B4=D1=8F=D0=BD=20=D0=90=D1=80?= =?UTF-8?q?=D1=82=D1=91=D0=BC=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sat, 3 Oct 2026 19:05:31 +0300 Subject: [PATCH 4/7] [AdvCP] Move asset CLI entry points to scripts/advcp Keep asset_utils and runtime_assets in opencda/core/attack/advcp/utils as the library shared with the attack runtime, and move the command-line wrappers (generate_car_mesh, generate_mesh_divide, generate_remove_advshape_assets, validate_advcp_assets) to scripts/advcp, run as `python -m scripts.advcp.` from the repository root. Their smoke tests move to test/ alongside the other script tests; usage examples now point at a models-repository bundle instead of the removed in-tree 3d_models directory. --- .../advcp/test/test_advcp_asset_utils.py | 241 +-------------- opencda/core/attack/advcp/utils/__init__.py | 25 +- .../advcp}/generate_car_mesh.py | 8 +- .../advcp}/generate_mesh_divide.py | 12 +- .../advcp}/generate_remove_advshape_assets.py | 26 +- .../advcp}/validate_advcp_assets.py | 18 +- test/test_advcp_asset_scripts.py | 288 ++++++++++++++++++ 7 files changed, 333 insertions(+), 285 deletions(-) rename {opencda/core/attack/advcp/utils => scripts/advcp}/generate_car_mesh.py (92%) rename {opencda/core/attack/advcp/utils => scripts/advcp}/generate_mesh_divide.py (83%) rename {opencda/core/attack/advcp/utils => scripts/advcp}/generate_remove_advshape_assets.py (87%) rename {opencda/core/attack/advcp/utils => scripts/advcp}/validate_advcp_assets.py (94%) create mode 100644 test/test_advcp_asset_scripts.py diff --git a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py index 63928f1c..9a15d5d1 100644 --- a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py +++ b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py @@ -20,13 +20,13 @@ :func:`validate_divide_indices` (pass and fail cases). - Blueprint dimension lookup: :func:`blueprint_dimensions_m`, :func:`parse_dimensions_arg`. -- CLI integration smoke tests for ``generate_car_mesh``, - ``generate_mesh_divide``, ``generate_remove_advshape_assets``, - and ``validate_advcp_assets``. - Runtime asset helper: :class:`AdvCPRuntimeAssetHelper` spoof and removal asset generation (skipped on Python < 3.10 due to ``TypeAlias`` dependency in ``types.py``). +CLI smoke tests for the scripts in ``scripts/advcp`` live in +``test/test_advcp_asset_scripts.py``. + All tests use temporary directories and do not modify the repository. """ @@ -35,7 +35,6 @@ import hashlib import pickle import struct -import sys import tempfile from pathlib import Path from typing import Any @@ -916,240 +915,6 @@ def test_round_trip(self, tmp_output: Path) -> None: assert loaded == meta -# ========================================================================= -# CLI integration smoke tests -# ========================================================================= - - -class TestGenerateCarMeshCLI: - """Smoke tests for generate_car_mesh CLI.""" - - def test_generates_ply(self, tmp_output: Path) -> None: - out = tmp_output / "car.ply" - from opencda.core.attack.advcp.utils.generate_car_mesh import main - - sys.argv = ["generate_car_mesh", "--vehicle-blueprint", "vehicle.tesla.model3", "--output", str(out)] - main() - assert out.exists() - mesh = read_mesh(out) - assert mesh.vertices.shape[0] > 0 - - def test_with_custom_dimensions(self, tmp_output: Path) -> None: - out = tmp_output / "car.ply" - sys.argv = [ - "generate_car_mesh", - "--dimensions", - "5.0", - "2.0", - "1.8", - "--output", - str(out), - ] - from opencda.core.attack.advcp.utils.generate_car_mesh import main - - main() - mesh = read_mesh(out) - size = mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0) - np.testing.assert_allclose(size, [5.0, 2.0, 1.8], atol=1e-6) - - -class TestGenerateMeshDivideCLI: - """Smoke tests for generate_mesh_divide CLI.""" - - def test_generates_spoof_pkl(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: - mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_car_mesh, mesh_path) - out = tmp_output / "divide.pkl" - sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "spoof", "--output", str(out)] - from opencda.core.attack.advcp.utils.generate_mesh_divide import main - - main() - assert out.exists() - loaded = load_divide_pickle(out) - assert len(loaded) == 8 - - def test_generates_remove_pkl(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: - mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_car_mesh, mesh_path) - out = tmp_output / "divide.pkl" - sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "remove", "--output", str(out)] - from opencda.core.attack.advcp.utils.generate_mesh_divide import main - - main() - loaded = load_divide_pickle(out) - assert len(loaded) == 10 - - def test_rejects_divide_with_empty_pieces(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: - """A bare 8-vertex box cannot be split into 8 non-empty spoof pieces.""" - mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_box_mesh, mesh_path) - out = tmp_output / "divide.pkl" - sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "spoof", "--output", str(out)] - from opencda.core.attack.advcp.utils.generate_mesh_divide import main - - with pytest.raises(ValueError, match="no complete triangles"): - main() - assert not out.exists() - - -class TestGenerateRemoveAdvshapeAssetsCLI: - """Smoke tests for generate_remove_advshape_assets CLI.""" - - def test_generates_divide(self, tmp_output: Path) -> None: - out = tmp_output / "divide.pkl" - sys.argv = [ - "generate_remove_advshape_assets", - "--mode", - "divide", - "--output", - str(out), - ] - from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main - - main() - assert out.exists() - loaded = load_divide_pickle(out) - assert len(loaded) == 10 - - def test_generates_perturb(self, tmp_output: Path) -> None: - out = tmp_output / "perturb.npy" - sys.argv = [ - "generate_remove_advshape_assets", - "--mode", - "perturb", - "--output", - str(out), - ] - from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main - - main() - assert out.exists() - loaded = np.load(out) - assert loaded.shape[1] == 3 - - def test_generates_random_perturb(self, tmp_output: Path) -> None: - out = tmp_output / "perturb.npy" - sys.argv = [ - "generate_remove_advshape_assets", - "--mode", - "perturb", - "--random", - "--seed", - "42", - "--perturb-scale", - "0.3", - "--output", - str(out), - ] - from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main - - main() - loaded = np.load(out) - assert loaded.min() >= -0.3 - assert loaded.max() <= 0.3 - - def test_generates_both(self, tmp_output: Path) -> None: - div_out = tmp_output / "divide.pkl" - pert_out = tmp_output / "perturb.npy" - sys.argv = [ - "generate_remove_advshape_assets", - "--mode", - "both", - "--divide-output", - str(div_out), - "--perturb-output", - str(pert_out), - ] - from opencda.core.attack.advcp.utils.generate_remove_advshape_assets import main - - main() - assert div_out.exists() - assert pert_out.exists() - - -class TestValidateAdvcpAssetsCLI: - """Smoke tests for validate_advcp_assets CLI.""" - - def test_validate_car_mesh_only(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: - mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_box_mesh, mesh_path) - sys.argv = [ - "validate_advcp_assets", - "--car-mesh", - str(mesh_path), - ] - from opencda.core.attack.advcp.utils.validate_advcp_assets import main - - main() # should not raise - - def test_validate_all_assets(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: - mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_car_mesh, mesh_path) - spoof_path = tmp_output / "spoof.pkl" - dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "spoof"), spoof_path) - remove_path = tmp_output / "remove.pkl" - dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "remove"), remove_path) - perturb_path = tmp_output / "perturb.npy" - save_perturbation(perturb_path, np.zeros((sample_car_mesh.vertices.shape[0], 3), dtype=np.float32)) - sys.argv = [ - "validate_advcp_assets", - "--car-mesh", - str(mesh_path), - "--spoof-divide", - str(spoof_path), - "--remove-divide", - str(remove_path), - "--remove-perturb", - str(perturb_path), - ] - from opencda.core.attack.advcp.utils.validate_advcp_assets import main - - main() - - def test_validate_fails_on_empty_spoof_pieces(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: - mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_box_mesh, mesh_path) - spoof_path = tmp_output / "spoof.pkl" - dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "spoof"), spoof_path) - sys.argv = [ - "validate_advcp_assets", - "--car-mesh", - str(mesh_path), - "--spoof-divide", - str(spoof_path), - ] - from opencda.core.attack.advcp.utils.validate_advcp_assets import main - - with pytest.raises(SystemExit): - main() - - def test_validate_fails_on_missing_file(self, tmp_output: Path) -> None: - sys.argv = [ - "validate_advcp_assets", - "--car-mesh", - str(tmp_output / "nonexistent.ply"), - ] - from opencda.core.attack.advcp.utils.validate_advcp_assets import main - - with pytest.raises(SystemExit): - main() - - def test_validate_fails_on_wrong_vertex_count(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: - mesh_path = tmp_output / "mesh.ply" - write_ascii_ply(sample_box_mesh, mesh_path) - sys.argv = [ - "validate_advcp_assets", - "--car-mesh", - str(mesh_path), - "--expected-vertices", - "9999", - ] - from opencda.core.attack.advcp.utils.validate_advcp_assets import main - - with pytest.raises(SystemExit): - main() - - # ========================================================================= # Runtime asset helper (Python >= 3.10 only due to TypeAlias in types.py) # ========================================================================= diff --git a/opencda/core/attack/advcp/utils/__init__.py b/opencda/core/attack/advcp/utils/__init__.py index 20daeb70..590ff14b 100644 --- a/opencda/core/attack/advcp/utils/__init__.py +++ b/opencda/core/attack/advcp/utils/__init__.py @@ -1,23 +1,18 @@ """ -AdvCP asset generation and validation utilities. +AdvCP asset library shared by the attack runtime and the asset scripts. Available modules ----------------- -generate_car_mesh - CLI for generating a vehicle mesh (.ply) from a CARLA blueprint or - external mesh input. -generate_mesh_divide - CLI for generating spoof or removal mesh-division metadata (.pkl). -generate_remove_advshape_assets - CLI for generating removal assets (.pkl and/or .npy) including - mesh-division metadata and adversarial-shape perturbation tensors. -validate_advcp_assets - CLI for validating .ply, .pkl, and .npy assets against the AdvCP - pipeline requirements. +asset_utils + Core mesh, divide, and perturbation helpers (read, write, validate, + transform), including the canonical adv-shape template used by the + runtime. runtime_assets :class:`~AdvCPRuntimeAssetHelper` for on-demand asset generation during simulation. -asset_utils - Core mesh, divide, and perturbation helpers (read, write, validate, - transform). + +The command-line entry points (``generate_car_mesh``, +``generate_mesh_divide``, ``generate_remove_advshape_assets`` and +``validate_advcp_assets``) live in ``scripts/advcp`` and are run from the +repository root, e.g. ``python -m scripts.advcp.generate_car_mesh``. """ diff --git a/opencda/core/attack/advcp/utils/generate_car_mesh.py b/scripts/advcp/generate_car_mesh.py similarity index 92% rename from opencda/core/attack/advcp/utils/generate_car_mesh.py rename to scripts/advcp/generate_car_mesh.py index 5aca4c83..bb46a168 100644 --- a/opencda/core/attack/advcp/utils/generate_car_mesh.py +++ b/scripts/advcp/generate_car_mesh.py @@ -17,19 +17,19 @@ Generate a mesh for the default Tesla Model 3 blueprint:: - python -m opencda.core.attack.advcp.utils.generate_car_mesh \\ - --output opencda/core/attack/advcp/3d_models/car_mesh_0200.ply + python -m scripts.advcp.generate_car_mesh \\ + --output ../models/advcp/my-car/car_mesh_0200.ply Generate a mesh for a specific blueprint with custom dimensions:: - python -m opencda.core.attack.advcp.utils.generate_car_mesh \\ + python -m scripts.advcp.generate_car_mesh \\ --vehicle-blueprint vehicle.audi.a2 \\ --dimensions 3.7 1.7 1.55 \\ --output /tmp/car_mesh_audi.ply Generate a mesh from an external .obj file, preserving aspect ratio:: - python -m opencda.core.attack.advcp.utils.generate_car_mesh \\ + python -m scripts.advcp.generate_car_mesh \\ --mesh-input /path/to/vehicle.obj \\ --dimensions 4.5 2.0 1.6 \\ --preserve-aspect \\ diff --git a/opencda/core/attack/advcp/utils/generate_mesh_divide.py b/scripts/advcp/generate_mesh_divide.py similarity index 83% rename from opencda/core/attack/advcp/utils/generate_mesh_divide.py rename to scripts/advcp/generate_mesh_divide.py index b29260ae..05b56c14 100644 --- a/opencda/core/attack/advcp/utils/generate_mesh_divide.py +++ b/scripts/advcp/generate_mesh_divide.py @@ -16,17 +16,17 @@ Generate spoof mesh-divide metadata:: - python -m opencda.core.attack.advcp.utils.generate_mesh_divide \\ - --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + python -m scripts.advcp.generate_mesh_divide \\ + --mesh ../models/advcp/my-car/car_mesh_0200.ply \\ --mode spoof \\ - --output opencda/core/attack/advcp/3d_models/spoof/car_mesh_divide.pkl + --output ../models/advcp/my-car/spoof/car_mesh_divide.pkl Generate removal mesh-divide metadata:: - python -m opencda.core.attack.advcp.utils.generate_mesh_divide \\ - --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + python -m scripts.advcp.generate_mesh_divide \\ + --mesh ../models/advcp/my-car/car_mesh_0200.ply \\ --mode remove \\ - --output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl + --output ../models/advcp/my-car/remove/mesh_divide.pkl """ from __future__ import annotations diff --git a/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py b/scripts/advcp/generate_remove_advshape_assets.py similarity index 87% rename from opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py rename to scripts/advcp/generate_remove_advshape_assets.py index 1f8eb350..24238d1c 100644 --- a/opencda/core/attack/advcp/utils/generate_remove_advshape_assets.py +++ b/scripts/advcp/generate_remove_advshape_assets.py @@ -13,38 +13,38 @@ Generate removal mesh-divide from an existing car mesh:: - python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + python -m scripts.advcp.generate_remove_advshape_assets \\ --mode divide \\ - --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ - --output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl + --mesh ../models/advcp/my-car/car_mesh_0200.ply \\ + --output ../models/advcp/my-car/remove/mesh_divide.pkl Generate removal mesh-divide from the built-in AdvCP template mesh:: - python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + python -m scripts.advcp.generate_remove_advshape_assets \\ --mode divide \\ - --output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl + --output ../models/advcp/my-car/remove/mesh_divide.pkl Generate a zero-initialised perturbation tensor:: - python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + python -m scripts.advcp.generate_remove_advshape_assets \\ --mode perturb \\ - --output opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy + --output ../models/advcp/my-car/remove/mesh_perturb.npy Generate a random perturbation tensor (for testing / warm-start):: - python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + python -m scripts.advcp.generate_remove_advshape_assets \\ --mode perturb \\ --random \\ --seed 42 \\ - --output opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy + --output ../models/advcp/my-car/remove/mesh_perturb.npy Generate both divide and perturb in one call:: - python -m opencda.core.attack.advcp.utils.generate_remove_advshape_assets \\ + python -m scripts.advcp.generate_remove_advshape_assets \\ --mode both \\ - --mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ - --divide-output opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl \\ - --perturb-output opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy + --mesh ../models/advcp/my-car/car_mesh_0200.ply \\ + --divide-output ../models/advcp/my-car/remove/mesh_divide.pkl \\ + --perturb-output ../models/advcp/my-car/remove/mesh_perturb.npy """ from __future__ import annotations diff --git a/opencda/core/attack/advcp/utils/validate_advcp_assets.py b/scripts/advcp/validate_advcp_assets.py similarity index 94% rename from opencda/core/attack/advcp/utils/validate_advcp_assets.py rename to scripts/advcp/validate_advcp_assets.py index b432cba1..68abc0c6 100644 --- a/opencda/core/attack/advcp/utils/validate_advcp_assets.py +++ b/scripts/advcp/validate_advcp_assets.py @@ -19,21 +19,21 @@ Validate all assets:: - python -m opencda.core.attack.advcp.utils.validate_advcp_assets \\ - --car-mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ - --spoof-divide opencda/core/attack/advcp/3d_models/spoof/car_mesh_divide.pkl \\ - --remove-divide opencda/core/attack/advcp/3d_models/remove/mesh_divide.pkl \\ - --remove-perturb opencda/core/attack/advcp/3d_models/remove/mesh_perturb.npy + python -m scripts.advcp.validate_advcp_assets \\ + --car-mesh ../models/advcp/my-car/car_mesh_0200.ply \\ + --spoof-divide ../models/advcp/my-car/spoof/car_mesh_divide.pkl \\ + --remove-divide ../models/advcp/my-car/remove/mesh_divide.pkl \\ + --remove-perturb ../models/advcp/my-car/remove/mesh_perturb.npy Validate only the car mesh:: - python -m opencda.core.attack.advcp.utils.validate_advcp_assets \\ - --car-mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply + python -m scripts.advcp.validate_advcp_assets \\ + --car-mesh ../models/advcp/my-car/car_mesh_0200.ply Validate with an explicit expected vertex count:: - python -m opencda.core.attack.advcp.utils.validate_advcp_assets \\ - --car-mesh opencda/core/attack/advcp/3d_models/car_mesh_0200.ply \\ + python -m scripts.advcp.validate_advcp_assets \\ + --car-mesh ../models/advcp/my-car/car_mesh_0200.ply \\ --expected-vertices 148755 """ diff --git a/test/test_advcp_asset_scripts.py b/test/test_advcp_asset_scripts.py new file mode 100644 index 00000000..2723c14b --- /dev/null +++ b/test/test_advcp_asset_scripts.py @@ -0,0 +1,288 @@ +""" +CLI smoke tests for the AdvCP asset scripts in ``scripts/advcp``. + +Covers ``generate_car_mesh``, ``generate_mesh_divide``, +``generate_remove_advshape_assets`` and ``validate_advcp_assets``. The +underlying library (``opencda.core.attack.advcp.utils.asset_utils``) is +tested in ``opencda/core/attack/advcp/test/test_advcp_asset_utils.py``. + +All tests use temporary directories and do not modify the repository. +""" + +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path +from typing import Iterator + +import numpy as np +import pytest + +from opencda.core.attack.advcp.utils.asset_utils import ( + MeshData, + box_mesh, + copy_or_generate_mesh, + dump_divide_pickle, + generate_divide_indices, + load_divide_pickle, + read_mesh, + save_perturbation, + write_ascii_ply, +) + + +@pytest.fixture(autouse=True) +def restore_argv() -> Iterator[None]: + """Restore ``sys.argv`` after each CLI invocation.""" + original = list(sys.argv) + yield + sys.argv = original + + +@pytest.fixture +def tmp_output() -> Iterator[Path]: + """Yield a temporary directory path for test outputs.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def sample_box_mesh() -> MeshData: + """A bare 8-vertex 4.3 x 1.91 x 1.26 m box mesh (Tesla Model 3).""" + return box_mesh(4.3, 1.91, 1.26) + + +@pytest.fixture +def sample_car_mesh() -> MeshData: + """The default generated (subdivided) car mesh for Tesla Model 3 dimensions.""" + return copy_or_generate_mesh(None, dimensions=(4.3, 1.91, 1.26), preserve_aspect=False) + + +class TestGenerateCarMeshCLI: + """Smoke tests for generate_car_mesh CLI.""" + + def test_generates_ply(self, tmp_output: Path) -> None: + out = tmp_output / "car.ply" + from scripts.advcp.generate_car_mesh import main + + sys.argv = ["generate_car_mesh", "--vehicle-blueprint", "vehicle.tesla.model3", "--output", str(out)] + main() + assert out.exists() + mesh = read_mesh(out) + assert mesh.vertices.shape[0] > 0 + + def test_with_custom_dimensions(self, tmp_output: Path) -> None: + out = tmp_output / "car.ply" + sys.argv = [ + "generate_car_mesh", + "--dimensions", + "5.0", + "2.0", + "1.8", + "--output", + str(out), + ] + from scripts.advcp.generate_car_mesh import main + + main() + mesh = read_mesh(out) + size = mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0) + np.testing.assert_allclose(size, [5.0, 2.0, 1.8], atol=1e-6) + + +class TestGenerateMeshDivideCLI: + """Smoke tests for generate_mesh_divide CLI.""" + + def test_generates_spoof_pkl(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_car_mesh, mesh_path) + out = tmp_output / "divide.pkl" + sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "spoof", "--output", str(out)] + from scripts.advcp.generate_mesh_divide import main + + main() + assert out.exists() + loaded = load_divide_pickle(out) + assert len(loaded) == 8 + + def test_generates_remove_pkl(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_car_mesh, mesh_path) + out = tmp_output / "divide.pkl" + sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "remove", "--output", str(out)] + from scripts.advcp.generate_mesh_divide import main + + main() + loaded = load_divide_pickle(out) + assert len(loaded) == 10 + + def test_rejects_divide_with_empty_pieces(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + """A bare 8-vertex box cannot be split into 8 non-empty spoof pieces.""" + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + out = tmp_output / "divide.pkl" + sys.argv = ["generate_mesh_divide", "--mesh", str(mesh_path), "--mode", "spoof", "--output", str(out)] + from scripts.advcp.generate_mesh_divide import main + + with pytest.raises(ValueError, match="no complete triangles"): + main() + assert not out.exists() + + +class TestGenerateRemoveAdvshapeAssetsCLI: + """Smoke tests for generate_remove_advshape_assets CLI.""" + + def test_generates_divide(self, tmp_output: Path) -> None: + out = tmp_output / "divide.pkl" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", + "divide", + "--output", + str(out), + ] + from scripts.advcp.generate_remove_advshape_assets import main + + main() + assert out.exists() + loaded = load_divide_pickle(out) + assert len(loaded) == 10 + + def test_generates_perturb(self, tmp_output: Path) -> None: + out = tmp_output / "perturb.npy" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", + "perturb", + "--output", + str(out), + ] + from scripts.advcp.generate_remove_advshape_assets import main + + main() + assert out.exists() + loaded = np.load(out) + assert loaded.shape[1] == 3 + + def test_generates_random_perturb(self, tmp_output: Path) -> None: + out = tmp_output / "perturb.npy" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", + "perturb", + "--random", + "--seed", + "42", + "--perturb-scale", + "0.3", + "--output", + str(out), + ] + from scripts.advcp.generate_remove_advshape_assets import main + + main() + loaded = np.load(out) + assert loaded.min() >= -0.3 + assert loaded.max() <= 0.3 + + def test_generates_both(self, tmp_output: Path) -> None: + div_out = tmp_output / "divide.pkl" + pert_out = tmp_output / "perturb.npy" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", + "both", + "--divide-output", + str(div_out), + "--perturb-output", + str(pert_out), + ] + from scripts.advcp.generate_remove_advshape_assets import main + + main() + assert div_out.exists() + assert pert_out.exists() + + +class TestValidateAdvcpAssetsCLI: + """Smoke tests for validate_advcp_assets CLI.""" + + def test_validate_car_mesh_only(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", + str(mesh_path), + ] + from scripts.advcp.validate_advcp_assets import main + + main() # should not raise + + def test_validate_all_assets(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_car_mesh, mesh_path) + spoof_path = tmp_output / "spoof.pkl" + dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "spoof"), spoof_path) + remove_path = tmp_output / "remove.pkl" + dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "remove"), remove_path) + perturb_path = tmp_output / "perturb.npy" + save_perturbation(perturb_path, np.zeros((sample_car_mesh.vertices.shape[0], 3), dtype=np.float32)) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", + str(mesh_path), + "--spoof-divide", + str(spoof_path), + "--remove-divide", + str(remove_path), + "--remove-perturb", + str(perturb_path), + ] + from scripts.advcp.validate_advcp_assets import main + + main() + + def test_validate_fails_on_empty_spoof_pieces(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + spoof_path = tmp_output / "spoof.pkl" + dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "spoof"), spoof_path) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", + str(mesh_path), + "--spoof-divide", + str(spoof_path), + ] + from scripts.advcp.validate_advcp_assets import main + + with pytest.raises(SystemExit): + main() + + def test_validate_fails_on_missing_file(self, tmp_output: Path) -> None: + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", + str(tmp_output / "nonexistent.ply"), + ] + from scripts.advcp.validate_advcp_assets import main + + with pytest.raises(SystemExit): + main() + + def test_validate_fails_on_wrong_vertex_count(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(sample_box_mesh, mesh_path) + sys.argv = [ + "validate_advcp_assets", + "--car-mesh", + str(mesh_path), + "--expected-vertices", + "9999", + ] + from scripts.advcp.validate_advcp_assets import main + + with pytest.raises(SystemExit): + main() From 7835bccbc817ea7f9c706ce6801a0d0e366059e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D1=83=D1=80=D0=B0=D0=B4=D1=8F=D0=BD=20=D0=90=D1=80?= =?UTF-8?q?=D1=82=D1=91=D0=BC=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Tue, 6 Oct 2026 01:10:45 +0300 Subject: [PATCH 5/7] [AdvCP] Validate removal assets against the adv-shape template Removal divide and perturbation assets are indexed against the 98-vertex adv-shape template used by the attack runtime, not against --car-mesh. The validator now resolves the template vertex count separately (--advshape-template-vertices takes precedence when given), checks both removal assets against it, and verifies that every removal group keeps template triangles. The documented "validate all assets" workflow now accepts assets produced by the generation scripts. --- scripts/advcp/validate_advcp_assets.py | 132 ++++++++++++++++--------- test/test_advcp_asset_scripts.py | 92 ++++++++++++++++- 2 files changed, 175 insertions(+), 49 deletions(-) diff --git a/scripts/advcp/validate_advcp_assets.py b/scripts/advcp/validate_advcp_assets.py index 68abc0c6..5d8e2539 100644 --- a/scripts/advcp/validate_advcp_assets.py +++ b/scripts/advcp/validate_advcp_assets.py @@ -7,9 +7,15 @@ - Mesh (.ply) format, vertex/face counts, coordinate frame, and scale. - Spoof mesh-divide (.pkl) structure, index bounds, and compatibility with the car mesh vertex count. -- Removal mesh-divide (.pkl) structure, index bounds, and compatibility with - the car mesh or AdvCP template mesh vertex count. -- Removal perturbation (.npy) shape, dtype, and finite values. +- Removal mesh-divide (.pkl) structure, index bounds, and non-empty mesh + pieces of the AdvCP adv-shape template. +- Removal perturbation (.npy) shape, dtype, finite values, and vertex count + of the AdvCP adv-shape template. + +Removal assets are indexed against the adv-shape template used by the +attack runtime (:func:`advshape_template_mesh`, 98 vertices), not against +``--car-mesh``; ``--advshape-template-vertices`` overrides the expected +template vertex count. Exits with code 0 when all checks pass, or 1 with actionable error messages when any check fails. @@ -46,6 +52,7 @@ from opencda.core.attack.advcp.utils.asset_utils import ( MeshData, + advshape_template_mesh, load_divide_pickle, load_perturbation, read_mesh, @@ -106,10 +113,9 @@ def _build_parser() -> argparse.ArgumentParser: type=int, default=None, help=( - "Expected vertex count for the AdvCP template mesh used by removal " - "divide. When omitted, the removal divide is validated against the " - "car mesh vertex count (if --car-mesh is provided), or the check is " - "skipped." + "Expected vertex count of the adv-shape template that the removal " + "divide and perturbation are indexed against. Defaults to the AdvCP " + "runtime template (98 vertices). Takes precedence when provided." ), ) return parser @@ -221,64 +227,97 @@ def _validate_spoof_divide(path: Path, car_mesh: MeshData | None) -> None: print(" [PASS]") +def _resolve_removal_template(template_vertices_override: int | None) -> tuple[int, MeshData | None]: + """Resolve the template that removal assets are validated against. + + Parameters + ---------- + template_vertices_override : int or None + Value of ``--advshape-template-vertices``. Takes precedence over + the runtime template when provided. + + Returns + ------- + tuple + ``(vertex_count, template_mesh)``. *template_mesh* is the runtime + template when its vertex count is the one being validated, or + ``None`` for a custom override (mesh pieces are then not checked). + + Raises + ------ + ValueError + If the override is not positive. + """ + template = advshape_template_mesh() + runtime_vertex_count = template.vertices.shape[0] + if template_vertices_override is None or template_vertices_override == runtime_vertex_count: + return runtime_vertex_count, template + if template_vertices_override <= 0: + raise ValueError(f"--advshape-template-vertices must be positive, got {template_vertices_override}.") + print( + f"Note: validating removal assets against {template_vertices_override} template vertices; " + f"the AdvCP runtime always uses the {runtime_vertex_count}-vertex adv-shape template." + ) + return template_vertices_override, None + + def _validate_remove_divide( path: Path, - car_vertex_count: int | None, - template_vertex_count: int | None, + template_vertex_count: int, + template_mesh: MeshData | None, ) -> None: """Validate a removal mesh-divide ``.pkl`` file. Checks file existence, that the divide contains exactly 10 groups, - and that all indices are valid. The expected vertex count is - resolved from *car_vertex_count* first, then *template_vertex_count*. + that all indices are valid for the adv-shape template, and (when the + template mesh is known) that every group yields a non-empty piece. Parameters ---------- path : Path Path to the ``.pkl`` file. - car_vertex_count : int or None - Vertex count of the car mesh (used when available). - template_vertex_count : int or None - Expected vertex count for the AdvCP template mesh (used as - fallback when *car_vertex_count* is ``None``). + template_vertex_count : int + Vertex count of the adv-shape template the indices refer to. + template_mesh : MeshData or None + The adv-shape template mesh, or ``None`` to skip the mesh-piece + check. Raises ------ FileNotFoundError If the file does not exist. ValueError - If the file is empty, contains the wrong number of groups, or - has out-of-bounds indices. + If the file is empty, contains the wrong number of groups, has + out-of-bounds indices, or a group retains no triangles. """ print(f"Validating removal mesh divide: {path}") _check_file_exists(path, "Removal mesh divide") indices = load_divide_pickle(path) if len(indices) != 10: raise ValueError(f"Removal mesh divide must contain exactly 10 index groups, got {len(indices)}.") - # Determine the expected vertex count for validation - expected_count = car_vertex_count or template_vertex_count - if expected_count is not None: - validate_divide_indices(indices, expected_count, "Removal mesh divide") + validate_divide_indices(indices, template_vertex_count, "Removal mesh divide") + if template_mesh is not None: + validate_divide_pieces(indices, template_mesh.faces, "Removal mesh divide") print(f" Groups: {len(indices)}") for i, g in enumerate(indices): print(f" Group {i}: {g.shape[0]} indices, range [{g.min()}, {g.max()}]") print(" [PASS]") -def _validate_remove_perturb(path: Path, car_vertex_count: int | None) -> None: +def _validate_remove_perturb(path: Path, template_vertex_count: int) -> None: """Validate a removal perturbation ``.npy`` file. Checks file existence, that the perturbation has shape (N, 3), - contains only finite values, and its vertex count matches the car - mesh (when *car_vertex_count* is provided). + contains only finite values, and that N matches the adv-shape + template vertex count. Parameters ---------- path : Path Path to the ``.npy`` file. - car_vertex_count : int or None - Vertex count of the corresponding car mesh. When provided, the - perturbation's first dimension must match. + template_vertex_count : int + Vertex count of the adv-shape template the perturbation is + applied to. Raises ------ @@ -294,9 +333,10 @@ def _validate_remove_perturb(path: Path, car_vertex_count: int | None) -> None: print(f" Shape: {perturbation.shape}") print(f" Dtype: {perturbation.dtype}") print(f" Range: [{perturbation.min():.6f}, {perturbation.max():.6f}]") - if car_vertex_count is not None and perturbation.shape[0] != car_vertex_count: + if perturbation.shape[0] != template_vertex_count: raise ValueError( - f"Removal perturbation vertex count mismatch: car mesh has {car_vertex_count} vertices, perturbation has {perturbation.shape[0]}." + f"Removal perturbation vertex count mismatch: adv-shape template has {template_vertex_count} vertices, " + f"perturbation has {perturbation.shape[0]}." ) print(" [PASS]") @@ -328,13 +368,11 @@ def main() -> None: errors: list[str] = [] car_mesh: MeshData | None = None - car_vertex_count: int | None = None # 1. Validate car mesh (if provided) if args.car_mesh is not None: try: car_mesh = _validate_car_mesh(args.car_mesh, args.expected_vertices) - car_vertex_count = car_mesh.vertices.shape[0] except (FileNotFoundError, ValueError) as exc: errors.append(str(exc)) @@ -345,23 +383,23 @@ def main() -> None: except (FileNotFoundError, ValueError) as exc: errors.append(str(exc)) - # 3. Validate removal mesh divide (if provided) - if args.remove_divide is not None: + # 3-4. Removal assets are indexed against the adv-shape template, not the car mesh + if args.remove_divide is not None or args.remove_perturb is not None: try: - _validate_remove_divide( - args.remove_divide, - car_vertex_count, - args.advshape_template_vertices, - ) - except (FileNotFoundError, ValueError) as exc: - errors.append(str(exc)) - - # 4. Validate removal perturbation (if provided) - if args.remove_perturb is not None: - try: - _validate_remove_perturb(args.remove_perturb, car_vertex_count) - except (FileNotFoundError, ValueError) as exc: + template_vertex_count, template_mesh = _resolve_removal_template(args.advshape_template_vertices) + except ValueError as exc: errors.append(str(exc)) + else: + if args.remove_divide is not None: + try: + _validate_remove_divide(args.remove_divide, template_vertex_count, template_mesh) + except (FileNotFoundError, ValueError) as exc: + errors.append(str(exc)) + if args.remove_perturb is not None: + try: + _validate_remove_perturb(args.remove_perturb, template_vertex_count) + except (FileNotFoundError, ValueError) as exc: + errors.append(str(exc)) if errors: print("\nValidation FAILED with the following errors:") diff --git a/test/test_advcp_asset_scripts.py b/test/test_advcp_asset_scripts.py index 2723c14b..107fde12 100644 --- a/test/test_advcp_asset_scripts.py +++ b/test/test_advcp_asset_scripts.py @@ -21,6 +21,7 @@ from opencda.core.attack.advcp.utils.asset_utils import ( MeshData, + advshape_template_mesh, box_mesh, copy_or_generate_mesh, dump_divide_pickle, @@ -28,6 +29,7 @@ load_divide_pickle, read_mesh, save_perturbation, + subdivide_midpoint, write_ascii_ply, ) @@ -220,15 +222,101 @@ def test_validate_car_mesh_only(self, sample_box_mesh: MeshData, tmp_output: Pat main() # should not raise + @staticmethod + def _run_validator(*arguments: str) -> None: + sys.argv = ["validate_advcp_assets", *arguments] + from scripts.advcp.validate_advcp_assets import main + + main() + + @staticmethod + def _generate_removal_assets(tmp_output: Path) -> tuple[Path, Path]: + """Generate removal assets with the removal CLI (default adv-shape template).""" + divide_path = tmp_output / "remove.pkl" + perturb_path = tmp_output / "perturb.npy" + sys.argv = [ + "generate_remove_advshape_assets", + "--mode", + "both", + "--divide-output", + str(divide_path), + "--perturb-output", + str(perturb_path), + ] + from scripts.advcp.generate_remove_advshape_assets import main + + main() + return divide_path, perturb_path + + def test_validate_all_generated_assets(self, tmp_output: Path) -> None: + """Documented workflow: car mesh and removal template have different vertex counts.""" + car_mesh = subdivide_midpoint(box_mesh(4.3, 1.91, 1.26), levels=3) + assert car_mesh.vertices.shape[0] != advshape_template_mesh().vertices.shape[0] + mesh_path = tmp_output / "mesh.ply" + write_ascii_ply(car_mesh, mesh_path) + spoof_path = tmp_output / "spoof.pkl" + dump_divide_pickle(generate_divide_indices(car_mesh.vertices, "spoof"), spoof_path) + remove_path, perturb_path = self._generate_removal_assets(tmp_output) + self._run_validator( + "--car-mesh", + str(mesh_path), + "--spoof-divide", + str(spoof_path), + "--remove-divide", + str(remove_path), + "--remove-perturb", + str(perturb_path), + ) + + def test_validate_removal_assets_without_car_mesh(self, tmp_output: Path) -> None: + remove_path, perturb_path = self._generate_removal_assets(tmp_output) + self._run_validator("--remove-divide", str(remove_path), "--remove-perturb", str(perturb_path)) + + def test_validate_fails_on_car_sized_perturbation(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + """A perturbation sized for the car mesh does not fit the adv-shape template.""" + perturb_path = tmp_output / "perturb.npy" + save_perturbation(perturb_path, np.zeros((sample_box_mesh.vertices.shape[0], 3), dtype=np.float32)) + with pytest.raises(SystemExit): + self._run_validator("--remove-perturb", str(perturb_path)) + + def test_validate_fails_on_out_of_range_removal_divide(self, tmp_output: Path) -> None: + remove_path = tmp_output / "remove.pkl" + dump_divide_pickle([np.array([0, 1, 500], dtype=np.int32)] * 10, remove_path) + with pytest.raises(SystemExit): + self._run_validator("--remove-divide", str(remove_path)) + + def test_validate_fails_on_empty_removal_pieces(self, tmp_output: Path) -> None: + """Groups of isolated vertices keep no template triangles.""" + remove_path = tmp_output / "remove.pkl" + dump_divide_pickle([np.array([0, 1], dtype=np.int32)] * 10, remove_path) + with pytest.raises(SystemExit): + self._run_validator("--remove-divide", str(remove_path)) + + def test_template_vertices_override_takes_precedence(self, sample_box_mesh: MeshData, tmp_output: Path) -> None: + remove_path = tmp_output / "remove.pkl" + dump_divide_pickle(generate_divide_indices(sample_box_mesh.vertices, "remove"), remove_path) + perturb_path = tmp_output / "perturb.npy" + save_perturbation(perturb_path, np.zeros((8, 3), dtype=np.float32)) + with pytest.raises(SystemExit): + self._run_validator("--remove-perturb", str(perturb_path)) + self._run_validator( + "--advshape-template-vertices", + "8", + "--remove-divide", + str(remove_path), + "--remove-perturb", + str(perturb_path), + ) + def test_validate_all_assets(self, sample_car_mesh: MeshData, tmp_output: Path) -> None: mesh_path = tmp_output / "mesh.ply" write_ascii_ply(sample_car_mesh, mesh_path) spoof_path = tmp_output / "spoof.pkl" dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "spoof"), spoof_path) remove_path = tmp_output / "remove.pkl" - dump_divide_pickle(generate_divide_indices(sample_car_mesh.vertices, "remove"), remove_path) + dump_divide_pickle(generate_divide_indices(advshape_template_mesh().vertices, "remove"), remove_path) perturb_path = tmp_output / "perturb.npy" - save_perturbation(perturb_path, np.zeros((sample_car_mesh.vertices.shape[0], 3), dtype=np.float32)) + save_perturbation(perturb_path, np.zeros((advshape_template_mesh().vertices.shape[0], 3), dtype=np.float32)) sys.argv = [ "validate_advcp_assets", "--car-mesh", From a9f9bb458614626b2abc5366321d8a71327d6218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D1=83=D1=80=D0=B0=D0=B4=D1=8F=D0=BD=20=D0=90=D1=80?= =?UTF-8?q?=D1=82=D1=91=D0=BC=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Tue, 6 Oct 2026 01:16:54 +0300 Subject: [PATCH 6/7] [AdvCP] Reject unknown CARLA blueprints instead of falling back silently Blueprint-based mesh generation only looks up approximate dimensions in a small table and builds a generic box; no CARLA geometry is queried. Document it as such, and raise an actionable error for blueprints that are not in the table (pass --dimensions / car_mesh_dimensions or an external mesh instead) rather than silently using Tesla Model 3 dimensions. The CLI reports the error through argparse. --- .../advcp/test/test_advcp_asset_utils.py | 6 ++--- .../core/attack/advcp/utils/asset_utils.py | 24 ++++++++++++++++--- scripts/advcp/generate_car_mesh.py | 22 +++++++++++++---- test/test_advcp_asset_scripts.py | 18 ++++++++++++++ 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py index 9a15d5d1..a89a38c5 100644 --- a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py +++ b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py @@ -155,9 +155,9 @@ def test_known_blueprint(self) -> None: assert blueprint_dimensions_m("vehicle.tesla.model3") == (4.30, 1.91, 1.26) assert blueprint_dimensions_m("vehicle.audi.a2") == (3.70, 1.70, 1.55) - def test_unknown_blueprint_falls_back_to_default(self) -> None: - dims = blueprint_dimensions_m("vehicle.unknown.foo") - assert dims == (4.30, 1.91, 1.26) + def test_unknown_blueprint_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown CARLA blueprint 'vehicle.unknown.foo'"): + blueprint_dimensions_m("vehicle.unknown.foo") def test_none_blueprint_falls_back_to_default(self) -> None: dims = blueprint_dimensions_m(None) diff --git a/opencda/core/attack/advcp/utils/asset_utils.py b/opencda/core/attack/advcp/utils/asset_utils.py index 906d7600..0d17705d 100644 --- a/opencda/core/attack/advcp/utils/asset_utils.py +++ b/opencda/core/attack/advcp/utils/asset_utils.py @@ -74,21 +74,39 @@ class MeshData: def blueprint_dimensions_m(blueprint: str | None) -> tuple[float, float, float]: """Return the (length, width, height) in meters for a CARLA blueprint. + This is a lookup in a small table of approximate dimensions; no CARLA + geometry is queried. Blueprint-based generation therefore produces a + *generic box* with the vehicle's footprint, not the vehicle's shape. + For a faithful shape, export the CARLA vehicle mesh and pass it as an + external mesh input. + Parameters ---------- blueprint : str or None CARLA blueprint identifier (e.g. ``"vehicle.tesla.model3"``). - When ``None`` or unknown, default Tesla Model 3 dimensions are - returned. + ``None`` selects the default (Tesla Model 3) dimensions. Returns ------- tuple of float ``(length, width, height)`` in meters. + + Raises + ------ + ValueError + If *blueprint* is not in the lookup table. Pass explicit + dimensions instead. """ if blueprint is None: return _DEFAULT_DIMENSIONS_M - return _BLUEPRINT_DIMENSIONS_M.get(blueprint, _DEFAULT_DIMENSIONS_M) + try: + return _BLUEPRINT_DIMENSIONS_M[blueprint] + except KeyError: + known = ", ".join(sorted(_BLUEPRINT_DIMENSIONS_M)) + raise ValueError( + f"Unknown CARLA blueprint '{blueprint}': no dimensions are known for it. " + f"Pass explicit dimensions (--dimensions / car_mesh_dimensions) or an external mesh. Known blueprints: {known}." + ) from None def parse_dimensions_arg(raw_dimensions: Iterable[float] | None) -> tuple[float, float, float] | None: diff --git a/scripts/advcp/generate_car_mesh.py b/scripts/advcp/generate_car_mesh.py index bb46a168..53ed6ea5 100644 --- a/scripts/advcp/generate_car_mesh.py +++ b/scripts/advcp/generate_car_mesh.py @@ -7,8 +7,11 @@ The mesh can be constructed in two ways: -1. **From a CARLA blueprint** — a simple box mesh is generated using the - blueprint's known dimensions (or user-supplied dimensions). +1. **Generic box** — a subdivided box with the footprint of a CARLA + blueprint, looked up in a small table of approximate dimensions (or + user-supplied ``--dimensions``). No CARLA geometry is queried, so this + approximates only the vehicle's size, not its shape; unknown blueprints + are rejected unless ``--dimensions`` is given. 2. **From an external mesh file** — a ``.ply`` or ``.obj`` file is loaded, normalised to a bottom-centre frame, and scaled to the target dimensions. @@ -59,7 +62,12 @@ def _build_parser() -> argparse.ArgumentParser: dimensions, aspect-preservation flag, and output path. """ parser = argparse.ArgumentParser(description="Generate AdvCP-compatible vehicle mesh (.ply).") - parser.add_argument("--vehicle-blueprint", type=str, default="vehicle.tesla.model3", help="CARLA blueprint id.") + parser.add_argument( + "--vehicle-blueprint", + type=str, + default="vehicle.tesla.model3", + help="CARLA blueprint id whose approximate dimensions size the generic box (ignored with --dimensions).", + ) parser.add_argument("--mesh-input", type=Path, default=None, help="Optional external .ply/.obj mesh input.") parser.add_argument( "--dimensions", @@ -81,8 +89,12 @@ def main() -> None: or blueprint lookup), generates or copies the mesh, writes the result to the output path, and prints a summary. """ - args = _build_parser().parse_args() - dimensions = parse_dimensions_arg(args.dimensions) or blueprint_dimensions_m(args.vehicle_blueprint) + parser = _build_parser() + args = parser.parse_args() + try: + dimensions = parse_dimensions_arg(args.dimensions) or blueprint_dimensions_m(args.vehicle_blueprint) + except ValueError as exc: + parser.error(str(exc)) mesh = copy_or_generate_mesh(args.mesh_input, dimensions=dimensions, preserve_aspect=args.preserve_aspect) write_mesh(args.output, mesh) print(f"Generated car mesh: {args.output}") diff --git a/test/test_advcp_asset_scripts.py b/test/test_advcp_asset_scripts.py index 107fde12..4f9a19fd 100644 --- a/test/test_advcp_asset_scripts.py +++ b/test/test_advcp_asset_scripts.py @@ -92,6 +92,24 @@ def test_with_custom_dimensions(self, tmp_output: Path) -> None: size = mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0) np.testing.assert_allclose(size, [5.0, 2.0, 1.8], atol=1e-6) + def test_unknown_blueprint_is_rejected(self, tmp_output: Path, capsys: pytest.CaptureFixture[str]) -> None: + out = tmp_output / "car.ply" + sys.argv = ["generate_car_mesh", "--vehicle-blueprint", "vehicle.unknown.foo", "--output", str(out)] + from scripts.advcp.generate_car_mesh import main + + with pytest.raises(SystemExit): + main() + assert "Unknown CARLA blueprint 'vehicle.unknown.foo'" in capsys.readouterr().err + assert not out.exists() + + def test_unknown_blueprint_with_dimensions(self, tmp_output: Path) -> None: + out = tmp_output / "car.ply" + sys.argv = ["generate_car_mesh", "--vehicle-blueprint", "vehicle.unknown.foo", "--dimensions", "4.0", "1.8", "1.5", "--output", str(out)] + from scripts.advcp.generate_car_mesh import main + + main() + assert out.exists() + class TestGenerateMeshDivideCLI: """Smoke tests for generate_mesh_divide CLI.""" From 44a9e1f2170d5086a8cc25ea2afa8707c95d2b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D1=83=D1=80=D0=B0=D0=B4=D1=8F=D0=BD=20=D0=90=D1=80?= =?UTF-8?q?=D1=82=D1=91=D0=BC=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Tue, 6 Oct 2026 01:17:10 +0300 Subject: [PATCH 7/7] [AdvCP] Wire runtime asset generation into config loading AdvCoperceptionModelManager.load_config now calls AdvCPRuntimeAssetHelper.prepare_assets, so `asset_runtime_generation: true` in the AdvCP YAML generates the car mesh and spoof divide (and, with advshape, removal assets) on demand and points the config at them. Generation and caching are now independent: - explicit asset paths: missing files are generated there; files without generation metadata are user-provided and never overwritten (a missing spoof divide is derived from a user mesh); - asset_cache_dir: assets are stored under a key hashing every generation input (blueprint, dimensions, source mesh content, preserve_aspect, subdivision, format version) and a metadata.json is checked before reuse, so changed inputs never reuse stale assets; - neither: spoof assets go to a per-process temporary directory and removal assets fall back to the identical runtime default partition. Bundle default paths are never written to. Relative paths resolve against the AdvCP YAML directory. New config keys are documented in AdvCPConfig and example.yaml. --- .../advcp/adv_coperception_model_manager.py | 8 + .../core/attack/advcp/early_fusion_attack.py | 10 +- opencda/core/attack/advcp/test/test_advcp.py | 18 + .../advcp/test/test_advcp_asset_utils.py | 207 ++++++-- opencda/core/attack/advcp/types.py | 28 + .../core/attack/advcp/utils/runtime_assets.py | 486 +++++++++++++----- .../config_yaml/advcp-configs/example.yaml | 13 + 7 files changed, 567 insertions(+), 203 deletions(-) diff --git a/opencda/core/attack/advcp/adv_coperception_model_manager.py b/opencda/core/attack/advcp/adv_coperception_model_manager.py index 442dd344..7307f24b 100644 --- a/opencda/core/attack/advcp/adv_coperception_model_manager.py +++ b/opencda/core/attack/advcp/adv_coperception_model_manager.py @@ -45,6 +45,7 @@ AgentId, AttackerId, ) +from opencda.core.attack.advcp.utils.runtime_assets import AdvCPRuntimeAssetHelper from opencda.core.common.coperception_model_manager import ( CoperceptionInferenceResult, CoperceptionModelManager, @@ -407,6 +408,10 @@ def load_config(config_path: str | None, assets_dir: str | None = None) -> AdvCP step_value = cast(int | str, config["step"]) config.setdefault("lr", 1.0 if int(step_value) <= 2 else 0.05) config.setdefault("feature_size", 10) + # Runtime asset generation writes to user-set spoof paths only; bundle defaults are never overwritten. + spoof_paths_explicit = ("car_mesh_path" in config or "model_path" in config) and ( + "car_mesh_divide_path" in config or "mesh_divide_path" in config + ) config.setdefault("car_mesh_path", config.get("model_path", str(local_model_root / "car_mesh_0200.ply"))) config.setdefault( "car_mesh_divide_path", @@ -448,6 +453,9 @@ def load_config(config_path: str | None, assets_dir: str | None = None) -> AdvCP path = Path(str(path_value)).expanduser() if not path.is_absolute(): config[optional_path_key] = str((config_dir / path).resolve()) + + # Opt-in on-demand generation (``asset_runtime_generation: true``); points the asset paths at the result. + AdvCPRuntimeAssetHelper.prepare_assets(config, config_dir, spoof_paths_explicit) return cast(AdvCPConfig, config) def validate_advcp_agents(self, valid_agent_ids: list[AgentId]) -> bool: diff --git a/opencda/core/attack/advcp/early_fusion_attack.py b/opencda/core/attack/advcp/early_fusion_attack.py index 0de1af30..4151e57c 100644 --- a/opencda/core/attack/advcp/early_fusion_attack.py +++ b/opencda/core/attack/advcp/early_fusion_attack.py @@ -55,11 +55,6 @@ from opencood.utils.transformation_utils import x_to_world from opencda.core.attack.advcp.attack_helper import AdvCPAttackHelper, AdvCPCarMeshHelper -from opencda.core.attack.advcp.utils.asset_utils import ( - ADVSHAPE_TEMPLATE_DIMENSIONS_M, - advshape_default_divide, - advshape_template_mesh, -) from opencda.core.attack.advcp.types import ( AdvCPAttackResult, AdvCPConfig, @@ -69,6 +64,11 @@ AttackerId, BoxLwhBottomCenter, ) +from opencda.core.attack.advcp.utils.asset_utils import ( + ADVSHAPE_TEMPLATE_DIMENSIONS_M, + advshape_default_divide, + advshape_template_mesh, +) logger = logging.getLogger("cavise.opencda.opencda.core.attack.advcp.early_fusion_attack") diff --git a/opencda/core/attack/advcp/test/test_advcp.py b/opencda/core/attack/advcp/test/test_advcp.py index 4d98d034..fbcf83cd 100644 --- a/opencda/core/attack/advcp/test/test_advcp.py +++ b/opencda/core/attack/advcp/test/test_advcp.py @@ -1,6 +1,7 @@ import pytest from unittest.mock import MagicMock, patch from importlib import import_module +from pathlib import Path import numpy as np # The production code imports are now safe because pytest_configure in conftest.py @@ -975,6 +976,23 @@ def test_load_config_uses_resolved_advcp_asset_bundle(self, tmp_path): assert loaded_config["car_mesh_path"] == str(tmp_path / "car_mesh_0200.ply") assert loaded_config["car_mesh_divide_path"] == str(tmp_path / "spoof" / "car_mesh_divide.pkl") + def test_load_config_generates_runtime_assets_when_enabled(self, tmp_path): + config_path = tmp_path / "advcp.yaml" + config_path.write_text( + "asset_runtime_generation: true\nvehicle_blueprint: vehicle.audi.a2\nadvshape: true\nasset_cache_dir: cache\n", + encoding="utf-8", + ) + bundle_dir = tmp_path / "bundle" + + loaded_config = AdvCoperceptionModelManager.load_config(str(config_path), assets_dir=str(bundle_dir)) + + cache_dir = (tmp_path / "cache").resolve() + for key in ("car_mesh_path", "car_mesh_divide_path", "remove_adv_shape_divide_path"): + path = Path(loaded_config[key]) + assert path.exists() + assert cache_dir in path.parents + assert not bundle_dir.exists() + class TestAdvCoperceptionVisualizer: def test_advcp_visualizer_includes_removed_boxes_from_context(self): diff --git a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py index a89a38c5..fe2e641d 100644 --- a/opencda/core/attack/advcp/test/test_advcp_asset_utils.py +++ b/opencda/core/attack/advcp/test/test_advcp_asset_utils.py @@ -33,6 +33,7 @@ from __future__ import annotations import hashlib +import json import pickle import struct import tempfile @@ -916,92 +917,184 @@ def test_round_trip(self, tmp_output: Path) -> None: # ========================================================================= -# Runtime asset helper (Python >= 3.10 only due to TypeAlias in types.py) +# Runtime asset helper # ========================================================================= _RUNTIME_ASSETS_AVAILABLE: bool = True try: - from opencda.core.attack.advcp.utils.runtime_assets import AdvCPRuntimeAssetHelper + from opencda.core.attack.advcp.utils.runtime_assets import ASSET_FORMAT_VERSION, AdvCPRuntimeAssetHelper except ImportError: _RUNTIME_ASSETS_AVAILABLE = False -@pytest.mark.skipif( - not _RUNTIME_ASSETS_AVAILABLE, - reason="AdvCPRuntimeAssetHelper requires Python >= 3.10 (TypeAlias in types.py)", -) +@pytest.mark.skipif(not _RUNTIME_ASSETS_AVAILABLE, reason="AdvCP runtime asset helper could not be imported") class TestAdvCPRuntimeAssetHelper: """Runtime asset generation helper.""" - def test_ensure_spoof_assets_generates_missing(self, tmp_output: Path) -> None: - config: dict[str, Any] = { - "car_mesh_path": str(tmp_output / "car_mesh.ply"), - "car_mesh_divide_path": str(tmp_output / "spoof" / "car_mesh_divide.pkl"), - "vehicle_blueprint": "vehicle.tesla.model3", - "asset_runtime_generation": True, - } + @staticmethod + def _spoof_config(**overrides: Any) -> dict[str, Any]: + config: dict[str, Any] = {"asset_runtime_generation": True, "vehicle_blueprint": "vehicle.tesla.model3"} + config.update(overrides) + return config + + def test_spoof_without_cache_or_paths_uses_temporary_dir(self) -> None: + config = self._spoof_config() + mesh_path, divide_path = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config, spoof_paths_explicit=False) + assert mesh_path.exists() and divide_path.exists() + assert mesh_path.name == "car_mesh.ply" + assert config["car_mesh_path"] == str(mesh_path) + assert config["car_mesh_divide_path"] == str(divide_path) + mesh = read_mesh(mesh_path) + assert divide_piece_face_counts(load_divide_pickle(divide_path), mesh.faces)[6:] != [0, 0] + + def test_spoof_generates_missing_explicit_paths(self, tmp_output: Path) -> None: + config = self._spoof_config( + car_mesh_path=str(tmp_output / "car_mesh.ply"), + car_mesh_divide_path=str(tmp_output / "spoof" / "car_mesh_divide.pkl"), + ) mesh_path, divide_path = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) + assert mesh_path == tmp_output / "car_mesh.ply" + assert len(load_divide_pickle(divide_path)) == 8 + assert (tmp_output / "car_mesh.ply.advcp-meta.json").exists() + + def test_spoof_explicit_paths_resolve_against_config_dir(self, tmp_output: Path) -> None: + config = self._spoof_config(car_mesh_path="assets/car.ply", car_mesh_divide_path="assets/car.pkl") + mesh_path, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config, config_dir=tmp_output) + assert mesh_path == (tmp_output / "assets" / "car.ply").resolve() assert mesh_path.exists() - assert divide_path.exists() - divide = load_divide_pickle(divide_path) - assert len(divide) == 8 - def test_ensure_spoof_assets_uses_cache(self, tmp_output: Path) -> None: + def test_spoof_user_files_are_reused_untouched(self, tmp_output: Path) -> None: mesh_path = tmp_output / "car_mesh.ply" divide_path = tmp_output / "spoof" / "car_mesh_divide.pkl" - mesh_path.parent.mkdir(parents=True, exist_ok=True) - box = box_mesh(4.3, 1.91, 1.26) - write_ascii_ply(box, mesh_path) - dump_divide_pickle(generate_divide_indices(box.vertices, "spoof"), divide_path) - mtime_before = mesh_path.stat().st_mtime - config: dict[str, Any] = { - "car_mesh_path": str(mesh_path), - "car_mesh_divide_path": str(divide_path), - "asset_runtime_generation": True, - } + mesh = subdivide_midpoint(box_mesh(4.0, 1.8, 1.5), levels=2) + write_ascii_ply(mesh, mesh_path) + dump_divide_pickle(generate_divide_indices(mesh.vertices, "spoof"), divide_path) + mesh_mtime, divide_mtime = mesh_path.stat().st_mtime_ns, divide_path.stat().st_mtime_ns + config = self._spoof_config(car_mesh_path=str(mesh_path), car_mesh_divide_path=str(divide_path)) AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) - assert mesh_path.stat().st_mtime == mtime_before + assert mesh_path.stat().st_mtime_ns == mesh_mtime + assert divide_path.stat().st_mtime_ns == divide_mtime - def test_ensure_spoof_assets_disabled(self, tmp_output: Path) -> None: - config: dict[str, Any] = { - "car_mesh_path": str(tmp_output / "car_mesh.ply"), - "car_mesh_divide_path": str(tmp_output / "spoof" / "car_mesh_divide.pkl"), - "asset_runtime_generation": False, - } - mesh_path, divide_path = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) - assert not mesh_path.exists() - assert not divide_path.exists() - - def test_ensure_remove_advshape_assets_no_cache_dir(self) -> None: - config: dict[str, Any] = { - "asset_runtime_generation": True, - } + def test_spoof_divide_derived_from_user_mesh(self, tmp_output: Path) -> None: + mesh_path = tmp_output / "car_mesh.ply" + divide_path = tmp_output / "spoof" / "car_mesh_divide.pkl" + user_mesh = subdivide_midpoint(box_mesh(4.0, 1.8, 1.5), levels=3) + write_ascii_ply(user_mesh, mesh_path) + mesh_mtime = mesh_path.stat().st_mtime_ns + config = self._spoof_config(car_mesh_path=str(mesh_path), car_mesh_divide_path=str(divide_path)) + AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) + assert mesh_path.stat().st_mtime_ns == mesh_mtime + divide = load_divide_pickle(divide_path) + assert max(int(group.max()) for group in divide) < user_mesh.vertices.shape[0] + assert max(int(group.max()) for group in divide) >= 98 + + def test_spoof_stale_generated_files_are_regenerated(self, tmp_output: Path) -> None: + config = self._spoof_config( + car_mesh_path=str(tmp_output / "car_mesh.ply"), + car_mesh_divide_path=str(tmp_output / "car_mesh_divide.pkl"), + ) + AdvCPRuntimeAssetHelper.ensure_spoof_assets(dict(config)) + changed = dict(config, car_mesh_dimensions=[5.0, 2.0, 1.8]) + mesh_path, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(changed) + size = np.ptp(read_mesh(mesh_path).vertices, axis=0) + np.testing.assert_allclose(size, [5.0, 2.0, 1.8], atol=1e-6) + + def test_spoof_cache_reused_for_same_inputs(self, tmp_output: Path) -> None: + config = self._spoof_config(asset_cache_dir=str(tmp_output / "cache")) + mesh_path, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(dict(config)) + mtime = mesh_path.stat().st_mtime_ns + again, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(dict(config)) + assert again == mesh_path + assert again.stat().st_mtime_ns == mtime + metadata = json.loads((mesh_path.parent / "metadata.json").read_text()) + assert metadata["format_version"] == ASSET_FORMAT_VERSION + assert metadata["dimensions_m"] == [4.3, 1.91, 1.26] + + @pytest.mark.parametrize( + "override", + [ + {"car_mesh_dimensions": [5.0, 2.0, 1.8]}, + {"vehicle_blueprint": "vehicle.audi.a2"}, + {"car_mesh_preserve_aspect": False}, + ], + ) + def test_spoof_cache_key_covers_generation_inputs(self, tmp_output: Path, override: dict[str, Any]) -> None: + base = self._spoof_config(asset_cache_dir=str(tmp_output / "cache")) + first, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(dict(base)) + second, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(dict(base, **override)) + assert first.parent != second.parent + + def test_spoof_cache_key_covers_source_mesh_content(self, tmp_output: Path) -> None: + source = tmp_output / "source.ply" + write_ascii_ply(subdivide_midpoint(box_mesh(4.0, 1.8, 1.5), levels=2), source) + config = self._spoof_config(asset_cache_dir=str(tmp_output / "cache"), car_mesh_source_path=str(source)) + first, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(dict(config)) + write_ascii_ply(subdivide_midpoint(box_mesh(3.0, 1.8, 1.5), levels=2), source) + second, _ = AdvCPRuntimeAssetHelper.ensure_spoof_assets(dict(config)) + assert first.parent != second.parent + + def test_spoof_unknown_blueprint_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown CARLA blueprint"): + AdvCPRuntimeAssetHelper.ensure_spoof_assets(self._spoof_config(vehicle_blueprint="vehicle.unknown.foo"), spoof_paths_explicit=False) + + def test_remove_without_cache_or_paths_needs_no_generation(self) -> None: + config: dict[str, Any] = {"asset_runtime_generation": True} + assert AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) is None + assert "remove_adv_shape_divide_path" not in config + + def test_remove_generates_to_explicit_path_without_cache(self, tmp_output: Path) -> None: + divide_path = tmp_output / "remove" / "mesh_divide.pkl" + config: dict[str, Any] = {"asset_runtime_generation": True, "remove_adv_shape_divide_path": str(divide_path)} result = AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) - assert result is None + assert result == (divide_path, None) + template = advshape_template_mesh() + for generated, runtime_default in zip(load_divide_pickle(divide_path), advshape_default_divide(template.vertices)): + np.testing.assert_array_equal(generated, runtime_default) - def test_ensure_remove_advshape_assets_generates(self, tmp_output: Path) -> None: - cache_dir = tmp_output / "cache" + def test_remove_generates_into_cache_with_zero_perturb(self, tmp_output: Path) -> None: config: dict[str, Any] = { "asset_runtime_generation": True, - "asset_cache_dir": str(cache_dir), + "asset_cache_dir": str(tmp_output / "cache"), "remove_adv_shape_generate_zero_perturb": True, } result = AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) assert result is not None - perturb_path, divide_path = result - assert divide_path.exists() - assert perturb_path.exists() - divide = load_divide_pickle(divide_path) - assert len(divide) == 10 - - def test_ensure_remove_advshape_assets_updates_config(self, tmp_output: Path) -> None: - cache_dir = tmp_output / "cache" + divide_path, perturb_path = result + assert perturb_path is not None + assert len(load_divide_pickle(divide_path)) == 10 + assert load_perturbation(perturb_path).shape == (98, 3) + assert config["remove_adv_shape_divide_path"] == str(divide_path) + assert config["remove_adv_shape_perturb_path"] == str(perturb_path) + + def test_remove_user_divide_is_not_overwritten(self, tmp_output: Path) -> None: + divide_path = tmp_output / "mesh_divide.pkl" + custom = [np.arange(98, dtype=np.int32)] * 10 + dump_divide_pickle(custom, divide_path) config: dict[str, Any] = { "asset_runtime_generation": True, - "asset_cache_dir": str(cache_dir), + "remove_adv_shape_divide_path": str(divide_path), + "remove_adv_shape_generate_zero_perturb": True, } - AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) - assert "remove_adv_shape_divide_path" in config + _, perturb_path = AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) or (None, None) + np.testing.assert_array_equal(load_divide_pickle(divide_path)[0], custom[0]) + assert perturb_path is not None and perturb_path.exists() + + def test_prepare_assets_disabled_is_noop(self) -> None: + config: dict[str, Any] = {"car_mesh_path": "/nonexistent/car.ply", "car_mesh_divide_path": "/nonexistent/car.pkl"} + AdvCPRuntimeAssetHelper.prepare_assets(config, None, spoof_paths_explicit=False) + assert config["car_mesh_path"] == "/nonexistent/car.ply" + + def test_prepare_assets_ignores_bundle_default_paths(self, tmp_output: Path) -> None: + bundle_mesh = tmp_output / "bundle" / "car_mesh_0200.ply" + config = self._spoof_config(car_mesh_path=str(bundle_mesh), car_mesh_divide_path=str(tmp_output / "bundle" / "divide.pkl")) + AdvCPRuntimeAssetHelper.prepare_assets(config, None, spoof_paths_explicit=False) + assert not bundle_mesh.exists() + assert Path(config["car_mesh_path"]).exists() + + def test_prepare_assets_with_advshape(self, tmp_output: Path) -> None: + config = self._spoof_config(advshape=True, asset_cache_dir=str(tmp_output / "cache")) + AdvCPRuntimeAssetHelper.prepare_assets(config, None, spoof_paths_explicit=False) + assert Path(config["car_mesh_path"]).exists() assert Path(config["remove_adv_shape_divide_path"]).exists() diff --git a/opencda/core/attack/advcp/types.py b/opencda/core/attack/advcp/types.py index 131f8e49..19d27de9 100644 --- a/opencda/core/attack/advcp/types.py +++ b/opencda/core/attack/advcp/types.py @@ -232,6 +232,27 @@ class AdvCPConfig(TypedDict, total=False): ``True``. model_path, mesh_divide_path : str Backwards-compatible aliases honoured by ``load_config``. + asset_runtime_generation : bool + Generate the car mesh / divide (and, with ``advshape``, removal + assets) on demand instead of using the asset bundle. See + ``opencda.core.attack.advcp.utils.runtime_assets``. + asset_cache_dir : str + Optional directory for reusable generated assets, keyed by a hash + of all generation inputs. + vehicle_blueprint : str + CARLA blueprint whose approximate dimensions size the generated + generic box mesh. + car_mesh_dimensions : Sequence of float + Explicit ``(length, width, height)`` of the generated mesh; + overrides ``vehicle_blueprint``. + car_mesh_source_path : str + Optional external ``.ply`` / ``.obj`` mesh to normalise and scale + instead of generating a box. + car_mesh_preserve_aspect : bool + Preserve the source mesh proportions while scaling. + remove_adv_shape_generate_zero_perturb : bool + Also write a zero adv-shape perturbation when generating removal + assets. """ mode: str @@ -254,6 +275,13 @@ class AdvCPConfig(TypedDict, total=False): remove_adv_shape_divide_path: str # noqa: DC01 model_path: str # noqa: DC01 mesh_divide_path: str # noqa: DC01 + asset_runtime_generation: bool # noqa: DC01 + asset_cache_dir: str # noqa: DC01 + vehicle_blueprint: str # noqa: DC01 + car_mesh_dimensions: Sequence[float] # noqa: DC01 + car_mesh_source_path: str # noqa: DC01 + car_mesh_preserve_aspect: bool # noqa: DC01 + remove_adv_shape_generate_zero_perturb: bool # noqa: DC01 class AdvCPIntermediateAttackState(TypedDict, total=False): diff --git a/opencda/core/attack/advcp/utils/runtime_assets.py b/opencda/core/attack/advcp/utils/runtime_assets.py index 43bbee55..1ee5af50 100644 --- a/opencda/core/attack/advcp/utils/runtime_assets.py +++ b/opencda/core/attack/advcp/utils/runtime_assets.py @@ -1,38 +1,60 @@ """ Runtime asset generation helper for AdvCP mesh-derived assets. -Provides :class:`AdvCPRuntimeAssetHelper`, which is used by the AdvCP -attack pipeline to generate spoof and removal assets on demand during -simulation. This avoids requiring pre-generated ``.ply``, ``.pkl``, and -``.npy`` files to be committed to the repository. - -The helper supports two asset categories: - -1. **Spoof assets** — car mesh (``.ply``) and spoof mesh-divide (``.pkl``). -2. **Removal advshape assets** — removal mesh-divide (``.pkl``) and - optional perturbation tensor (``.npy``). - -Assets are generated once and cached on disk. Subsequent runs reuse the -cached files. The cache location is configurable via the -``asset_cache_dir`` config key. +Provides :class:`AdvCPRuntimeAssetHelper`, which the AdvCP model manager +uses to generate spoof and removal assets on demand while loading the +AdvCP config, so that ``.ply``, ``.pkl`` and ``.npy`` files do not have +to be committed or downloaded beforehand. + +Runtime generation is opt-in through the AdvCP YAML:: + + asset_runtime_generation: true + vehicle_blueprint: vehicle.tesla.model3 # or car_mesh_dimensions: [4.3, 1.91, 1.26] + # car_mesh_source_path: /path/to/vehicle.obj + # car_mesh_preserve_aspect: true + # asset_cache_dir: ~/.cache/cavise/advcp-assets + +Generation and caching are independent: + +- **Explicit paths** (``car_mesh_path`` / ``car_mesh_divide_path``, + ``remove_adv_shape_*_path``): missing files are generated there. +- **Cache** (``asset_cache_dir``): assets are stored under a directory + keyed by a hash of every generation input and reused across runs. +- **Neither**: spoof assets are generated into a per-process temporary + directory. Removal assets need no generation in that case, because the + runtime's default adv-shape partition is identical to the generated one. + +Every generated asset set carries a ``metadata.json`` (or a +``.advcp-meta.json`` sidecar for explicit paths) recording the +generation inputs and :data:`ASSET_FORMAT_VERSION`. Assets whose +metadata does not match the current inputs are regenerated. Files at +explicit paths without metadata are user-provided: they are never +overwritten, and only the missing files are derived from them (e.g. a +spoof divide for a user-supplied mesh). """ from __future__ import annotations +import hashlib +import json import logging +import tempfile from pathlib import Path -from typing import Any, Mapping +from typing import Any, MutableMapping import numpy as np -from opencda.core.attack.advcp.types import AdvCPConfig from opencda.core.attack.advcp.utils.asset_utils import ( + ADVSHAPE_TEMPLATE_DIMENSIONS_M, + ADVSHAPE_TEMPLATE_SUBDIVISION_LEVELS, + DEFAULT_CAR_MESH_SUBDIVISION_LEVELS, advshape_template_mesh, blueprint_dimensions_m, copy_or_generate_mesh, dump_divide_pickle, generate_divide_indices, parse_dimensions_arg, + read_mesh, save_perturbation, validate_divide_pieces, write_mesh, @@ -40,36 +62,40 @@ logger = logging.getLogger("cavise.opencda.opencda.core.attack.advcp.advcp_manager") +# Bump whenever the generation algorithm changes so that cached assets +# produced by an older algorithm are regenerated. +ASSET_FORMAT_VERSION = 2 + +_CACHE_METADATA_NAME = "metadata.json" +_SIDECAR_SUFFIX = ".advcp-meta.json" + class AdvCPRuntimeAssetHelper: """Runtime generation helper for AdvCP mesh-derived assets. - Generates and caches the 3D assets required by the AdvCP attack - pipeline (car mesh, mesh-divide metadata, and adversarial-shape - perturbation tensors) on demand during simulation. + Generates (or reuses) the car mesh, spoof mesh-divide, and removal + adv-shape assets described by the AdvCP config and points the config + at them. - Assets are written to the paths specified in the AdvCP config, or - to a configurable cache directory when ``asset_cache_dir`` is set. - Generation is skipped when the target files already exist. + Typical usage (performed by ``AdvCoperceptionModelManager.load_config``):: - Typical usage:: - - car_mesh_path, divide_path = AdvCPRuntimeAssetHelper.ensure_spoof_assets(config) - remove_paths = AdvCPRuntimeAssetHelper.ensure_remove_advshape_assets(config) + AdvCPRuntimeAssetHelper.prepare_assets(config, config_dir, spoof_paths_explicit=False) """ - @staticmethod - def _resolve_absolute(path_value: Any) -> Path: - """Resolve a config value to an absolute :class:`Path`. + _temporary_dir: Path | None = None - Expands the user home directory (``~``) and resolves relative - paths against the current working directory. + @staticmethod + def _resolve_path(path_value: Any, base_dir: Path | None) -> Path: + """Resolve a config path value to an absolute :class:`Path`. Parameters ---------- path_value : Any Path value from the AdvCP config (typically a ``str`` or ``Path``). + base_dir : Path or None + Directory relative paths are resolved against (the AdvCP YAML + directory); the current working directory when ``None``. Returns ------- @@ -78,162 +104,340 @@ def _resolve_absolute(path_value: Any) -> Path: """ path = Path(str(path_value)).expanduser() if not path.is_absolute(): - path = (Path.cwd() / path).resolve() + path = ((base_dir or Path.cwd()) / path).resolve() return path @classmethod - def _resolve_generation_roots(cls, config: Mapping[str, Any]) -> tuple[Path, Path]: - """Resolve the output paths for the car mesh and spoof mesh-divide. + def _temporary_root(cls) -> Path: + """Return the per-process temporary directory for uncached assets. - When ``asset_cache_dir`` is set in the config, the paths are - derived from the cache directory and the vehicle blueprint name. - Otherwise, the paths from ``car_mesh_path`` and - ``car_mesh_divide_path`` in the config are used directly. + Returns + ------- + Path + Directory created on first use and reused for the rest of the + process. + """ + if cls._temporary_dir is None or not cls._temporary_dir.exists(): + cls._temporary_dir = Path(tempfile.mkdtemp(prefix="advcp-assets-")) + return cls._temporary_dir + + @staticmethod + def _spec_key(spec: dict[str, Any]) -> str: + """Return a short, deterministic hash of generation inputs. Parameters ---------- - config : Mapping - AdvCP configuration mapping. + spec : dict + JSON-serialisable generation inputs. Returns ------- - tuple of Path - ``(car_mesh_path, car_mesh_divide_path)``. + str + First 16 hex digits of the SHA-256 of the canonical JSON. """ - car_mesh_path = cls._resolve_absolute(config.get("car_mesh_path")) - car_mesh_divide_path = cls._resolve_absolute(config.get("car_mesh_divide_path")) - cache_dir_value = config.get("asset_cache_dir") - if cache_dir_value is None: - return car_mesh_path, car_mesh_divide_path - - cache_dir = cls._resolve_absolute(cache_dir_value) - blueprint = str(config.get("vehicle_blueprint") or "vehicle.default").replace(".", "_") - mesh_name = f"car_mesh_{blueprint}.ply" - divide_name = f"car_mesh_divide_{blueprint}.pkl" - return cache_dir / mesh_name, cache_dir / "spoof" / divide_name + return hashlib.sha256(json.dumps(spec, sort_keys=True).encode("utf-8")).hexdigest()[:16] @staticmethod - def _resolve_dimensions(config: Mapping[str, Any]) -> tuple[float, float, float]: - """Resolve the target vehicle dimensions from the AdvCP config. + def _file_sha256(path: Path) -> str: + """Return the SHA-256 of a file's content. - Prefers an explicit ``car_mesh_dimensions`` override. Falls back - to the blueprint dimensions from :func:`blueprint_dimensions_m`. + Parameters + ---------- + path : Path + File to hash. + + Returns + ------- + str + Hex digest. + """ + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + @staticmethod + def _read_metadata(path: Path) -> dict[str, Any] | None: + """Read a metadata JSON file. Parameters ---------- - config : Mapping - AdvCP configuration mapping. + path : Path + Metadata file. Returns ------- - tuple of float - ``(length, width, height)`` in meters. + dict or None + Parsed metadata, or ``None`` when missing or unreadable. """ - dimensions_override = parse_dimensions_arg(config.get("car_mesh_dimensions")) - if dimensions_override is not None: - return dimensions_override - return blueprint_dimensions_m(config.get("vehicle_blueprint")) + try: + with path.open("r", encoding="utf-8") as handle: + metadata = json.load(handle) + except (OSError, ValueError): + return None + return metadata if isinstance(metadata, dict) else None - @classmethod - def ensure_spoof_assets(cls, config: AdvCPConfig) -> tuple[Path, Path]: - """Ensure that spoof assets (car mesh and mesh-divide) exist. + @staticmethod + def _write_metadata(path: Path, spec: dict[str, Any]) -> None: + """Write generation inputs as a metadata JSON file. - If the assets already exist on disk, or if runtime generation is - disabled (``asset_runtime_generation: false``), the configured - paths are returned without modification. + Parameters + ---------- + path : Path + Destination file; parent directories are created. + spec : dict + JSON-serialisable generation inputs. + """ + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(spec, handle, indent=2, sort_keys=True) - Otherwise, the car mesh is generated (from a source mesh or as a - box), the spoof mesh-divide indices are computed, and both are - written to disk. + @classmethod + def _is_current(cls, outputs: list[Path], metadata_path: Path, spec: dict[str, Any]) -> bool: + """Check whether a generated asset set exists and matches *spec*. Parameters ---------- - config : AdvCPConfig - Resolved AdvCP configuration. Relevant keys: - ``car_mesh_path``, ``car_mesh_divide_path``, - ``asset_cache_dir``, ``asset_runtime_generation``, - ``vehicle_blueprint``, ``car_mesh_dimensions``, - ``car_mesh_source_path``, ``car_mesh_preserve_aspect``. + outputs : list of Path + Asset files that make up the set. + metadata_path : Path + Metadata file recording the inputs the set was generated from. + spec : dict + Current generation inputs. Returns ------- - tuple of Path - ``(car_mesh_path, car_mesh_divide_path)`` — the paths to the - generated or existing assets. + bool + ``True`` when every output exists and the recorded inputs + equal *spec*. """ - car_mesh_path, car_mesh_divide_path = cls._resolve_generation_roots(config) - runtime_enabled = bool(config.get("asset_runtime_generation", True)) - if (car_mesh_path.exists() and car_mesh_divide_path.exists()) or not runtime_enabled: - return car_mesh_path, car_mesh_divide_path - - dimensions = cls._resolve_dimensions(config) - source_path_value = config.get("car_mesh_source_path") - source_path = cls._resolve_absolute(source_path_value) if source_path_value else None - preserve_aspect = bool(config.get("car_mesh_preserve_aspect", True)) - - mesh = copy_or_generate_mesh(source_path, dimensions=dimensions, preserve_aspect=preserve_aspect) - write_mesh(car_mesh_path, mesh) - divide = generate_divide_indices(mesh.vertices, mode="spoof") - validate_divide_pieces(divide, mesh.faces, "Generated spoof mesh divide") - dump_divide_pickle(divide, car_mesh_divide_path) - logger.info( - "Generated AdvCP runtime spoof assets at mesh='%s', divide='%s'.", - car_mesh_path, - car_mesh_divide_path, - ) - return car_mesh_path, car_mesh_divide_path + return all(path.exists() for path in outputs) and cls._read_metadata(metadata_path) == spec @classmethod - def ensure_remove_advshape_assets(cls, config: AdvCPConfig) -> tuple[Path, Path] | None: - """Ensure that removal advshape assets (mesh-divide and perturbation) exist. + def _spoof_spec(cls, config: MutableMapping[str, Any], config_dir: Path | None) -> tuple[dict[str, Any], Path | None]: + """Collect every input that determines the generated spoof assets. - Generates the removal mesh-divide from the AdvCP template mesh - and, optionally, a zero-initialised perturbation tensor when - ``remove_adv_shape_generate_zero_perturb`` is ``True``. + Parameters + ---------- + config : MutableMapping + AdvCP configuration mapping. + config_dir : Path or None + AdvCP YAML directory used to resolve relative paths. - Assets are written to the ``remove/`` subdirectory of the - configured ``asset_cache_dir``. Returns ``None`` when runtime - generation is disabled or no cache directory is configured. + Returns + ------- + tuple + ``(spec, source_path)`` where *spec* is the JSON-serialisable + generation input set and *source_path* the optional external + mesh. + + Raises + ------ + ValueError + If neither usable dimensions nor a known blueprint are given. + FileNotFoundError + If ``car_mesh_source_path`` does not exist. + """ + blueprint = config.get("vehicle_blueprint") + dimensions = parse_dimensions_arg(config.get("car_mesh_dimensions")) + if dimensions is None: + dimensions = blueprint_dimensions_m(blueprint) + source_value = config.get("car_mesh_source_path") + source_path = cls._resolve_path(source_value, config_dir) if source_value else None + if source_path is not None and not source_path.is_file(): + raise FileNotFoundError(f"AdvCP car_mesh_source_path '{source_path}' does not exist.") + spec = { + "format_version": ASSET_FORMAT_VERSION, + "kind": "spoof", + "vehicle_blueprint": blueprint, + "dimensions_m": [float(value) for value in dimensions], + "source_path": str(source_path) if source_path is not None else None, + "source_sha256": cls._file_sha256(source_path) if source_path is not None else None, + "preserve_aspect": bool(config.get("car_mesh_preserve_aspect", True)), + "subdivision_levels": DEFAULT_CAR_MESH_SUBDIVISION_LEVELS if source_path is None else 0, + } + return spec, source_path + + @classmethod + def ensure_spoof_assets( + cls, + config: MutableMapping[str, Any], + config_dir: Path | None = None, + spoof_paths_explicit: bool = True, + ) -> tuple[Path, Path]: + """Ensure that spoof assets (car mesh and mesh-divide) exist. + + The output location is chosen as follows: ``asset_cache_dir`` + (hash-keyed subdirectory) when configured; otherwise the explicit + ``car_mesh_path`` / ``car_mesh_divide_path``; otherwise a + per-process temporary directory. The config is updated to point + at the resulting files. Parameters ---------- - config : AdvCPConfig - Resolved AdvCP configuration. Relevant keys: - ``asset_runtime_generation``, ``asset_cache_dir``, - ``remove_adv_shape_generate_zero_perturb``. + config : MutableMapping + AdvCP configuration mapping; updated in place. + config_dir : Path or None + AdvCP YAML directory used to resolve relative paths. + spoof_paths_explicit : bool + Whether ``car_mesh_path`` / ``car_mesh_divide_path`` were set + by the user (as opposed to bundle defaults). Returns ------- - tuple of Path or None - ``(perturb_path, divide_path)`` when assets were generated - or already exist, or ``None`` when runtime generation is - disabled or no cache directory is configured. + tuple of Path + ``(car_mesh_path, car_mesh_divide_path)``. + + Raises + ------ + ValueError + If the dimensions cannot be resolved or a divide group would + produce an empty mesh piece. """ - runtime_enabled = bool(config.get("asset_runtime_generation", True)) - if not runtime_enabled: - return None + spec, source_path = cls._spoof_spec(config, config_dir) + cache_value = config.get("asset_cache_dir") + if cache_value is not None: + asset_dir = cls._resolve_path(cache_value, config_dir) / "spoof" / cls._spec_key(spec) + mesh_path, divide_path = asset_dir / "car_mesh.ply", asset_dir / "car_mesh_divide.pkl" + metadata_path = asset_dir / _CACHE_METADATA_NAME + elif spoof_paths_explicit and config.get("car_mesh_path") and config.get("car_mesh_divide_path"): + mesh_path = cls._resolve_path(config["car_mesh_path"], config_dir) + divide_path = cls._resolve_path(config["car_mesh_divide_path"], config_dir) + metadata_path = mesh_path.with_name(mesh_path.name + _SIDECAR_SUFFIX) + else: + asset_dir = cls._temporary_root() / "spoof" / cls._spec_key(spec) + mesh_path, divide_path = asset_dir / "car_mesh.ply", asset_dir / "car_mesh_divide.pkl" + metadata_path = asset_dir / _CACHE_METADATA_NAME + + user_owned_mesh = mesh_path.exists() and cls._read_metadata(metadata_path) is None + if cls._is_current([mesh_path, divide_path], metadata_path, spec) or (user_owned_mesh and divide_path.exists()): + logger.info("Reusing AdvCP spoof assets at mesh='%s', divide='%s'.", mesh_path, divide_path) + elif user_owned_mesh: + # Only the divide is missing: derive it from the user-provided mesh. + mesh = read_mesh(mesh_path) + divide = generate_divide_indices(mesh.vertices, mode="spoof") + validate_divide_pieces(divide, mesh.faces, f"Spoof mesh divide for '{mesh_path}'") + dump_divide_pickle(divide, divide_path) + logger.info("Generated AdvCP spoof divide '%s' for user-provided mesh '%s'.", divide_path, mesh_path) + else: + dimensions = (spec["dimensions_m"][0], spec["dimensions_m"][1], spec["dimensions_m"][2]) + mesh = copy_or_generate_mesh(source_path, dimensions=dimensions, preserve_aspect=spec["preserve_aspect"]) + divide = generate_divide_indices(mesh.vertices, mode="spoof") + validate_divide_pieces(divide, mesh.faces, "Generated spoof mesh divide") + write_mesh(mesh_path, mesh) + dump_divide_pickle(divide, divide_path) + cls._write_metadata(metadata_path, spec) + logger.info("Generated AdvCP runtime spoof assets at mesh='%s', divide='%s'.", mesh_path, divide_path) - cache_dir_value = config.get("asset_cache_dir") - if cache_dir_value is None: - return None + config["car_mesh_path"] = str(mesh_path) + config["car_mesh_divide_path"] = str(divide_path) + return mesh_path, divide_path - cache_dir = cls._resolve_absolute(cache_dir_value) - divide_path = cache_dir / "remove" / "mesh_divide.pkl" - perturb_path = cache_dir / "remove" / "mesh_perturb.npy" + @classmethod + def ensure_remove_advshape_assets( + cls, + config: MutableMapping[str, Any], + config_dir: Path | None = None, + ) -> tuple[Path, Path | None] | None: + """Ensure that removal adv-shape assets exist. + + Generates the removal divide (and, when + ``remove_adv_shape_generate_zero_perturb`` is set, a zero + perturbation) for the shared adv-shape template. Output goes to the + explicit ``remove_adv_shape_*_path`` keys when set, otherwise to + ``asset_cache_dir``. Without either nothing is generated: the + runtime falls back to the identical default partition. The config + is updated to point at generated files. - template = advshape_template_mesh() - if not divide_path.exists(): - divide = generate_divide_indices(template.vertices, mode="remove") - validate_divide_pieces(divide, template.faces, "Generated removal mesh divide") - dump_divide_pickle(divide, divide_path) + Parameters + ---------- + config : MutableMapping + AdvCP configuration mapping; updated in place. + config_dir : Path or None + AdvCP YAML directory used to resolve relative paths. - if bool(config.get("remove_adv_shape_generate_zero_perturb", False)) and not perturb_path.exists(): - zero_perturb = np.zeros(template.vertices.shape, dtype=np.float32) - save_perturbation(perturb_path, zero_perturb) + Returns + ------- + tuple or None + ``(divide_path, perturb_path)`` (``perturb_path`` is ``None`` + when no perturbation is used), or ``None`` when nothing needed + to be generated. + """ + generate_perturb = bool(config.get("remove_adv_shape_generate_zero_perturb", False)) + spec = { + "format_version": ASSET_FORMAT_VERSION, + "kind": "remove", + "template_dimensions_m": list(ADVSHAPE_TEMPLATE_DIMENSIONS_M), + "template_subdivision_levels": ADVSHAPE_TEMPLATE_SUBDIVISION_LEVELS, + "zero_perturb": generate_perturb, + } + divide_value = config.get("remove_adv_shape_divide_path") + perturb_value = config.get("remove_adv_shape_perturb_path") + cache_value = config.get("asset_cache_dir") + + if divide_value is not None: + divide_path = cls._resolve_path(divide_value, config_dir) + perturb_path = cls._resolve_path(perturb_value, config_dir) if perturb_value is not None else None + if generate_perturb and perturb_path is None: + perturb_path = divide_path.with_name("mesh_perturb.npy") + metadata_path = divide_path.with_name(divide_path.name + _SIDECAR_SUFFIX) + elif cache_value is not None: + asset_dir = cls._resolve_path(cache_value, config_dir) / "remove" / cls._spec_key(spec) + divide_path = asset_dir / "mesh_divide.pkl" + perturb_path = asset_dir / "mesh_perturb.npy" if generate_perturb else None + metadata_path = asset_dir / _CACHE_METADATA_NAME + else: + logger.debug("No removal asset path or cache configured; the runtime default adv-shape partition is used.") + return None - if "remove_adv_shape_divide_path" not in config: - config["remove_adv_shape_divide_path"] = str(divide_path) - if "remove_adv_shape_perturb_path" not in config and perturb_path.exists(): + outputs = [divide_path] + ([perturb_path] if generate_perturb and perturb_path is not None else []) + if cls._is_current(outputs, metadata_path, spec): + logger.info("Reusing AdvCP removal assets at divide='%s', perturb='%s'.", divide_path, perturb_path) + else: + generated_set = cls._read_metadata(metadata_path) is not None + # Files without metadata are user-provided: never overwrite them, only fill in what is missing. + user_owned = [path for path in outputs if path.exists() and not generated_set] + template = advshape_template_mesh() + if divide_path not in user_owned: + divide = generate_divide_indices(template.vertices, mode="remove") + validate_divide_pieces(divide, template.faces, "Generated removal mesh divide") + dump_divide_pickle(divide, divide_path) + if generate_perturb and perturb_path is not None and perturb_path not in user_owned: + save_perturbation(perturb_path, np.zeros(template.vertices.shape, dtype=np.float32)) + if not user_owned: + cls._write_metadata(metadata_path, spec) + logger.info("Prepared AdvCP runtime removal assets at divide='%s', perturb='%s'.", divide_path, perturb_path) + + config["remove_adv_shape_divide_path"] = str(divide_path) + if perturb_path is not None and perturb_path.exists(): config["remove_adv_shape_perturb_path"] = str(perturb_path) + return divide_path, perturb_path + + @classmethod + def prepare_assets( + cls, + config: MutableMapping[str, Any], + config_dir: Path | None, + spoof_paths_explicit: bool, + ) -> None: + """Generate or reuse all runtime assets requested by the config. - return perturb_path, divide_path + Does nothing unless ``asset_runtime_generation`` is true. Removal + assets are only prepared when ``advshape`` is enabled. + + Parameters + ---------- + config : MutableMapping + AdvCP configuration mapping; asset path keys are updated in + place. + config_dir : Path or None + AdvCP YAML directory used to resolve relative paths. + spoof_paths_explicit : bool + Whether the spoof asset paths were set by the user. + """ + if not bool(config.get("asset_runtime_generation", False)): + return + cls.ensure_spoof_assets(config, config_dir, spoof_paths_explicit) + if config.get("advshape") is True: + cls.ensure_remove_advshape_assets(config, config_dir) diff --git a/opencda/scenario_testing/config_yaml/advcp-configs/example.yaml b/opencda/scenario_testing/config_yaml/advcp-configs/example.yaml index d541775f..4f6b05c3 100644 --- a/opencda/scenario_testing/config_yaml/advcp-configs/example.yaml +++ b/opencda/scenario_testing/config_yaml/advcp-configs/example.yaml @@ -104,6 +104,19 @@ dense_distance: 10.0 # asset bundle in the sibling models repository. Explicit absolute or relative # paths can still override them here. +# Optional on-demand asset generation (instead of the bundle). The generated +# car mesh is a subdivided box with the blueprint's approximate dimensions, +# or an external mesh scaled to them. Set both car_mesh_path and +# car_mesh_divide_path to generate into fixed files; otherwise assets go to +# asset_cache_dir (reused across runs) or a per-run temporary directory. +# asset_runtime_generation: true +# vehicle_blueprint: vehicle.tesla.model3 +# car_mesh_dimensions: [4.3, 1.91, 1.26] # overrides vehicle_blueprint +# car_mesh_source_path: /path/to/vehicle.obj +# car_mesh_preserve_aspect: true +# asset_cache_dir: ~/.cache/cavise/advcp-assets +# remove_adv_shape_generate_zero_perturb: false + # --------------------------------------------------------------------------- # Intermediate-fusion spoofing parameters # ---------------------------------------------------------------------------