diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 000000000..bf8bdfb77 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,74 @@ +# AWML Deployment Framework + +AWML ships a unified, task-agnostic deployment stack that turns trained PyTorch checkpoints into production-ready ONNX and TensorRT artifacts. The verification and evaluation toolchain runs across every backend, ensuring numerical parity and consistent metrics across different projects. + +At the center is a shared runner/pipeline/exporter architecture that teams can extend with lightweight wrappers or workflows. CenterPoint, YOLOX, CalibrationStatusClassification, and future models plug into the same export and verification flow while still layering in task-specific logic where needed. + + +## Quick Start + +```bash +# Deployment entrypoint +python -m deployment.cli.main [project-specific args] + +# Example: CenterPoint deployment +python -m deployment.cli.main centerpoint --rot-y-axis-reference +``` + +## Documentation Map + +| Topic | Description | +| --- | --- | +| [`docs/overview.md`](docs/overview.md) | Design principles, key features, precision policies. | +| [`docs/architecture.md`](docs/architecture.md) | Workflow diagram, core components, file layout. | +| [`docs/usage.md`](docs/usage.md) | CLI usage, runner patterns, typed contexts, export modes. | +| [`docs/configuration.md`](docs/configuration.md) | Config structure, typed schemas, backend enums. | +| [`docs/projects.md`](docs/projects.md) | CenterPoint, YOLOX, and Calibration deployment specifics. | +| [`docs/export_pipeline.md`](docs/export_pipeline.md) | ONNX/TRT export steps and pipeline patterns. | +| [`docs/verification_evaluation.md`](docs/verification_evaluation.md) | Verification scenarios, evaluation metrics, core contract. | +| [`docs/best_practices.md`](docs/best_practices.md) | Best practices, troubleshooting, roadmap. | +| [`docs/contributing.md`](docs/contributing.md) | How to add new deployment projects end-to-end. | + +Refer to `deployment/docs/README.md` for the same index. + +## Architecture Snapshot + +- **Entry point** (`deployment/cli/main.py`) loads a project bundle from `deployment/projects//`. +- **Runtime** (`deployment/runtime/*`) coordinates load → export → verify → evaluate via shared orchestrators. +- **Exporters** live under `exporters/common/` with typed config classes; project wrappers/pipelines compose the base exporters as needed. +- **Pipelines** are registered by each project bundle and resolved via `PipelineFactory`. +- **Core package** (`core/`) supplies typed configs, runtime contexts, task definitions, and shared verification utilities. + +See [`docs/architecture.md`](docs/architecture.md) for diagrams and component details. + +## Export & Verification Flow + +1. Load the PyTorch checkpoint and run ONNX export (single or multi-file) using the injected wrappers/pipelines. +2. Optionally build TensorRT engines with precision policies such as `auto`, `fp16`, `fp32_tf32`, or `strongly_typed`. +3. Register artifacts via `ArtifactManager` for downstream verification and evaluation. +4. Run verification scenarios defined in config—pipelines are resolved by backend and device, and outputs are recursively compared with typed tolerances. +5. Execute evaluation across enabled backends and emit typed metrics. + +Implementation details live in [`docs/export_pipeline.md`](docs/export_pipeline.md) and [`docs/verification_evaluation.md`](docs/verification_evaluation.md). + +## Project Coverage + +- **CenterPoint** – multi-file export orchestrated by dedicated ONNX/TRT pipelines; see [`docs/projects.md`](docs/projects.md). +- **YOLOX** – single-file export with output reshaping via `YOLOXOptElanONNXWrapper`. +- **CalibrationStatusClassification** – binary classification deployment with identity wrappers and simplified pipelines. + +Each project ships its own deployment bundle under `deployment/projects//`. + +## Core Contract + +[`core_contract.md`](docs/core_contract.md) defines the boundaries between runners, orchestrators, evaluators, pipelines, and metrics interfaces. Follow the contract when introducing new logic to keep refactors safe and dependencies explicit. + +## Contributing & Best Practices + +- Start with [`docs/contributing.md`](docs/contributing.md) for the required files and patterns when adding a new deployment project. +- Consult [`docs/best_practices.md`](docs/best_practices.md) for export patterns, troubleshooting tips, and roadmap items. +- Keep documentation for project-specific quirks in the appropriate file under `deployment/docs/`. + +## License + +See LICENSE at the repository root. diff --git a/deployment/__init__.py b/deployment/__init__.py new file mode 100644 index 000000000..708e0b666 --- /dev/null +++ b/deployment/__init__.py @@ -0,0 +1,24 @@ +""" +Autoware ML Unified Deployment Framework + +This package provides a unified, task-agnostic deployment framework for +exporting, verifying, and evaluating machine learning models across different +tasks (classification, detection, segmentation, etc.) and backends (ONNX, +TensorRT). +""" + +from deployment.core.config.base_config import BaseDeploymentConfig +from deployment.core.evaluation.base_evaluator import BaseEvaluator +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.core.io.preprocessing_builder import build_preprocessing_pipeline +from deployment.runtime.runner import BaseDeploymentRunner + +__all__ = [ + "BaseDeploymentConfig", + "BaseDataLoader", + "BaseEvaluator", + "BaseDeploymentRunner", + "build_preprocessing_pipeline", +] + +__version__ = "1.0.0" diff --git a/deployment/cli/__init__.py b/deployment/cli/__init__.py new file mode 100644 index 000000000..4f413b78e --- /dev/null +++ b/deployment/cli/__init__.py @@ -0,0 +1 @@ +"""Deployment CLI package.""" diff --git a/deployment/cli/main.py b/deployment/cli/main.py new file mode 100644 index 000000000..3a1906148 --- /dev/null +++ b/deployment/cli/main.py @@ -0,0 +1,92 @@ +""" +Single deployment entrypoint. + +Usage: + python -m deployment.cli.main [project-specific args] +""" + +from __future__ import annotations + +import argparse +import importlib +import pkgutil +import sys +from typing import List + +import deployment.projects as projects_pkg +from deployment.core.config.base_config import parse_base_args +from deployment.projects import project_registry + + +def _discover_project_packages() -> List[str]: + """Discover project package names under deployment.projects (without importing them).""" + + names: List[str] = [] + for mod in pkgutil.iter_modules(projects_pkg.__path__): + if not mod.ispkg: + continue + if mod.name.startswith("_"): + continue + names.append(mod.name) + return sorted(names) + + +def _import_and_register_project(project_name: str) -> None: + """Import project package, which should register itself into project_registry.""" + importlib.import_module(f"deployment.projects.{project_name}") + + +def build_parser() -> argparse.ArgumentParser: + """Build the unified deployment CLI parser. + + This discovers `deployment.projects.` bundles, imports them to trigger + registration into `deployment.projects.project_registry`, then creates a + subcommand per registered project. + """ + parser = argparse.ArgumentParser( + description="AWML Deployment CLI", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + subparsers = parser.add_subparsers(dest="project", required=True) + + # Discover projects and import them so they can contribute args. + for project_name in _discover_project_packages(): + try: + _import_and_register_project(project_name) + except Exception: + # Skip broken/incomplete project bundles rather than breaking the whole CLI. + continue + + try: + adapter = project_registry.get(project_name) + except KeyError: + continue + + sub = subparsers.add_parser(project_name, help=f"{project_name} deployment") + parse_base_args(sub) # adds deploy_cfg, model_cfg, --log-level + adapter.add_args(sub) + sub.set_defaults(_adapter_name=project_name) + + return parser + + +def main(argv: List[str] | None = None) -> int: + """CLI entrypoint. + + Args: + argv: Optional argv list (without program name). If None, uses `sys.argv[1:]`. + + Returns: + Process exit code (0 for success). + """ + argv = sys.argv[1:] if argv is None else argv + parser = build_parser() + args = parser.parse_args(argv) + + adapter = project_registry.get(args._adapter_name) + return int(adapter.run(args) or 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py new file mode 100644 index 000000000..afe64e8b4 --- /dev/null +++ b/deployment/core/__init__.py @@ -0,0 +1,91 @@ +"""Core components for deployment framework.""" + +from deployment.core.artifacts import Artifact +from deployment.core.backend import Backend +from deployment.core.config.base_config import ( + BackendConfig, + BaseDeploymentConfig, + DeviceConfig, + EvaluationConfig, + ExportConfig, + ExportMode, + RuntimeConfig, + VerificationConfig, + VerificationScenario, + parse_base_args, + setup_logging, +) +from deployment.core.contexts import ( + CalibrationExportContext, + CenterPointExportContext, + ExportContext, + YOLOXExportContext, +) +from deployment.core.evaluation.base_evaluator import ( + EVALUATION_DEFAULTS, + BaseEvaluator, + EvalResultDict, + EvaluationDefaults, + ModelSpec, + TaskProfile, + VerifyResultDict, +) +from deployment.core.evaluation.verification_mixin import VerificationMixin +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.core.io.preprocessing_builder import build_preprocessing_pipeline +from deployment.core.metrics import ( + BaseMetricsConfig, + BaseMetricsInterface, + ClassificationMetricsConfig, + ClassificationMetricsInterface, + Detection2DMetricsConfig, + Detection2DMetricsInterface, + Detection3DMetricsConfig, + Detection3DMetricsInterface, +) + +__all__ = [ + # Backend and configuration + "Backend", + # Typed contexts + "ExportContext", + "YOLOXExportContext", + "CenterPointExportContext", + "CalibrationExportContext", + "BaseDeploymentConfig", + "ExportConfig", + "ExportMode", + "RuntimeConfig", + "BackendConfig", + "DeviceConfig", + "EvaluationConfig", + "VerificationConfig", + "VerificationScenario", + "setup_logging", + "parse_base_args", + # Constants + "EVALUATION_DEFAULTS", + "EvaluationDefaults", + # Data loading + "BaseDataLoader", + # Evaluation + "BaseEvaluator", + "TaskProfile", + "EvalResultDict", + "VerifyResultDict", + "VerificationMixin", + # Artifacts + "Artifact", + "ModelSpec", + # Preprocessing + "build_preprocessing_pipeline", + # Metrics interfaces (using autoware_perception_evaluation) + "BaseMetricsInterface", + "BaseMetricsConfig", + "Detection3DMetricsInterface", + "Detection3DMetricsConfig", + "Detection2DMetricsInterface", + "Detection2DMetricsConfig", + "ClassificationMetricsInterface", + "ClassificationMetricsConfig", +] diff --git a/deployment/core/artifacts.py b/deployment/core/artifacts.py new file mode 100644 index 000000000..985aa3bb1 --- /dev/null +++ b/deployment/core/artifacts.py @@ -0,0 +1,18 @@ +"""Artifact descriptors for deployment outputs.""" + +from __future__ import annotations + +import os.path as osp +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Artifact: + """Represents a produced deployment artifact such as ONNX or TensorRT outputs.""" + + path: str + multi_file: bool = False + + def exists(self) -> bool: + """Return True if the artifact path currently exists on disk.""" + return osp.exists(self.path) diff --git a/deployment/core/backend.py b/deployment/core/backend.py new file mode 100644 index 000000000..87cfc3109 --- /dev/null +++ b/deployment/core/backend.py @@ -0,0 +1,43 @@ +"""Backend enum used across deployment configs and runtime components.""" + +from __future__ import annotations + +from enum import Enum +from typing import Union + + +class Backend(str, Enum): + """Supported deployment backends.""" + + PYTORCH = "pytorch" + ONNX = "onnx" + TENSORRT = "tensorrt" + + @classmethod + def from_value(cls, value: Union[str, Backend]) -> Backend: + """ + Normalize backend identifiers coming from configs or enums. + + Args: + value: Backend as string or Backend enum + + Returns: + Backend enum instance + + Raises: + ValueError: If value cannot be mapped to a supported backend + """ + if isinstance(value, cls): + return value + + if isinstance(value, str): + normalized = value.strip().lower() + try: + return cls(normalized) + except ValueError as exc: + raise ValueError(f"Unsupported backend '{value}'. Expected one of {[b.value for b in cls]}.") from exc + + raise TypeError(f"Backend must be a string or Backend enum, got {type(value)}") + + def __str__(self) -> str: # pragma: no cover - convenience for logging + return self.value diff --git a/deployment/core/config/__init__.py b/deployment/core/config/__init__.py new file mode 100644 index 000000000..3197eb73d --- /dev/null +++ b/deployment/core/config/__init__.py @@ -0,0 +1,32 @@ +"""Configuration subpackage for deployment core.""" + +from deployment.core.config.base_config import ( + BackendConfig, + BaseDeploymentConfig, + EvaluationConfig, + ExportConfig, + ExportMode, + PrecisionPolicy, + RuntimeConfig, + VerificationConfig, + VerificationScenario, + parse_base_args, + setup_logging, +) +from deployment.core.evaluation.base_evaluator import EVALUATION_DEFAULTS, EvaluationDefaults + +__all__ = [ + "BackendConfig", + "BaseDeploymentConfig", + "EvaluationConfig", + "ExportConfig", + "ExportMode", + "PrecisionPolicy", + "VerificationConfig", + "VerificationScenario", + "parse_base_args", + "setup_logging", + "EVALUATION_DEFAULTS", + "EvaluationDefaults", + "RuntimeConfig", +] diff --git a/deployment/core/config/base_config.py b/deployment/core/config/base_config.py new file mode 100644 index 000000000..a9f00e573 --- /dev/null +++ b/deployment/core/config/base_config.py @@ -0,0 +1,563 @@ +""" +Base configuration classes for deployment framework. + +This module provides the foundation for task-agnostic deployment configuration. +Task-specific deployment configs should extend BaseDeploymentConfig. +""" + +from __future__ import annotations + +import argparse +import logging +from dataclasses import dataclass, field +from enum import Enum +from types import MappingProxyType +from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, Union + +import torch +from mmengine.config import Config + +from deployment.core.backend import Backend +from deployment.exporters.common.configs import ( + ONNXExportConfig, + TensorRTExportConfig, + TensorRTModelInputConfig, +) + +# Constants +DEFAULT_WORKSPACE_SIZE = 1 << 30 # 1 GB + + +def _empty_mapping() -> Mapping[Any, Any]: + """Return an immutable empty mapping.""" + return MappingProxyType({}) + + +class PrecisionPolicy(str, Enum): + """Precision policy options for TensorRT.""" + + AUTO = "auto" + FP16 = "fp16" + FP32_TF32 = "fp32_tf32" + STRONGLY_TYPED = "strongly_typed" + + +class ExportMode(str, Enum): + """Export pipeline modes.""" + + ONNX = "onnx" + TRT = "trt" + BOTH = "both" + NONE = "none" + + @classmethod + def from_value(cls, value: Optional[Union[str, ExportMode]]) -> ExportMode: + """Parse strings or enum members into ExportMode (defaults to BOTH).""" + if value is None: + return cls.BOTH + if isinstance(value, cls): + return value + if isinstance(value, str): + normalized = value.strip().lower() + for member in cls: + if member.value == normalized: + return member + raise ValueError(f"Invalid export mode '{value}'. Must be one of {[m.value for m in cls]}.") + + +# Precision policy mapping for TensorRT +PRECISION_POLICIES = { + PrecisionPolicy.AUTO.value: {}, # No special flags, TensorRT decides + PrecisionPolicy.FP16.value: {"FP16": True}, + PrecisionPolicy.FP32_TF32.value: {"TF32": True}, # TF32 for FP32 operations + PrecisionPolicy.STRONGLY_TYPED.value: {"STRONGLY_TYPED": True}, # Network creation flag +} + + +@dataclass(frozen=True) +class ExportConfig: + """Configuration for model export settings.""" + + mode: ExportMode = ExportMode.BOTH + work_dir: str = "work_dirs" + onnx_path: Optional[str] = None + + @classmethod + def from_dict(cls, config_dict: Mapping[str, Any]) -> ExportConfig: + """Create ExportConfig from dict.""" + return cls( + mode=ExportMode.from_value(config_dict.get("mode", ExportMode.BOTH)), + work_dir=config_dict.get("work_dir", cls.work_dir), + onnx_path=config_dict.get("onnx_path"), + ) + + def should_export_onnx(self) -> bool: + """Check if ONNX export is requested.""" + return self.mode in (ExportMode.ONNX, ExportMode.BOTH) + + def should_export_tensorrt(self) -> bool: + """Check if TensorRT export is requested.""" + return self.mode in (ExportMode.TRT, ExportMode.BOTH) + + +@dataclass(frozen=True) +class DeviceConfig: + """Normalized device settings shared across deployment stages.""" + + cpu: str = "cpu" + cuda: Optional[str] = "cuda:0" + + def __post_init__(self) -> None: + object.__setattr__(self, "cpu", self._normalize_cpu(self.cpu)) + object.__setattr__(self, "cuda", self._normalize_cuda(self.cuda)) + + @classmethod + def from_dict(cls, config_dict: Mapping[str, Any]) -> DeviceConfig: + """Create DeviceConfig from dict.""" + return cls(cpu=config_dict.get("cpu", cls.cpu), cuda=config_dict.get("cuda", cls.cuda)) + + @staticmethod + def _normalize_cpu(device: Optional[str]) -> str: + """Normalize CPU device string.""" + if not device: + return "cpu" + normalized = str(device).strip().lower() + if normalized.startswith("cuda"): + raise ValueError("CPU device cannot be a CUDA device") + return normalized + + @staticmethod + def _normalize_cuda(device: Optional[str]) -> Optional[str]: + """Normalize CUDA device string to 'cuda:N' format.""" + if device is None: + return None + if not isinstance(device, str): + raise ValueError("cuda device must be a string (e.g., 'cuda:0')") + normalized = device.strip().lower() + if normalized == "": + return None + if normalized == "cuda": + normalized = "cuda:0" + if not normalized.startswith("cuda"): + raise ValueError(f"Invalid CUDA device '{device}'. Must start with 'cuda'") + suffix = normalized.split(":", 1)[1] if ":" in normalized else "0" + suffix = suffix.strip() or "0" + if not suffix.isdigit(): + raise ValueError(f"Invalid CUDA device index in '{device}'") + device_id = int(suffix) + if device_id < 0: + raise ValueError("CUDA device index must be non-negative") + return f"cuda:{device_id}" + + def get_cuda_device_index(self) -> Optional[int]: + """Return CUDA device index as integer (if configured).""" + if self.cuda is None: + return None + return int(self.cuda.split(":", 1)[1]) + + +@dataclass(frozen=True) +class RuntimeConfig: + """Configuration for runtime I/O settings.""" + + info_file: str = "" + sample_idx: int = 0 + + @classmethod + def from_dict(cls, config_dict: Mapping[str, Any]) -> RuntimeConfig: + """Create RuntimeConfig from dictionary.""" + return cls( + info_file=config_dict.get("info_file", ""), + sample_idx=config_dict.get("sample_idx", 0), + ) + + +@dataclass(frozen=True) +class BackendConfig: + """Configuration for backend-specific settings.""" + + common_config: Mapping[str, Any] = field(default_factory=_empty_mapping) + model_inputs: Tuple[TensorRTModelInputConfig, ...] = field(default_factory=tuple) + + @classmethod + def from_dict(cls, config_dict: Mapping[str, Any]) -> BackendConfig: + common_config = dict(config_dict.get("common_config", {})) + model_inputs_raw: Iterable[Mapping[str, Any]] = config_dict.get("model_inputs", []) or [] + model_inputs: Tuple[TensorRTModelInputConfig, ...] = tuple( + TensorRTModelInputConfig.from_dict(item) for item in model_inputs_raw + ) + return cls( + common_config=MappingProxyType(common_config), + model_inputs=model_inputs, + ) + + def get_precision_policy(self) -> str: + """Get precision policy name.""" + return self.common_config.get("precision_policy", PrecisionPolicy.AUTO.value) + + def get_precision_flags(self) -> Mapping[str, bool]: + """Get TensorRT precision flags for the configured policy.""" + policy = self.get_precision_policy() + return PRECISION_POLICIES.get(policy, {}) + + def get_max_workspace_size(self) -> int: + """Get maximum workspace size for TensorRT.""" + return self.common_config.get("max_workspace_size", DEFAULT_WORKSPACE_SIZE) + + +@dataclass(frozen=True) +class EvaluationConfig: + """Typed configuration for evaluation settings.""" + + enabled: bool = False + num_samples: int = 10 + verbose: bool = False + backends: Mapping[Any, Mapping[str, Any]] = field(default_factory=_empty_mapping) + models: Mapping[Any, Any] = field(default_factory=_empty_mapping) + devices: Mapping[str, str] = field(default_factory=_empty_mapping) + + @classmethod + def from_dict(cls, config_dict: Mapping[str, Any]) -> EvaluationConfig: + backends_raw = config_dict.get("backends", {}) or {} + backends_frozen = {key: MappingProxyType(dict(value)) for key, value in backends_raw.items()} + + return cls( + enabled=config_dict.get("enabled", False), + num_samples=config_dict.get("num_samples", 10), + verbose=config_dict.get("verbose", False), + backends=MappingProxyType(backends_frozen), + models=MappingProxyType(dict(config_dict.get("models", {}))), + devices=MappingProxyType(dict(config_dict.get("devices", {}))), + ) + + +@dataclass(frozen=True) +class VerificationConfig: + """Typed configuration for verification settings.""" + + enabled: bool = True + num_verify_samples: int = 3 + tolerance: float = 0.1 + devices: Mapping[str, str] = field(default_factory=_empty_mapping) + scenarios: Mapping[ExportMode, Tuple[VerificationScenario, ...]] = field(default_factory=_empty_mapping) + + @classmethod + def from_dict(cls, config_dict: Mapping[str, Any]) -> VerificationConfig: + scenarios_raw = config_dict.get("scenarios", {}) or {} + scenario_map: Dict[ExportMode, Tuple[VerificationScenario, ...]] = {} + for mode_key, scenario_list in scenarios_raw.items(): + mode = ExportMode.from_value(mode_key) + scenario_entries = tuple(VerificationScenario.from_dict(entry) for entry in (scenario_list or [])) + scenario_map[mode] = scenario_entries + + return cls( + enabled=config_dict.get("enabled", True), + num_verify_samples=config_dict.get("num_verify_samples", 3), + tolerance=config_dict.get("tolerance", 0.1), + devices=MappingProxyType(dict(config_dict.get("devices", {}))), + scenarios=MappingProxyType(scenario_map), + ) + + def get_scenarios(self, mode: ExportMode) -> Tuple[VerificationScenario, ...]: + """Return scenarios for a specific export mode.""" + return self.scenarios.get(mode, ()) + + +@dataclass(frozen=True) +class VerificationScenario: + """Immutable verification scenario specification.""" + + ref_backend: Backend + ref_device: str + test_backend: Backend + test_device: str + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> VerificationScenario: + missing_keys = {"ref_backend", "ref_device", "test_backend", "test_device"} - data.keys() + if missing_keys: + raise ValueError(f"Verification scenario missing keys: {missing_keys}") + + return cls( + ref_backend=Backend.from_value(data["ref_backend"]), + ref_device=str(data["ref_device"]), + test_backend=Backend.from_value(data["test_backend"]), + test_device=str(data["test_device"]), + ) + + +class BaseDeploymentConfig: + """ + Base configuration container for deployment settings. + + This class provides a task-agnostic interface for deployment configuration. + Task-specific configs should extend this class and add task-specific settings. + + Attributes: + checkpoint_path: Single source of truth for the PyTorch checkpoint path. + Used by both export (for ONNX conversion) and evaluation + (for PyTorch backend). Defined at top-level of deploy config. + """ + + def __init__(self, deploy_cfg: Config): + """ + Initialize deployment configuration. + + Args: + deploy_cfg: MMEngine Config object containing deployment settings + """ + self.deploy_cfg = deploy_cfg + self._validate_config() + + self._checkpoint_path: Optional[str] = deploy_cfg.get("checkpoint_path") + self._device_config = DeviceConfig.from_dict(deploy_cfg.get("devices", {}) or {}) + + # Initialize config sections + self.export_config = ExportConfig.from_dict(deploy_cfg.get("export", {})) + self.runtime_config = RuntimeConfig.from_dict(deploy_cfg.get("runtime_io", {})) + self.backend_config = BackendConfig.from_dict(deploy_cfg.get("backend_config", {})) + self._evaluation_config = EvaluationConfig.from_dict(deploy_cfg.get("evaluation", {})) + self._verification_config = VerificationConfig.from_dict(deploy_cfg.get("verification", {})) + + self._validate_cuda_device() + + def _validate_config(self) -> None: + """Validate configuration structure and required fields.""" + # Validate required sections + if "export" not in self.deploy_cfg: + raise ValueError( + "Missing 'export' section in deploy config. " "Please update your config to include 'export' section." + ) + + # Validate export mode + try: + ExportMode.from_value(self.deploy_cfg.get("export", {}).get("mode", ExportMode.BOTH)) + except ValueError as exc: + raise ValueError(str(exc)) from exc + + # Validate precision policy if present + backend_cfg = self.deploy_cfg.get("backend_config", {}) + common_cfg = backend_cfg.get("common_config", {}) + precision_policy = common_cfg.get("precision_policy", PrecisionPolicy.AUTO.value) + if precision_policy not in PRECISION_POLICIES: + raise ValueError( + f"Invalid precision_policy '{precision_policy}'. " f"Must be one of {list(PRECISION_POLICIES.keys())}" + ) + + def _validate_cuda_device(self) -> None: + """Validate CUDA device availability once at config stage.""" + if not self._needs_cuda_device(): + return + + cuda_device = self.devices.cuda + device_idx = self.devices.get_cuda_device_index() + + if cuda_device is None or device_idx is None: + raise RuntimeError( + "CUDA device is required (TensorRT export/verification/evaluation enabled) but no CUDA device was" + " configured in deploy_cfg.devices." + ) + + if not torch.cuda.is_available(): + raise RuntimeError( + "CUDA device is required (TensorRT export/verification/evaluation enabled) " + "but torch.cuda.is_available() returned False." + ) + + device_count = torch.cuda.device_count() + if device_idx >= device_count: + raise ValueError( + f"Requested CUDA device '{cuda_device}' but only {device_count} CUDA device(s) are available." + ) + + def _needs_cuda_device(self) -> bool: + """Determine if current deployment config requires a CUDA device.""" + if self.export_config.should_export_tensorrt(): + return True + + evaluation_cfg = self.evaluation_config + backends_cfg = evaluation_cfg.backends + tensorrt_backend = backends_cfg.get(Backend.TENSORRT.value) or backends_cfg.get(Backend.TENSORRT, {}) + if tensorrt_backend and tensorrt_backend.get("enabled", False): + return True + + verification_cfg = self.verification_config + + for scenario_list in verification_cfg.scenarios.values(): + for scenario in scenario_list: + if Backend.TENSORRT in (scenario.ref_backend, scenario.test_backend): + return True + + return False + + @property + def checkpoint_path(self) -> Optional[str]: + """ + Get checkpoint path - single source of truth for PyTorch model. + + This path is used by: + - Export pipeline: to load the PyTorch model for ONNX conversion + - Evaluation: for PyTorch backend evaluation + - Verification: when PyTorch is used as reference or test backend + + Returns: + Path to the PyTorch checkpoint file, or None if not configured + """ + return self._checkpoint_path + + @property + def evaluation_config(self) -> EvaluationConfig: + """Get evaluation configuration.""" + return self._evaluation_config + + @property + def onnx_config(self) -> Mapping[str, Any]: + """Get ONNX configuration.""" + return self.deploy_cfg.get("onnx_config", {}) + + @property + def verification_config(self) -> VerificationConfig: + """Get verification configuration.""" + return self._verification_config + + @property + def devices(self) -> DeviceConfig: + """Get normalized device settings.""" + return self._device_config + + def get_evaluation_backends(self) -> Mapping[Any, Mapping[str, Any]]: + """ + Get evaluation backends configuration. + + Returns: + Dictionary mapping backend names to their configuration + """ + return self.evaluation_config.backends + + def get_verification_scenarios(self, export_mode: ExportMode) -> Tuple[VerificationScenario, ...]: + """ + Get verification scenarios for the given export mode. + + Args: + export_mode: Export mode (`ExportMode`) + + Returns: + Tuple of verification scenarios + """ + return self.verification_config.get_scenarios(export_mode) + + @property + def task_type(self) -> Optional[str]: + """Get task type for pipeline building.""" + return self.deploy_cfg.get("task_type") + + def get_onnx_settings(self) -> ONNXExportConfig: + """ + Get ONNX export settings. + + Returns: + ONNXExportConfig instance containing ONNX export parameters + """ + onnx_config = self.onnx_config + model_io = self.deploy_cfg.get("model_io", {}) + + # Get batch size and dynamic axes from model_io + batch_size = model_io.get("batch_size", None) + dynamic_axes = model_io.get("dynamic_axes", None) + + # If batch_size is set to a number, disable dynamic_axes + if batch_size is not None and isinstance(batch_size, int): + dynamic_axes = None + + # Handle multiple inputs and outputs + input_names = [model_io.get("input_name", "input")] + output_names = [model_io.get("output_name", "output")] + + # Add additional inputs if specified + additional_inputs = model_io.get("additional_inputs", []) + for additional_input in additional_inputs: + if isinstance(additional_input, dict): + input_names.append(additional_input.get("name", "input")) + + # Add additional outputs if specified + additional_outputs = model_io.get("additional_outputs", []) + for additional_output in additional_outputs: + if isinstance(additional_output, str): + output_names.append(additional_output) + + settings_dict = { + "opset_version": onnx_config.get("opset_version", 16), + "do_constant_folding": onnx_config.get("do_constant_folding", True), + "input_names": tuple(input_names), + "output_names": tuple(output_names), + "dynamic_axes": dynamic_axes, + "export_params": onnx_config.get("export_params", True), + "keep_initializers_as_inputs": onnx_config.get("keep_initializers_as_inputs", False), + "verbose": onnx_config.get("verbose", False), + "save_file": onnx_config.get("save_file", "model.onnx"), + "batch_size": batch_size, + } + + # Note: simplify is typically True by default, but can be overridden + if "simplify" in onnx_config: + settings_dict["simplify"] = onnx_config["simplify"] + + return ONNXExportConfig.from_mapping(settings_dict) + + def get_tensorrt_settings(self) -> TensorRTExportConfig: + """ + Get TensorRT export settings with precision policy support. + + Returns: + TensorRTExportConfig instance containing TensorRT export parameters + """ + settings_dict = { + "max_workspace_size": self.backend_config.get_max_workspace_size(), + "precision_policy": self.backend_config.get_precision_policy(), + "policy_flags": self.backend_config.get_precision_flags(), + "model_inputs": self.backend_config.model_inputs, + } + return TensorRTExportConfig.from_mapping(settings_dict) + + +def setup_logging(level: str = "INFO") -> logging.Logger: + """ + Setup logging configuration. + + Args: + level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + + Returns: + Configured logger instance + """ + logging.basicConfig(level=getattr(logging, level), format="%(levelname)s:%(name)s:%(message)s") + return logging.getLogger("deployment") + + +def parse_base_args(parser: Optional[argparse.ArgumentParser] = None) -> argparse.ArgumentParser: + """ + Create argument parser with common deployment arguments. + + Args: + parser: Optional existing ArgumentParser to add arguments to + + Returns: + ArgumentParser with deployment arguments + """ + if parser is None: + parser = argparse.ArgumentParser( + description="Deploy model to ONNX/TensorRT", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument("deploy_cfg", help="Deploy config path") + parser.add_argument("model_cfg", help="Model config path") + # Optional overrides + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Logging level", + ) + + return parser diff --git a/deployment/core/contexts.py b/deployment/core/contexts.py new file mode 100644 index 000000000..486caa1e5 --- /dev/null +++ b/deployment/core/contexts.py @@ -0,0 +1,92 @@ +""" +Typed context objects for deployment workflows. + +This module defines typed dataclasses that replace **kwargs with explicit, +type-checked parameters. This improves: +- Type safety: Catches mismatches at type-check time +- Discoverability: IDE autocomplete shows available parameters +- Refactoring safety: Renamed fields are caught by type checkers + +Design Principles: + 1. Base contexts define common parameters across all projects + 2. Project-specific contexts extend base with additional fields + 3. Optional fields have sensible defaults + 4. Contexts are immutable (frozen=True) for safety + +Usage: + # Create context for export + ctx = ExportContext(sample_idx=0) + + # Project-specific context + ctx = CenterPointExportContext(rot_y_axis_reference=True) + + # Pass to orchestrator + result = export_orchestrator.run(ctx) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Mapping, Optional + + +@dataclass(frozen=True) +class ExportContext: + """ + Base context for export operations. + + This context carries parameters needed during the export workflow, + including model loading and ONNX/TensorRT export settings. + + Attributes: + sample_idx: Index of sample to use for tracing/shape inference (default: 0) + extra: Dictionary for project-specific or debug-only options that don't + warrant a dedicated field. Use sparingly. + """ + + sample_idx: int = 0 + extra: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + + def get(self, key: str, default: Any = None) -> Any: + """Get a value from extra dict with a default.""" + return self.extra.get(key, default) + + +@dataclass(frozen=True) +class YOLOXExportContext(ExportContext): + """ + YOLOX-specific export context. + + Attributes: + model_cfg_path: Path to model configuration file. If None, attempts + to extract from model_cfg.filename. + """ + + model_cfg: Optional[str] = None + + +@dataclass(frozen=True) +class CenterPointExportContext(ExportContext): + """ + CenterPoint-specific export context. + + Attributes: + rot_y_axis_reference: Whether to use y-axis rotation reference for + ONNX-compatible output format. This affects + how rotation and dimensions are encoded. + """ + + rot_y_axis_reference: bool = False + + +@dataclass(frozen=True) +class CalibrationExportContext(ExportContext): + """ + Calibration model export context. + + Currently uses only base ExportContext fields. + Extend with calibration-specific parameters as needed. + """ + + pass diff --git a/deployment/core/evaluation/__init__.py b/deployment/core/evaluation/__init__.py new file mode 100644 index 000000000..1125ce5e0 --- /dev/null +++ b/deployment/core/evaluation/__init__.py @@ -0,0 +1,18 @@ +"""Evaluation subpackage for deployment core.""" + +from deployment.core.evaluation.base_evaluator import BaseEvaluator, TaskProfile +from deployment.core.evaluation.evaluator_types import ( + EvalResultDict, + ModelSpec, + VerifyResultDict, +) +from deployment.core.evaluation.verification_mixin import VerificationMixin + +__all__ = [ + "BaseEvaluator", + "TaskProfile", + "EvalResultDict", + "ModelSpec", + "VerifyResultDict", + "VerificationMixin", +] diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py new file mode 100644 index 000000000..23d251ae9 --- /dev/null +++ b/deployment/core/evaluation/base_evaluator.py @@ -0,0 +1,322 @@ +""" +Base evaluator for model evaluation in deployment. + +This module provides: +- Type definitions (EvalResultDict, VerifyResultDict, ModelSpec) +- BaseEvaluator: the single base class for all task evaluators +- TaskProfile: describes task-specific metadata + +All project evaluators should extend BaseEvaluator and implement +the required hooks for their specific task. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union + +import numpy as np +import torch + +from deployment.core.backend import Backend +from deployment.core.evaluation.evaluator_types import ( + EvalResultDict, + InferenceResult, + LatencyBreakdown, + LatencyStats, + ModelSpec, + VerifyResultDict, +) +from deployment.core.evaluation.verification_mixin import VerificationMixin +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.core.metrics import BaseMetricsInterface + +# Re-export types +__all__ = [ + "EvalResultDict", + "VerifyResultDict", + "ModelSpec", + "InferenceResult", + "LatencyStats", + "LatencyBreakdown", + "TaskProfile", + "BaseEvaluator", + "EvaluationDefaults", + "EVALUATION_DEFAULTS", +] + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EvaluationDefaults: + """Default values for evaluation settings.""" + + LOG_INTERVAL: int = 50 + GPU_CLEANUP_INTERVAL: int = 10 + + +EVALUATION_DEFAULTS = EvaluationDefaults() + + +@dataclass(frozen=True) +class TaskProfile: + """ + Profile describing task-specific evaluation behavior. + + Attributes: + task_name: Internal identifier for the task + class_names: Tuple of class names for the task + num_classes: Number of classes + display_name: Human-readable name for display (defaults to task_name) + """ + + task_name: str + class_names: Tuple[str, ...] + num_classes: int + display_name: str = "" + + def __post_init__(self): + if not self.display_name: + object.__setattr__(self, "display_name", self.task_name) + + +class BaseEvaluator(VerificationMixin, ABC): + """ + Base class for all task-specific evaluators. + + This class provides: + - A unified evaluation loop (iterate samples → infer → accumulate → compute metrics) + - Verification support via VerificationMixin + - Common utilities (latency stats, model device management) + + Subclasses implement task-specific hooks: + - _create_pipeline(): Create backend-specific pipeline + - _prepare_input(): Prepare model input from sample + - _parse_predictions(): Normalize pipeline output + - _parse_ground_truths(): Extract ground truth from sample + - _add_to_interface(): Feed a single frame to the metrics interface + - _build_results(): Construct final results dict from interface metrics + - print_results(): Format and display results + """ + + def __init__( + self, + metrics_interface: BaseMetricsInterface, + task_profile: TaskProfile, + model_cfg: Any, + ): + """ + Initialize evaluator. + + Args: + metrics_interface: Metrics interface for computing task-specific metrics + task_profile: Profile describing the task + model_cfg: Model configuration (MMEngine Config or similar) + """ + self.metrics_interface = metrics_interface + self.task_profile = task_profile + self.model_cfg = model_cfg + self.pytorch_model: Any = None + + @property + def class_names(self) -> Tuple[str, ...]: + """Get class names from task profile.""" + return self.task_profile.class_names + + def set_pytorch_model(self, pytorch_model: Any) -> None: + """Set PyTorch model (called by deployment runner).""" + self.pytorch_model = pytorch_model + + def _ensure_model_on_device(self, device: str) -> Any: + """Ensure PyTorch model is on the correct device.""" + if self.pytorch_model is None: + raise RuntimeError( + f"{self.__class__.__name__}.pytorch_model is None. " + "DeploymentRunner must set evaluator.pytorch_model before calling verify/evaluate." + ) + + current_device = next(self.pytorch_model.parameters()).device + target_device = torch.device(device) + + if current_device != target_device: + logger.info(f"Moving PyTorch model from {current_device} to {target_device}") + self.pytorch_model = self.pytorch_model.to(target_device) + + return self.pytorch_model + + # ================== Abstract Methods (Task-Specific) ================== + + @abstractmethod + def _create_pipeline(self, model_spec: ModelSpec, device: str) -> Any: + """Create a pipeline for the specified backend.""" + raise NotImplementedError + + @abstractmethod + def _prepare_input( + self, + sample: Mapping[str, Any], + data_loader: BaseDataLoader, + device: str, + ) -> Tuple[Any, Dict[str, Any]]: + """Prepare model input from a sample. Returns (input_data, inference_kwargs).""" + raise NotImplementedError + + @abstractmethod + def _parse_predictions(self, pipeline_output: Any) -> Any: + """Normalize pipeline output to standard format.""" + raise NotImplementedError + + @abstractmethod + def _parse_ground_truths(self, gt_data: Mapping[str, Any]) -> Any: + """Extract ground truth from sample data.""" + raise NotImplementedError + + @abstractmethod + def _add_to_interface(self, predictions: Any, ground_truths: Any) -> None: + """Add a single frame to the metrics interface.""" + raise NotImplementedError + + @abstractmethod + def _build_results( + self, + latencies: List[float], + latency_breakdowns: List[Dict[str, float]], + num_samples: int, + ) -> EvalResultDict: + """Build final results dict from interface metrics.""" + raise NotImplementedError + + @abstractmethod + def print_results(self, results: EvalResultDict) -> None: + """Pretty print evaluation results.""" + raise NotImplementedError + + # ================== VerificationMixin Implementation ================== + + def _create_pipeline_for_verification( + self, + model_spec: ModelSpec, + device: str, + log: logging.Logger, + ) -> Any: + """Create pipeline for verification.""" + self._ensure_model_on_device(device) + return self._create_pipeline(model_spec, device) + + def _get_verification_input( + self, + sample_idx: int, + data_loader: BaseDataLoader, + device: str, + ) -> Tuple[Any, Dict[str, Any]]: + """Get verification input.""" + sample = data_loader.load_sample(sample_idx) + return self._prepare_input(sample, data_loader, device) + + # ================== Core Evaluation Loop ================== + + def evaluate( + self, + model: ModelSpec, + data_loader: BaseDataLoader, + num_samples: int, + verbose: bool = False, + ) -> EvalResultDict: + """ + Run evaluation on the specified model. + + Args: + model: Model specification (backend/device/path) + data_loader: Data loader for samples + num_samples: Number of samples to evaluate + verbose: Whether to print progress + + Returns: + Evaluation results dictionary + """ + logger.info(f"\nEvaluating {model.backend.value} model: {model.path}") + logger.info(f"Number of samples: {num_samples}") + + self._ensure_model_on_device(model.device) + pipeline = self._create_pipeline(model, model.device) + self.metrics_interface.reset() + + latencies = [] + latency_breakdowns = [] + + actual_samples = min(num_samples, data_loader.get_num_samples()) + + for idx in range(actual_samples): + if verbose and idx % EVALUATION_DEFAULTS.LOG_INTERVAL == 0: + logger.info(f"Processing sample {idx + 1}/{actual_samples}") + + sample = data_loader.load_sample(idx) + input_data, infer_kwargs = self._prepare_input(sample, data_loader, model.device) + + gt_data = data_loader.get_ground_truth(idx) + ground_truths = self._parse_ground_truths(gt_data) + + infer_result = pipeline.infer(input_data, **infer_kwargs) + latencies.append(infer_result.latency_ms) + if infer_result.breakdown: + latency_breakdowns.append(infer_result.breakdown) + + predictions = self._parse_predictions(infer_result.output) + self._add_to_interface(predictions, ground_truths) + + if model.backend is Backend.TENSORRT and idx % EVALUATION_DEFAULTS.GPU_CLEANUP_INTERVAL == 0: + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + # Cleanup pipeline resources + try: + pipeline.cleanup() + except Exception as e: + logger.warning(f"Error during pipeline cleanup: {e}") + + return self._build_results(latencies, latency_breakdowns, actual_samples) + + # ================== Utilities ================== + + def compute_latency_stats(self, latencies: List[float]) -> LatencyStats: + """Compute latency statistics from a list of measurements.""" + if not latencies: + return LatencyStats.empty() + + arr = np.array(latencies) + return LatencyStats( + mean_ms=float(np.mean(arr)), + std_ms=float(np.std(arr)), + min_ms=float(np.min(arr)), + max_ms=float(np.max(arr)), + median_ms=float(np.median(arr)), + ) + + def _compute_latency_breakdown( + self, + latency_breakdowns: List[Dict[str, float]], + ) -> LatencyBreakdown: + """Compute statistics for each latency stage.""" + if not latency_breakdowns: + return LatencyBreakdown.empty() + + stage_order = list(dict.fromkeys(stage for breakdown in latency_breakdowns for stage in breakdown.keys())) + + return LatencyBreakdown( + stages={ + stage: self.compute_latency_stats([bd[stage] for bd in latency_breakdowns if stage in bd]) + for stage in stage_order + } + ) + + def format_latency_stats(self, stats: Union[Mapping[str, float], LatencyStats]) -> str: + """Format latency statistics as a readable string.""" + stats_dict = stats.to_dict() if isinstance(stats, LatencyStats) else stats + return ( + f"Latency: {stats_dict['mean_ms']:.2f} ± {stats_dict['std_ms']:.2f} ms " + f"(min: {stats_dict['min_ms']:.2f}, max: {stats_dict['max_ms']:.2f}, " + f"median: {stats_dict['median_ms']:.2f})" + ) diff --git a/deployment/core/evaluation/evaluator_types.py b/deployment/core/evaluation/evaluator_types.py new file mode 100644 index 000000000..de800656f --- /dev/null +++ b/deployment/core/evaluation/evaluator_types.py @@ -0,0 +1,141 @@ +""" +Type definitions for model evaluation in deployment. + +This module contains the shared type definitions used by evaluators, +runners, and orchestrators. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Dict, Optional, TypedDict + +from deployment.core.artifacts import Artifact +from deployment.core.backend import Backend + + +class EvalResultDict(TypedDict, total=False): + """ + Structured evaluation result used across deployments. + + Attributes: + primary_metric: Main scalar metric for quick ranking (e.g., accuracy, mAP). + metrics: Flat dictionary of additional scalar metrics. + per_class: Optional nested metrics keyed by class/label name. + latency: Latency statistics as returned by compute_latency_stats(). + metadata: Arbitrary metadata that downstream components might need. + """ + + primary_metric: float + metrics: Dict[str, float] + per_class: Dict[str, Any] + latency: Dict[str, float] + metadata: Dict[str, Any] + + +class VerifyResultDict(TypedDict, total=False): + """ + Structured verification outcome shared between runners and evaluators. + + Attributes: + summary: Aggregate pass/fail counts. + samples: Mapping of sample identifiers to boolean pass/fail states. + """ + + summary: Dict[str, int] + samples: Dict[str, bool] + error: str + + +@dataclass(frozen=True) +class LatencyStats: + """ + Immutable latency statistics for a batch of inferences. + + Provides a typed alternative to loose dictionaries and a convenient + ``to_dict`` helper for interoperability with existing call sites. + """ + + mean_ms: float + std_ms: float + min_ms: float + max_ms: float + median_ms: float + + @classmethod + def empty(cls) -> "LatencyStats": + """Return a zero-initialized stats object.""" + return cls(0.0, 0.0, 0.0, 0.0, 0.0) + + def to_dict(self) -> Dict[str, float]: + """Convert to a plain dictionary for serialization.""" + return asdict(self) + + +@dataclass(frozen=True) +class LatencyBreakdown: + """ + Stage-wise latency statistics keyed by stage name. + + Stored as a mapping of stage -> LatencyStats, with a ``to_dict`` helper + to preserve backward compatibility with existing dictionary consumers. + """ + + stages: Dict[str, LatencyStats] + + @classmethod + def empty(cls) -> "LatencyBreakdown": + """Return an empty breakdown.""" + return cls(stages={}) + + def to_dict(self) -> Dict[str, Dict[str, float]]: + """Convert to ``Dict[str, Dict[str, float]]`` for downstream use.""" + return {stage: stats.to_dict() for stage, stats in self.stages.items()} + + +@dataclass(frozen=True) +class InferenceResult: + """Standard inference return payload.""" + + output: Any + latency_ms: float + breakdown: Optional[Dict[str, float]] = None + + @classmethod + def empty(cls) -> "InferenceResult": + """Return an empty inference result.""" + return cls(output=None, latency_ms=0.0, breakdown={}) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a plain dictionary for logging/serialization.""" + return { + "output": self.output, + "latency_ms": self.latency_ms, + "breakdown": dict(self.breakdown or {}), + } + + +@dataclass(frozen=True) +class ModelSpec: + """ + Minimal description of a concrete model artifact to evaluate or verify. + + Attributes: + backend: Backend identifier such as 'pytorch', 'onnx', or 'tensorrt'. + device: Target device string (e.g., 'cpu', 'cuda:0'). + artifact: Filesystem representation of the produced model. + """ + + backend: Backend + device: str + artifact: Artifact + + @property + def path(self) -> str: + """Backward-compatible access to artifact path.""" + return self.artifact.path + + @property + def multi_file(self) -> bool: + """True if the artifact represents a multi-file bundle.""" + return self.artifact.multi_file diff --git a/deployment/core/evaluation/verification_mixin.py b/deployment/core/evaluation/verification_mixin.py new file mode 100644 index 000000000..9b44c2110 --- /dev/null +++ b/deployment/core/evaluation/verification_mixin.py @@ -0,0 +1,483 @@ +""" +Verification mixin providing shared verification logic for evaluators. + +This mixin extracts the common verification workflow that was duplicated +across CenterPointEvaluator, YOLOXOptElanEvaluator, and ClassificationEvaluator. +""" + +from __future__ import annotations + +import logging +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union + +import numpy as np +import torch + +from deployment.core.backend import Backend +from deployment.core.evaluation.evaluator_types import ModelSpec, VerifyResultDict +from deployment.core.io.base_data_loader import BaseDataLoader + + +@dataclass(frozen=True) +class ComparisonResult: + """Result of comparing two outputs (immutable).""" + + passed: bool + max_diff: float + mean_diff: float + num_elements: int = 0 + details: Tuple[Tuple[str, ComparisonResult], ...] = () + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + result = { + "passed": self.passed, + "max_diff": self.max_diff, + "mean_diff": self.mean_diff, + "num_elements": self.num_elements, + } + if self.details: + result["details"] = {k: v.to_dict() for k, v in self.details} + return result + + +class VerificationMixin: + """ + Mixin providing shared verification logic for all evaluators. + + Subclasses must implement: + - _create_pipeline_for_verification(): Create backend-specific pipeline + - _get_verification_input(): Extract inputs for verification + + Subclasses may optionally override: + - _get_output_names(): Provide meaningful names for list/tuple outputs + """ + + @abstractmethod + def _create_pipeline_for_verification( + self, + model_spec: ModelSpec, + device: str, + logger: logging.Logger, + ) -> Any: + """Create a pipeline for the specified backend.""" + raise NotImplementedError + + @abstractmethod + def _get_verification_input( + self, + sample_idx: int, + data_loader: BaseDataLoader, + device: str, + ) -> Tuple[Any, Dict[str, Any]]: + """Get input data for verification.""" + raise NotImplementedError + + def _get_output_names(self) -> Optional[List[str]]: + """ + Optional: Provide meaningful names for list/tuple outputs. + + Override this method to provide task-specific output names for better logging. + Returns None by default, which uses generic naming (output_0, output_1, ...). + """ + return None + + def _compare_outputs( + self, + reference: Any, + test: Any, + tolerance: float, + logger: logging.Logger, + path: str = "output", + ) -> ComparisonResult: + """ + Recursively compare outputs of any structure. + + Handles: + - Tensors (torch.Tensor, np.ndarray) + - Scalars (int, float) + - Dictionaries + - Lists/Tuples + - None values + + Args: + reference: Reference output + test: Test output + tolerance: Maximum allowed difference + logger: Logger instance + path: Current path in the structure (for logging) + + Returns: + ComparisonResult with comparison statistics + """ + # Handle None + if reference is None and test is None: + return ComparisonResult(passed=True, max_diff=0.0, mean_diff=0.0) + + if reference is None or test is None: + logger.error(f" {path}: One output is None while the other is not") + return ComparisonResult(passed=False, max_diff=float("inf"), mean_diff=float("inf")) + + # Handle dictionaries + if isinstance(reference, dict) and isinstance(test, dict): + return self._compare_dicts(reference, test, tolerance, logger, path) + + # Handle lists/tuples + if isinstance(reference, (list, tuple)) and isinstance(test, (list, tuple)): + return self._compare_sequences(reference, test, tolerance, logger, path) + + # Handle tensors and arrays + if self._is_array_like(reference) and self._is_array_like(test): + return self._compare_arrays(reference, test, tolerance, logger, path) + + # Handle scalars + if isinstance(reference, (int, float)) and isinstance(test, (int, float)): + diff = abs(float(reference) - float(test)) + passed = diff < tolerance + if not passed: + logger.warning(f" {path}: scalar diff={diff:.6f} > tolerance={tolerance:.6f}") + return ComparisonResult(passed=passed, max_diff=diff, mean_diff=diff, num_elements=1) + + # Type mismatch + logger.error(f" {path}: Type mismatch - {type(reference).__name__} vs {type(test).__name__}") + return ComparisonResult(passed=False, max_diff=float("inf"), mean_diff=float("inf")) + + def _compare_dicts( + self, + reference: Mapping[str, Any], + test: Mapping[str, Any], + tolerance: float, + logger: logging.Logger, + path: str, + ) -> ComparisonResult: + """Compare dictionary outputs.""" + ref_keys = set(reference.keys()) + test_keys = set(test.keys()) + + if ref_keys != test_keys: + missing = ref_keys - test_keys + extra = test_keys - ref_keys + if missing: + logger.error(f" {path}: Missing keys in test: {missing}") + if extra: + logger.warning(f" {path}: Extra keys in test: {extra}") + return ComparisonResult(passed=False, max_diff=float("inf"), mean_diff=float("inf")) + + max_diff = 0.0 + total_diff = 0.0 + total_elements = 0 + all_passed = True + details_list = [] + + for key in sorted(ref_keys): + child_path = f"{path}.{key}" + result = self._compare_outputs(reference[key], test[key], tolerance, logger, child_path) + details_list.append((key, result)) + + max_diff = max(max_diff, result.max_diff) + total_diff += result.mean_diff * result.num_elements + total_elements += result.num_elements + all_passed = all_passed and result.passed + + mean_diff = total_diff / total_elements if total_elements > 0 else 0.0 + return ComparisonResult( + passed=all_passed, + max_diff=max_diff, + mean_diff=mean_diff, + num_elements=total_elements, + details=tuple(details_list), + ) + + def _compare_sequences( + self, + reference: Union[List, Tuple], + test: Union[List, Tuple], + tolerance: float, + logger: logging.Logger, + path: str, + ) -> ComparisonResult: + """Compare list/tuple outputs.""" + if len(reference) != len(test): + logger.error(f" {path}: Length mismatch - {len(reference)} vs {len(test)}") + return ComparisonResult(passed=False, max_diff=float("inf"), mean_diff=float("inf")) + + # Get optional output names from subclass + output_names = self._get_output_names() + + max_diff = 0.0 + total_diff = 0.0 + total_elements = 0 + all_passed = True + details_list = [] + + for idx, (ref_item, test_item) in enumerate(zip(reference, test)): + # Use provided names or generic naming + if output_names and idx < len(output_names): + name = output_names[idx] + else: + name = f"output_{idx}" + + child_path = f"{path}[{name}]" + result = self._compare_outputs(ref_item, test_item, tolerance, logger, child_path) + details_list.append((name, result)) + + max_diff = max(max_diff, result.max_diff) + total_diff += result.mean_diff * result.num_elements + total_elements += result.num_elements + all_passed = all_passed and result.passed + + mean_diff = total_diff / total_elements if total_elements > 0 else 0.0 + return ComparisonResult( + passed=all_passed, + max_diff=max_diff, + mean_diff=mean_diff, + num_elements=total_elements, + details=tuple(details_list), + ) + + def _compare_arrays( + self, + reference: Any, + test: Any, + tolerance: float, + logger: logging.Logger, + path: str, + ) -> ComparisonResult: + """Compare array-like outputs (tensors, numpy arrays).""" + ref_np = self._to_numpy(reference) + test_np = self._to_numpy(test) + + if ref_np.shape != test_np.shape: + logger.error(f" {path}: Shape mismatch - {ref_np.shape} vs {test_np.shape}") + return ComparisonResult(passed=False, max_diff=float("inf"), mean_diff=float("inf")) + + diff = np.abs(ref_np - test_np) + max_diff = float(np.max(diff)) + mean_diff = float(np.mean(diff)) + num_elements = int(diff.size) + + passed = max_diff < tolerance + logger.info(f" {path}: shape={ref_np.shape}, max_diff={max_diff:.6f}, mean_diff={mean_diff:.6f}") + + return ComparisonResult( + passed=passed, + max_diff=max_diff, + mean_diff=mean_diff, + num_elements=num_elements, + ) + + @staticmethod + def _is_array_like(obj: Any) -> bool: + """Check if object is array-like (tensor or numpy array).""" + return isinstance(obj, (torch.Tensor, np.ndarray)) + + @staticmethod + def _to_numpy(tensor: Any) -> np.ndarray: + """Convert tensor to numpy array.""" + if isinstance(tensor, torch.Tensor): + return tensor.detach().cpu().numpy() + if isinstance(tensor, np.ndarray): + return tensor + return np.array(tensor) + + def _compare_backend_outputs( + self, + reference_output: Any, + test_output: Any, + tolerance: float, + backend_name: str, + logger: logging.Logger, + ) -> Tuple[bool, Dict[str, float]]: + """ + Compare outputs from reference and test backends. + + This is the main entry point for output comparison. + Uses recursive comparison to handle any output structure. + """ + result = self._compare_outputs(reference_output, test_output, tolerance, logger) + + logger.info(f"\n Overall Max difference: {result.max_diff:.6f}") + logger.info(f" Overall Mean difference: {result.mean_diff:.6f}") + + if result.passed: + logger.info(f" {backend_name} verification PASSED ✓") + else: + logger.warning( + f" {backend_name} verification FAILED ✗ " + f"(max diff: {result.max_diff:.6f} > tolerance: {tolerance:.6f})" + ) + + return result.passed, {"max_diff": result.max_diff, "mean_diff": result.mean_diff} + + def _normalize_verification_device( + self, + backend: Backend, + device: str, + logger: logging.Logger, + ) -> Optional[str]: + """Normalize device for verification based on backend requirements.""" + if backend is Backend.PYTORCH and device.startswith("cuda"): + logger.warning("PyTorch verification is forced to CPU; overriding device to 'cpu'") + return "cpu" + + if backend is Backend.TENSORRT: + if not device.startswith("cuda"): + return None + if device != "cuda:0": + logger.warning("TensorRT verification only supports 'cuda:0'. Overriding.") + return "cuda:0" + + return device + + def verify( + self, + reference: ModelSpec, + test: ModelSpec, + data_loader: BaseDataLoader, + num_samples: int = 1, + tolerance: float = 0.1, + verbose: bool = False, + ) -> VerifyResultDict: + """Verify exported models using policy-based verification.""" + logger = logging.getLogger(__name__) + + results: VerifyResultDict = { + "summary": {"passed": 0, "failed": 0, "total": 0}, + "samples": {}, + } + + ref_device = self._normalize_verification_device(reference.backend, reference.device, logger) + test_device = self._normalize_verification_device(test.backend, test.device, logger) + + if test_device is None: + results["error"] = f"{test.backend.value} requires CUDA" + return results + + self._log_verification_header(reference, test, ref_device, test_device, num_samples, tolerance, logger) + + logger.info(f"\nInitializing {reference.backend.value} reference pipeline...") + ref_pipeline = self._create_pipeline_for_verification(reference, ref_device, logger) + + logger.info(f"\nInitializing {test.backend.value} test pipeline...") + test_pipeline = self._create_pipeline_for_verification(test, test_device, logger) + + actual_samples = min(num_samples, data_loader.get_num_samples()) + for i in range(actual_samples): + logger.info(f"\n{'='*60}") + logger.info(f"Verifying sample {i}") + logger.info(f"{'='*60}") + + passed = self._verify_single_sample( + i, + ref_pipeline, + test_pipeline, + data_loader, + ref_device, + test_device, + reference.backend, + test.backend, + tolerance, + logger, + ) + results["samples"][f"sample_{i}"] = passed + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + # Cleanup pipeline resources - all pipelines now have cleanup() via base class + for pipeline in [ref_pipeline, test_pipeline]: + if pipeline is not None: + try: + pipeline.cleanup() + except Exception as e: + logger.warning(f"Error during pipeline cleanup in verification: {e}") + + sample_values = results["samples"].values() + passed_count = sum(1 for v in sample_values if v is True) + failed_count = sum(1 for v in sample_values if v is False) + + results["summary"] = {"passed": passed_count, "failed": failed_count, "total": len(results["samples"])} + self._log_verification_summary(results, logger) + + return results + + def _verify_single_sample( + self, + sample_idx: int, + ref_pipeline: Any, + test_pipeline: Any, + data_loader: BaseDataLoader, + ref_device: str, + test_device: str, + ref_backend: Backend, + test_backend: Backend, + tolerance: float, + logger: logging.Logger, + ) -> bool: + """Verify a single sample.""" + input_data, metadata = self._get_verification_input(sample_idx, data_loader, ref_device) + + ref_name = f"{ref_backend.value} ({ref_device})" + logger.info(f"\nRunning {ref_name} reference...") + ref_result = ref_pipeline.infer(input_data, metadata, return_raw_outputs=True) + logger.info(f" {ref_name} latency: {ref_result.latency_ms:.2f} ms") + + test_input = self._move_input_to_device(input_data, test_device) + test_name = f"{test_backend.value} ({test_device})" + logger.info(f"\nRunning {test_name} test...") + test_result = test_pipeline.infer(test_input, metadata, return_raw_outputs=True) + logger.info(f" {test_name} latency: {test_result.latency_ms:.2f} ms") + + passed, _ = self._compare_backend_outputs(ref_result.output, test_result.output, tolerance, test_name, logger) + return passed + + def _move_input_to_device(self, input_data: Any, device: str) -> Any: + """Move input data to specified device.""" + device_obj = torch.device(device) + + if isinstance(input_data, torch.Tensor): + return input_data.to(device_obj) if input_data.device != device_obj else input_data + if isinstance(input_data, dict): + return {k: self._move_input_to_device(v, device) for k, v in input_data.items()} + if isinstance(input_data, (list, tuple)): + return type(input_data)(self._move_input_to_device(item, device) for item in input_data) + return input_data + + def _log_verification_header( + self, + reference: ModelSpec, + test: ModelSpec, + ref_device: str, + test_device: str, + num_samples: int, + tolerance: float, + logger: logging.Logger, + ) -> None: + """Log verification header information.""" + logger.info("\n" + "=" * 60) + logger.info("Model Verification (Policy-Based)") + logger.info("=" * 60) + logger.info(f"Reference: {reference.backend.value} on {ref_device} - {reference.path}") + logger.info(f"Test: {test.backend.value} on {test_device} - {test.path}") + logger.info(f"Number of samples: {num_samples}") + logger.info(f"Tolerance: {tolerance}") + logger.info("=" * 60) + + def _log_verification_summary(self, results: VerifyResultDict, logger: logging.Logger) -> None: + """Log verification summary.""" + logger.info("\n" + "=" * 60) + logger.info("Verification Summary") + logger.info("=" * 60) + + for key, value in results["samples"].items(): + status = "PASSED" if value else "FAILED" + logger.info(f" {key}: {status}") + + summary = results["summary"] + logger.info("=" * 60) + logger.info( + f"Total: {summary['passed']}/{summary['total']} passed, {summary['failed']}/{summary['total']} failed" + ) + logger.info("=" * 60) diff --git a/deployment/core/io/__init__.py b/deployment/core/io/__init__.py new file mode 100644 index 000000000..fae8a5fc0 --- /dev/null +++ b/deployment/core/io/__init__.py @@ -0,0 +1,9 @@ +"""I/O utilities subpackage for deployment core.""" + +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.core.io.preprocessing_builder import build_preprocessing_pipeline + +__all__ = [ + "BaseDataLoader", + "build_preprocessing_pipeline", +] diff --git a/deployment/core/io/base_data_loader.py b/deployment/core/io/base_data_loader.py new file mode 100644 index 000000000..bdc94c066 --- /dev/null +++ b/deployment/core/io/base_data_loader.py @@ -0,0 +1,142 @@ +""" +Abstract base class for data loading in deployment. + +Each task (classification, detection, segmentation, etc.) must implement +a concrete DataLoader that extends this base class. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Mapping, TypedDict + +import torch + + +class SampleData(TypedDict, total=False): + """ + Typed representation of a data sample handled by data loaders. + + Attributes: + input: Raw input data such as images or point clouds. + ground_truth: Labels or annotations if available. + metadata: Additional information required for evaluation. + """ + + input: Any + ground_truth: Any + metadata: Dict[str, Any] + + +class BaseDataLoader(ABC): + """ + Abstract base class for task-specific data loaders. + + This class defines the interface that all task-specific data loaders + must implement. It handles loading raw data from disk and preprocessing + it into a format suitable for model inference. + """ + + def __init__(self, config: Mapping[str, Any]): + """ + Initialize data loader. + + Args: + config: Configuration dictionary containing task-specific settings + """ + self.config = config + + @abstractmethod + def load_sample(self, index: int) -> SampleData: + """ + Load a single sample from the dataset. + + Args: + index: Sample index to load + + Returns: + Dictionary containing raw sample data. Structure is task-specific, + but should typically include: + - Raw input data (image, point cloud, etc.) + - Ground truth labels/annotations (if available) + - Any metadata needed for evaluation + + Raises: + IndexError: If index is out of range + FileNotFoundError: If sample data files don't exist + """ + raise NotImplementedError + + @abstractmethod + def preprocess(self, sample: SampleData) -> torch.Tensor: + """ + Preprocess raw sample data into model input format. + + Args: + sample: Raw sample data returned by load_sample() + + Returns: + Preprocessed tensor ready for model inference. + Shape and format depend on the specific task. + + Raises: + ValueError: If sample format is invalid + """ + raise NotImplementedError + + @abstractmethod + def get_num_samples(self) -> int: + """ + Get total number of samples in the dataset. + + Returns: + Total number of samples available + """ + raise NotImplementedError + + @abstractmethod + def get_ground_truth(self, index: int) -> Mapping[str, Any]: + """ + Get ground truth annotations for a specific sample. + + Args: + index: Sample index whose annotations should be returned + + Returns: + Dictionary containing task-specific ground truth data. + Implementations should raise IndexError if the index is invalid. + """ + raise NotImplementedError + + def get_shape_sample(self, index: int = 0) -> Any: + """ + Return a representative sample used for export shape configuration. + + This method provides a consistent interface for exporters to obtain + shape information without needing to know the internal structure of + preprocessed inputs (e.g., whether it's a single tensor, tuple, or list). + + The default implementation: + 1. Loads a sample using load_sample() + 2. Preprocesses it using preprocess() + 3. If the result is a list/tuple, returns the first element + 4. Otherwise returns the preprocessed result as-is + + Subclasses can override this method to provide custom shape sample logic + if the default behavior is insufficient. + + Args: + index: Sample index to use (default: 0) + + Returns: + A representative sample for shape configuration. Typically a torch.Tensor, + but the exact type depends on the task-specific implementation. + """ + sample = self.load_sample(index) + preprocessed = self.preprocess(sample) + + # Handle nested structures: if it's a list/tuple, use first element for shape + if isinstance(preprocessed, (list, tuple)): + return preprocessed[0] if len(preprocessed) > 0 else preprocessed + + return preprocessed diff --git a/deployment/core/io/preprocessing_builder.py b/deployment/core/io/preprocessing_builder.py new file mode 100644 index 000000000..1472f1e3a --- /dev/null +++ b/deployment/core/io/preprocessing_builder.py @@ -0,0 +1,167 @@ +""" +Preprocessing pipeline builder for deployment data loaders. + +This module provides functions to extract and build preprocessing pipelines +from MMDet/MMDet3D/MMPretrain configs for use in deployment data loaders. + +This module is compatible with the BaseDeploymentPipeline. +""" + +from __future__ import annotations + +import logging +from typing import Any, List, Mapping, Optional + +from mmengine.config import Config +from mmengine.dataset import Compose +from mmengine.registry import init_default_scope + +logger = logging.getLogger(__name__) + +TransformConfig = Mapping[str, Any] + + +class ComposeBuilder: + """ + Unified builder for creating Compose objects with different MM frameworks. + + Uses MMEngine-based Compose with init_default_scope for all frameworks. + """ + + @staticmethod + def build( + pipeline_cfg: List[TransformConfig], + scope: str, + import_modules: List[str], + ) -> Any: + """ + Build Compose object using MMEngine with init_default_scope. + + Args: + pipeline_cfg: List of transform configurations + scope: Default scope name (e.g., 'mmdet', 'mmdet3d', 'mmpretrain') + import_modules: List of module paths to import for transform registration + + Returns: + Compose object + + Raises: + ImportError: If required packages are not available + """ + # Import transform modules to register transforms + for module_path in import_modules: + try: + __import__(module_path) + except ImportError as e: + raise ImportError( + f"Failed to import transform module '{module_path}' for scope '{scope}'. " + f"Please ensure the required package is installed. Error: {e}" + ) from e + + # Set default scope and build Compose + try: + init_default_scope(scope) + logger.info( + "Building pipeline with mmengine.dataset.Compose (default_scope='%s')", + scope, + ) + return Compose(pipeline_cfg) + except Exception as e: + raise RuntimeError( + f"Failed to build Compose pipeline for scope '{scope}'. " + f"Check your pipeline configuration and transforms. Error: {e}" + ) from e + + +TASK_PIPELINE_CONFIGS: Mapping[str, Mapping[str, Any]] = { + "detection2d": { + "scope": "mmdet", + "import_modules": ["mmdet.datasets.transforms"], + }, + "detection3d": { + "scope": "mmdet3d", + "import_modules": ["mmdet3d.datasets.transforms"], + }, + "classification": { + "scope": "mmpretrain", + "import_modules": ["mmpretrain.datasets.transforms"], + }, + "segmentation": { + "scope": "mmseg", + "import_modules": ["mmseg.datasets.transforms"], + }, +} + +# Valid task types +VALID_TASK_TYPES = list(TASK_PIPELINE_CONFIGS.keys()) + + +def build_preprocessing_pipeline( + model_cfg: Config, + task_type: str = "detection3d", +) -> Any: + """ + Build preprocessing pipeline from model config. + + This function extracts the test pipeline configuration from a model config + and builds a Compose pipeline that can be used for preprocessing in deployment data loaders. + + Args: + model_cfg: Model configuration containing test pipeline definition. + Supports config (``model_cfg.test_pipeline``) + task_type: Explicit task type ('detection2d', 'detection3d', 'classification', 'segmentation'). + Must be provided either via this argument or via + ``model_cfg.task_type`` / ``model_cfg.deploy.task_type``. + Recommended: specify in deploy_config.py as ``task_type = "detection3d"``. + Returns: + Pipeline compose object (e.g., mmdet.datasets.transforms.Compose) + + Raises: + ValueError: If no valid test pipeline found in config or invalid task_type + ImportError: If required transform packages are not available + + Examples: + >>> from mmengine.config import Config + >>> cfg = Config.fromfile('model_config.py') + >>> pipeline = build_preprocessing_pipeline(cfg, task_type='detection3d') + >>> # Use pipeline to preprocess data + >>> results = pipeline({'img_path': 'image.jpg'}) + """ + pipeline_cfg = _extract_pipeline_config(model_cfg) + if task_type not in VALID_TASK_TYPES: + raise ValueError( + f"Invalid task_type '{task_type}'. Must be one of {VALID_TASK_TYPES}. " + f"Please specify a supported task type in the deploy config or function argument." + ) + + logger.info("Building preprocessing pipeline with task_type: %s", task_type) + try: + task_cfg = TASK_PIPELINE_CONFIGS[task_type] + except KeyError: + raise ValueError(f"Unknown task_type '{task_type}'. " f"Must be one of {VALID_TASK_TYPES}") + return ComposeBuilder.build(pipeline_cfg=pipeline_cfg, **task_cfg) + + +def _extract_pipeline_config(model_cfg: Config) -> List[TransformConfig]: + """ + Extract pipeline configuration from model config. + + Args: + model_cfg: Model configuration + + Returns: + List of transform configurations + + Raises: + ValueError: If no valid pipeline found + """ + try: + pipeline_cfg = model_cfg["test_pipeline"] + except (KeyError, TypeError) as exc: + raise ValueError("No test pipeline found in config. Expected pipeline at: test_pipeline.") from exc + + if not pipeline_cfg: + raise ValueError("test_pipeline is defined but empty. Please provide a valid test pipeline.") + + logger.info("Found test pipeline at: test_pipeline") + return pipeline_cfg diff --git a/deployment/core/metrics/__init__.py b/deployment/core/metrics/__init__.py new file mode 100644 index 000000000..1acbbae10 --- /dev/null +++ b/deployment/core/metrics/__init__.py @@ -0,0 +1,79 @@ +""" +Unified Metrics Interfaces for AWML Deployment Framework. + +This module provides task-specific metric interfaces that use autoware_perception_evaluation +as the single source of truth for metric computation. This ensures consistency between +training evaluation (T4MetricV2) and deployment evaluation. + +Design Principles: + 1. 3D Detection → Detection3DMetricsInterface (mAP, mAPH using autoware_perception_eval) + 2. 2D Detection → Detection2DMetricsInterface (mAP using autoware_perception_eval, 2D mode) + 3. Classification → ClassificationMetricsInterface (accuracy, precision, recall, F1) + +Usage: + # For 3D detection (CenterPoint, etc.) + from deployment.core.metrics import Detection3DMetricsInterface, Detection3DMetricsConfig + + config = Detection3DMetricsConfig( + class_names=["car", "truck", "bus", "bicycle", "pedestrian"], + ) + interface = Detection3DMetricsInterface(config) + interface.add_frame(predictions, ground_truths) + metrics = interface.compute_metrics() + + # For 2D detection (YOLOX, etc.) + from deployment.core.metrics import Detection2DMetricsInterface, Detection2DMetricsConfig + + config = Detection2DMetricsConfig( + class_names=["car", "truck", "bus", ...], + ) + interface = Detection2DMetricsInterface(config) + interface.add_frame(predictions, ground_truths) + metrics = interface.compute_metrics() + + # For classification (Calibration, etc.) + from deployment.core.metrics import ClassificationMetricsInterface, ClassificationMetricsConfig + + config = ClassificationMetricsConfig( + class_names=["miscalibrated", "calibrated"], + ) + interface = ClassificationMetricsInterface(config) + interface.add_frame(prediction_label, ground_truth_label, probabilities) + metrics = interface.compute_metrics() +""" + +from deployment.core.metrics.base_metrics_interface import ( + BaseMetricsConfig, + BaseMetricsInterface, + ClassificationSummary, + DetectionSummary, +) +from deployment.core.metrics.classification_metrics import ( + ClassificationMetricsConfig, + ClassificationMetricsInterface, +) +from deployment.core.metrics.detection_2d_metrics import ( + Detection2DMetricsConfig, + Detection2DMetricsInterface, +) +from deployment.core.metrics.detection_3d_metrics import ( + Detection3DMetricsConfig, + Detection3DMetricsInterface, +) + +__all__ = [ + # Base classes + "BaseMetricsInterface", + "BaseMetricsConfig", + "ClassificationSummary", + "DetectionSummary", + # 3D Detection + "Detection3DMetricsInterface", + "Detection3DMetricsConfig", + # 2D Detection + "Detection2DMetricsInterface", + "Detection2DMetricsConfig", + # Classification + "ClassificationMetricsInterface", + "ClassificationMetricsConfig", +] diff --git a/deployment/core/metrics/base_metrics_interface.py b/deployment/core/metrics/base_metrics_interface.py new file mode 100644 index 000000000..37feb8be4 --- /dev/null +++ b/deployment/core/metrics/base_metrics_interface.py @@ -0,0 +1,162 @@ +""" +Base Metrics Interface for unified metric computation. + +This module provides the abstract base class that all task-specific metrics interfaces +must implement. It ensures a consistent contract across 3D detection, 2D detection, +and classification tasks. + +All metric interfaces use autoware_perception_evaluation as the underlying computation +engine to ensure consistency between training (T4MetricV2) and deployment evaluation. +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class BaseMetricsConfig: + """Base configuration for all metrics interfaces. + + Attributes: + class_names: List of class names for evaluation. + frame_id: Frame ID for evaluation (e.g., "base_link" for 3D, "camera" for 2D). + """ + + class_names: List[str] + frame_id: str = "base_link" + + +@dataclass(frozen=True) +class ClassificationSummary: + """Structured summary for classification metrics.""" + + accuracy: float = 0.0 + precision: float = 0.0 + recall: float = 0.0 + f1score: float = 0.0 + per_class_accuracy: Dict[str, float] = field(default_factory=dict) + confusion_matrix: List[List[int]] = field(default_factory=list) + num_samples: int = 0 + detailed_metrics: Dict[str, float] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a serializable dictionary.""" + return { + "accuracy": self.accuracy, + "precision": self.precision, + "recall": self.recall, + "f1score": self.f1score, + "per_class_accuracy": dict(self.per_class_accuracy), + "confusion_matrix": [list(row) for row in self.confusion_matrix], + "num_samples": self.num_samples, + "detailed_metrics": dict(self.detailed_metrics), + } + + +@dataclass(frozen=True) +class DetectionSummary: + """Structured summary for detection metrics (2D/3D).""" + + mAP: float = 0.0 + per_class_ap: Dict[str, float] = field(default_factory=dict) + num_frames: int = 0 + detailed_metrics: Dict[str, float] = field(default_factory=dict) + mAPH: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + data = { + "mAP": self.mAP, + "per_class_ap": dict(self.per_class_ap), + "num_frames": self.num_frames, + "detailed_metrics": dict(self.detailed_metrics), + } + if self.mAPH is not None: + data["mAPH"] = self.mAPH + return data + + +class BaseMetricsInterface(ABC): + """ + Abstract base class for all task-specific metrics interfaces. + + This class defines the common interface that all metric interfaces must implement. + Each interface wraps autoware_perception_evaluation to compute metrics consistent + with training evaluation (T4MetricV2). + + The workflow is: + 1. Create interface with task-specific config + 2. Call reset() to start a new evaluation session + 3. Call add_frame() for each sample + 4. Call compute_metrics() to get final metrics + 5. Optionally call get_summary() for a human-readable summary + + Example: + interface = SomeMetricsInterface(config) + interface.reset() + for pred, gt in data: + interface.add_frame(pred, gt) + metrics = interface.compute_metrics() + """ + + def __init__(self, config: BaseMetricsConfig): + """ + Initialize the metrics interface. + + Args: + config: Configuration for the metrics interface. + """ + self.config = config + self.class_names = config.class_names + self.frame_id = config.frame_id + self._frame_count = 0 + + @abstractmethod + def reset(self) -> None: + """ + Reset the interface for a new evaluation session. + + This method should clear all accumulated frame data and reinitialize + the underlying evaluator. + """ + pass + + @abstractmethod + def add_frame(self, *args, **kwargs) -> None: + """ + Add a frame of predictions and ground truths for evaluation. + + The specific arguments depend on the task type: + - 3D Detection: predictions: List[Dict], ground_truths: List[Dict] + - 2D Detection: predictions: List[Dict], ground_truths: List[Dict] + - Classification: prediction: int, ground_truth: int, probabilities: List[float] + """ + pass + + @abstractmethod + def compute_metrics(self) -> Dict[str, float]: + """ + Compute metrics from all added frames. + + Returns: + Dictionary of metric names to values. + """ + pass + + @abstractmethod + def get_summary(self) -> Any: + """ + Get a summary of the evaluation including primary metrics. + + Returns: + Dictionary with summary metrics and additional information. + """ + pass + + @property + def frame_count(self) -> int: + """Return the number of frames added so far.""" + return self._frame_count diff --git a/deployment/core/metrics/classification_metrics.py b/deployment/core/metrics/classification_metrics.py new file mode 100644 index 000000000..07852243c --- /dev/null +++ b/deployment/core/metrics/classification_metrics.py @@ -0,0 +1,379 @@ +""" +Classification Metrics Interface using autoware_perception_evaluation. + +This module provides an interface to compute classification metrics (accuracy, precision, +recall, F1) using autoware_perception_evaluation, ensuring consistent metrics between +training evaluation and deployment evaluation. + +Usage: + config = ClassificationMetricsConfig( + class_names=["miscalibrated", "calibrated"], + ) + interface = ClassificationMetricsInterface(config) + + for pred_label, gt_label in zip(predictions, ground_truths): + interface.add_frame(prediction=pred_label, ground_truth=gt_label) + + metrics = interface.compute_metrics() + # Returns: {"accuracy": 0.95, "precision": 0.94, "recall": 0.96, "f1score": 0.95, ...} +""" + +import logging +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import numpy as np +from perception_eval.common.dataset import FrameGroundTruth +from perception_eval.common.label import AutowareLabel, Label +from perception_eval.common.object2d import DynamicObject2D +from perception_eval.common.schema import FrameID +from perception_eval.config.perception_evaluation_config import PerceptionEvaluationConfig +from perception_eval.evaluation.metrics import MetricsScore +from perception_eval.evaluation.result.perception_frame_config import ( + CriticalObjectFilterConfig, + PerceptionPassFailConfig, +) +from perception_eval.manager import PerceptionEvaluationManager + +from deployment.core.metrics.base_metrics_interface import ( + BaseMetricsConfig, + BaseMetricsInterface, + ClassificationSummary, +) + +logger = logging.getLogger(__name__) + +# Valid 2D frame IDs for camera-based classification +VALID_2D_FRAME_IDS = [ + "cam_front", + "cam_front_right", + "cam_front_left", + "cam_front_lower", + "cam_back", + "cam_back_left", + "cam_back_right", + "cam_traffic_light_near", + "cam_traffic_light_far", + "cam_traffic_light", +] + + +@dataclass(frozen=True) +class ClassificationMetricsConfig(BaseMetricsConfig): + """Configuration for classification metrics. + + Attributes: + class_names: List of class names for evaluation. + frame_id: Camera frame ID for evaluation (default: "cam_front"). + evaluation_config_dict: Configuration dict for perception evaluation. + critical_object_filter_config: Config for filtering critical objects. + frame_pass_fail_config: Config for pass/fail criteria. + """ + + frame_id: str = "cam_front" + evaluation_config_dict: Optional[Dict[str, Any]] = None + critical_object_filter_config: Optional[Dict[str, Any]] = None + frame_pass_fail_config: Optional[Dict[str, Any]] = None + + def __post_init__(self): + if self.frame_id not in VALID_2D_FRAME_IDS: + raise ValueError( + f"Invalid frame_id '{self.frame_id}' for classification. " f"Valid options: {VALID_2D_FRAME_IDS}" + ) + + if self.evaluation_config_dict is None: + object.__setattr__( + self, + "evaluation_config_dict", + { + "evaluation_task": "classification2d", + "target_labels": self.class_names, + "center_distance_thresholds": None, + "center_distance_bev_thresholds": None, + "plane_distance_thresholds": None, + "iou_2d_thresholds": None, + "iou_3d_thresholds": None, + "label_prefix": "autoware", + }, + ) + + if self.critical_object_filter_config is None: + object.__setattr__( + self, + "critical_object_filter_config", + { + "target_labels": self.class_names, + "ignore_attributes": None, + }, + ) + + if self.frame_pass_fail_config is None: + object.__setattr__( + self, + "frame_pass_fail_config", + { + "target_labels": self.class_names, + "matching_threshold_list": [1.0] * len(self.class_names), + "confidence_threshold_list": None, + }, + ) + + +class ClassificationMetricsInterface(BaseMetricsInterface): + """Interface for computing classification metrics using autoware_perception_evaluation. + + Metrics computed: + - Accuracy: TP / (num_predictions + num_gt - TP) + - Precision: TP / (TP + FP) + - Recall: TP / num_gt + - F1 Score: 2 * precision * recall / (precision + recall) + - Per-class accuracy, precision, recall, F1 + """ + + def __init__( + self, + config: ClassificationMetricsConfig, + data_root: str = "data/t4dataset/", + result_root_directory: str = "/tmp/perception_eval_classification/", + ): + """Initialize the classification metrics interface. + + Args: + config: Configuration for classification metrics. + data_root: Root directory of the dataset. + result_root_directory: Directory for saving evaluation results. + """ + super().__init__(config) + self.config: ClassificationMetricsConfig = config + + self.perception_eval_config = PerceptionEvaluationConfig( + dataset_paths=data_root, + frame_id=config.frame_id, + result_root_directory=result_root_directory, + evaluation_config_dict=config.evaluation_config_dict, + load_raw_data=False, + ) + + self.critical_object_filter_config = CriticalObjectFilterConfig( + evaluator_config=self.perception_eval_config, + **config.critical_object_filter_config, + ) + + self.frame_pass_fail_config = PerceptionPassFailConfig( + evaluator_config=self.perception_eval_config, + **config.frame_pass_fail_config, + ) + + self.evaluator: Optional[PerceptionEvaluationManager] = None + + def reset(self) -> None: + """Reset the interface for a new evaluation session.""" + self.evaluator = PerceptionEvaluationManager( + evaluation_config=self.perception_eval_config, + load_ground_truth=False, + metric_output_dir=None, + ) + self._frame_count = 0 + + def _convert_index_to_label(self, label_index: int) -> Label: + """Convert a label index to a Label object.""" + if 0 <= label_index < len(self.class_names): + class_name = self.class_names[label_index] + else: + class_name = "unknown" + + autoware_label = AutowareLabel.__members__.get(class_name.upper(), AutowareLabel.UNKNOWN) + return Label(label=autoware_label, name=class_name) + + def _create_dynamic_object_2d( + self, + label_index: int, + unix_time: int, + score: float = 1.0, + uuid: Optional[str] = None, + ) -> DynamicObject2D: + """Create a DynamicObject2D for classification (roi=None for image-level).""" + return DynamicObject2D( + unix_time=unix_time, + frame_id=FrameID.from_value(self.frame_id), + semantic_score=score, + semantic_label=self._convert_index_to_label(label_index), + roi=None, + uuid=uuid, + ) + + def add_frame( + self, + prediction: int, + ground_truth: int, + probabilities: Optional[List[float]] = None, + frame_name: Optional[str] = None, + ) -> None: + """Add a single prediction and ground truth for evaluation. + + Args: + prediction: Predicted class index. + ground_truth: Ground truth class index. + probabilities: Optional probability scores for each class. + frame_name: Optional name for the frame. + """ + if self.evaluator is None: + self.reset() + + unix_time = int(time.time() * 1e6) + if frame_name is None: + frame_name = str(self._frame_count) + + # Get confidence score from probabilities if available + score = 1.0 + if probabilities is not None and len(probabilities) > prediction: + score = float(probabilities[prediction]) + + # Create prediction and ground truth objects + estimated_object = self._create_dynamic_object_2d( + label_index=prediction, unix_time=unix_time, score=score, uuid=frame_name + ) + gt_object = self._create_dynamic_object_2d( + label_index=ground_truth, unix_time=unix_time, score=1.0, uuid=frame_name + ) + + frame_ground_truth = FrameGroundTruth( + unix_time=unix_time, + frame_name=frame_name, + objects=[gt_object], + transforms=None, + raw_data=None, + ) + + try: + self.evaluator.add_frame_result( + unix_time=unix_time, + ground_truth_now_frame=frame_ground_truth, + estimated_objects=[estimated_object], + critical_object_filter_config=self.critical_object_filter_config, + frame_pass_fail_config=self.frame_pass_fail_config, + ) + self._frame_count += 1 + except Exception as e: + logger.warning(f"Failed to add frame {frame_name}: {e}") + + def compute_metrics(self) -> Dict[str, float]: + """Compute metrics from all added predictions. + + Returns: + Dictionary of metrics including accuracy, precision, recall, f1score, + and per-class metrics. + """ + if self.evaluator is None or self._frame_count == 0: + logger.warning("No samples to evaluate") + return {} + + try: + metrics_score: MetricsScore = self.evaluator.get_scene_result() + return self._process_metrics_score(metrics_score) + except Exception as e: + logger.error(f"Error computing metrics: {e}") + import traceback + + traceback.print_exc() + return {} + + def _process_metrics_score(self, metrics_score: MetricsScore) -> Dict[str, float]: + """Process MetricsScore into a flat dictionary.""" + metric_dict = {} + + for classification_score in metrics_score.classification_scores: + # Get overall metrics + accuracy, precision, recall, f1score = classification_score._summarize() + + # Handle inf values (replace with 0.0) + metric_dict["accuracy"] = 0.0 if accuracy == float("inf") else accuracy + metric_dict["precision"] = 0.0 if precision == float("inf") else precision + metric_dict["recall"] = 0.0 if recall == float("inf") else recall + metric_dict["f1score"] = 0.0 if f1score == float("inf") else f1score + + # Process per-class metrics + for acc in classification_score.accuracies: + if not acc.target_labels: + continue + + target_label = acc.target_labels[0] + class_name = getattr(target_label, "name", str(target_label)) + + metric_dict[f"{class_name}_accuracy"] = 0.0 if acc.accuracy == float("inf") else acc.accuracy + metric_dict[f"{class_name}_precision"] = 0.0 if acc.precision == float("inf") else acc.precision + metric_dict[f"{class_name}_recall"] = 0.0 if acc.recall == float("inf") else acc.recall + metric_dict[f"{class_name}_f1score"] = 0.0 if acc.f1score == float("inf") else acc.f1score + metric_dict[f"{class_name}_tp"] = acc.num_tp + metric_dict[f"{class_name}_fp"] = acc.num_fp + metric_dict[f"{class_name}_num_gt"] = acc.num_ground_truth + metric_dict[f"{class_name}_num_pred"] = acc.objects_results_num + + metric_dict["total_samples"] = self._frame_count + return metric_dict + + # TODO(vividf): Remove after autoware_perception_evaluation supports confusion matrix. + def get_confusion_matrix(self) -> np.ndarray: + """Get the confusion matrix. + + Returns: + 2D numpy array where cm[i][j] = count of ground truth i predicted as j. + """ + num_classes = len(self.class_names) + if self.evaluator is None or self._frame_count == 0: + return np.zeros((num_classes, num_classes), dtype=int) + + confusion_matrix = np.zeros((num_classes, num_classes), dtype=int) + + for frame_result in self.evaluator.frame_results: + if not frame_result.object_results: + continue + + for obj_result in frame_result.object_results: + if obj_result.ground_truth_object is None: + continue + + pred_name = obj_result.estimated_object.semantic_label.name + gt_name = obj_result.ground_truth_object.semantic_label.name + + # Find indices + pred_idx = next( + (i for i, n in enumerate(self.class_names) if n.lower() == pred_name.lower()), + -1, + ) + gt_idx = next( + (i for i, n in enumerate(self.class_names) if n.lower() == gt_name.lower()), + -1, + ) + + if 0 <= pred_idx < num_classes and 0 <= gt_idx < num_classes: + confusion_matrix[gt_idx, pred_idx] += 1 + + return confusion_matrix + + def get_summary(self) -> ClassificationSummary: + """Get a summary of the evaluation. + + Returns: + ClassificationSummary with aggregate metrics. + """ + metrics = self.compute_metrics() + + if not metrics: + return ClassificationSummary() + + per_class_accuracy = { + name: metrics[f"{name}_accuracy"] for name in self.class_names if f"{name}_accuracy" in metrics + } + + return ClassificationSummary( + accuracy=metrics.get("accuracy", 0.0), + precision=metrics.get("precision", 0.0), + recall=metrics.get("recall", 0.0), + f1score=metrics.get("f1score", 0.0), + per_class_accuracy=per_class_accuracy, + confusion_matrix=self.get_confusion_matrix().tolist(), + num_samples=self._frame_count, + detailed_metrics=metrics, + ) diff --git a/deployment/core/metrics/detection_2d_metrics.py b/deployment/core/metrics/detection_2d_metrics.py new file mode 100644 index 000000000..fb9e73e5c --- /dev/null +++ b/deployment/core/metrics/detection_2d_metrics.py @@ -0,0 +1,478 @@ +""" +2D Detection Metrics Interface using autoware_perception_evaluation. + +This module provides an interface to compute 2D detection metrics (mAP) +using autoware_perception_evaluation in 2D mode, ensuring consistent metrics +between training evaluation and deployment evaluation. + +For 2D detection, the interface uses: +- IoU 2D thresholds for matching (e.g., 0.5, 0.75) +- Only AP is computed (no APH since there's no heading in 2D) + +Usage: + config = Detection2DMetricsConfig( + class_names=["car", "truck", "bus", "bicycle", "pedestrian", "motorcycle", "trailer", "unknown"], + frame_id="camera", + ) + interface = Detection2DMetricsInterface(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + interface.add_frame( + predictions=pred, # List[Dict] with bbox (x1,y1,x2,y2), label, score + ground_truths=gt, # List[Dict] with bbox (x1,y1,x2,y2), label + ) + + # Compute metrics + metrics = interface.compute_metrics() + # Returns: {"mAP_iou_2d_0.5": 0.7, "mAP_iou_2d_0.75": 0.65, ...} +""" + +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import numpy as np +from perception_eval.common.dataset import FrameGroundTruth +from perception_eval.common.label import AutowareLabel, Label +from perception_eval.common.object2d import DynamicObject2D +from perception_eval.common.schema import FrameID +from perception_eval.config.perception_evaluation_config import PerceptionEvaluationConfig +from perception_eval.evaluation.metrics import MetricsScore +from perception_eval.evaluation.result.perception_frame_config import ( + CriticalObjectFilterConfig, + PerceptionPassFailConfig, +) +from perception_eval.manager import PerceptionEvaluationManager + +from deployment.core.metrics.base_metrics_interface import BaseMetricsConfig, BaseMetricsInterface, DetectionSummary + +logger = logging.getLogger(__name__) + + +# Valid 2D frame IDs for camera-based detection +VALID_2D_FRAME_IDS = [ + "cam_front", + "cam_front_right", + "cam_front_left", + "cam_front_lower", + "cam_back", + "cam_back_left", + "cam_back_right", + "cam_traffic_light_near", + "cam_traffic_light_far", + "cam_traffic_light", +] + + +@dataclass(frozen=True) +class Detection2DMetricsConfig(BaseMetricsConfig): + """Configuration for 2D detection metrics. + + Attributes: + class_names: List of class names for evaluation. + frame_id: Frame ID for evaluation. Valid values for 2D: + "cam_front", "cam_front_right", "cam_front_left", "cam_front_lower", + "cam_back", "cam_back_left", "cam_back_right", + "cam_traffic_light_near", "cam_traffic_light_far", "cam_traffic_light" + iou_thresholds: List of IoU thresholds for evaluation. + evaluation_config_dict: Configuration dict for perception evaluation. + critical_object_filter_config: Config for filtering critical objects. + frame_pass_fail_config: Config for pass/fail criteria. + """ + + # Override default frame_id for 2D detection (camera frame instead of base_link) + frame_id: str = "cam_front" + iou_thresholds: List[float] = field(default_factory=lambda: [0.5, 0.75]) + evaluation_config_dict: Optional[Dict[str, Any]] = None + critical_object_filter_config: Optional[Dict[str, Any]] = None + frame_pass_fail_config: Optional[Dict[str, Any]] = None + + def __post_init__(self): + # Validate frame_id for 2D detection + if self.frame_id not in VALID_2D_FRAME_IDS: + raise ValueError( + f"Invalid frame_id '{self.frame_id}' for 2D detection. Valid options: {VALID_2D_FRAME_IDS}" + ) + + # Set default evaluation config if not provided + if self.evaluation_config_dict is None: + default_eval_config = { + "evaluation_task": "detection2d", + "target_labels": self.class_names, + "iou_2d_thresholds": self.iou_thresholds, + "center_distance_bev_thresholds": None, + "plane_distance_thresholds": None, + "iou_3d_thresholds": None, + "label_prefix": "autoware", + } + object.__setattr__(self, "evaluation_config_dict", default_eval_config) + + # Set default critical object filter config if not provided + if self.critical_object_filter_config is None: + default_filter_config = { + "target_labels": self.class_names, + "ignore_attributes": None, + } + object.__setattr__(self, "critical_object_filter_config", default_filter_config) + + # Set default frame pass fail config if not provided + if self.frame_pass_fail_config is None: + num_classes = len(self.class_names) + default_pass_fail_config = { + "target_labels": self.class_names, + "matching_threshold_list": [0.5] * num_classes, + "confidence_threshold_list": None, + } + object.__setattr__(self, "frame_pass_fail_config", default_pass_fail_config) + + +class Detection2DMetricsInterface(BaseMetricsInterface): + """ + Interface for computing 2D detection metrics using autoware_perception_evaluation. + + This interface provides a simplified interface for the deployment framework to + compute mAP for 2D object detection tasks (YOLOX, etc.). + + Unlike 3D detection, 2D detection: + - Uses IoU 2D for matching (based on bounding box overlap) + - Does not compute APH (no heading information in 2D) + - Works with image-space bounding boxes [x1, y1, x2, y2] + + Example usage: + config = Detection2DMetricsConfig( + class_names=["car", "truck", "bus", "bicycle", "pedestrian"], + iou_thresholds=[0.5, 0.75], + ) + interface = Detection2DMetricsInterface(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + interface.add_frame( + predictions=pred, # List[Dict] with bbox, label, score + ground_truths=gt, # List[Dict] with bbox, label + ) + + # Compute metrics + metrics = interface.compute_metrics() + """ + + _UNKNOWN = "unknown" + + def __init__( + self, + config: Detection2DMetricsConfig, + data_root: str = "data/t4dataset/", + result_root_directory: str = "/tmp/perception_eval_2d/", + ): + """ + Initialize the 2D detection metrics interface. + + Args: + config: Configuration for 2D detection metrics. + data_root: Root directory of the dataset. + result_root_directory: Directory for saving evaluation results. + """ + super().__init__(config) + self.config: Detection2DMetricsConfig = config + self.data_root = data_root + self.result_root_directory = result_root_directory + + # Create perception evaluation config + self.perception_eval_config = PerceptionEvaluationConfig( + dataset_paths=data_root, + frame_id=config.frame_id, + result_root_directory=result_root_directory, + evaluation_config_dict=config.evaluation_config_dict, + load_raw_data=False, + ) + + # Create critical object filter config + self.critical_object_filter_config = CriticalObjectFilterConfig( + evaluator_config=self.perception_eval_config, + **config.critical_object_filter_config, + ) + + # Create frame pass fail config + self.frame_pass_fail_config = PerceptionPassFailConfig( + evaluator_config=self.perception_eval_config, + **config.frame_pass_fail_config, + ) + + # Initialize evaluation manager + self.evaluator: Optional[PerceptionEvaluationManager] = None + + def reset(self) -> None: + """Reset the interface for a new evaluation session.""" + self.evaluator = PerceptionEvaluationManager( + evaluation_config=self.perception_eval_config, + load_ground_truth=False, + metric_output_dir=None, + ) + self._frame_count = 0 + + def _convert_index_to_label(self, label_index: int) -> Label: + """Convert a label index to a Label object. + + Args: + label_index: Index of the label in class_names. + + Returns: + Label object with AutowareLabel. + """ + if 0 <= label_index < len(self.class_names): + class_name = self.class_names[label_index] + else: + class_name = self._UNKNOWN + + autoware_label = AutowareLabel.__members__.get(class_name.upper(), AutowareLabel.UNKNOWN) + return Label(label=autoware_label, name=class_name) + + def _predictions_to_dynamic_objects_2d( + self, + predictions: List[Dict[str, Any]], + unix_time: int, + ) -> List[DynamicObject2D]: + """Convert prediction dicts to DynamicObject2D instances. + + Args: + predictions: List of prediction dicts with keys: + - bbox: [x1, y1, x2, y2] (image coordinates) + - label: int (class index) + - score: float (confidence score) + unix_time: Unix timestamp in microseconds. + + Returns: + List of DynamicObject2D instances. + """ + estimated_objects = [] + frame_id = FrameID.from_value(self.frame_id) + + for pred in predictions: + bbox = pred.get("bbox", []) + if len(bbox) < 4: + continue + + # Extract bbox components [x1, y1, x2, y2] + x1, y1, x2, y2 = bbox[0], bbox[1], bbox[2], bbox[3] + + # Convert [x1, y1, x2, y2] to [xmin, ymin, width, height] format + # as required by DynamicObject2D.roi + xmin = int(x1) + ymin = int(y1) + width = int(x2 - x1) + height = int(y2 - y1) + + # Get label + label_idx = pred.get("label", 0) + semantic_label = self._convert_index_to_label(int(label_idx)) + + # Get score + score = float(pred.get("score", 0.0)) + + # Create DynamicObject2D + # roi format: (xmin, ymin, width, height) + dynamic_obj = DynamicObject2D( + unix_time=unix_time, + frame_id=frame_id, + semantic_score=score, + semantic_label=semantic_label, + roi=(xmin, ymin, width, height), + uuid=None, + ) + estimated_objects.append(dynamic_obj) + + return estimated_objects + + def _ground_truths_to_dynamic_objects_2d( + self, + ground_truths: List[Dict[str, Any]], + unix_time: int, + ) -> List[DynamicObject2D]: + """Convert ground truth dicts to DynamicObject2D instances. + + Args: + ground_truths: List of ground truth dicts with keys: + - bbox: [x1, y1, x2, y2] (image coordinates) + - label: int (class index) + unix_time: Unix timestamp in microseconds. + + Returns: + List of DynamicObject2D instances. + """ + gt_objects = [] + frame_id = FrameID.from_value(self.frame_id) + + for gt in ground_truths: + bbox = gt.get("bbox", []) + if len(bbox) < 4: + continue + + # Extract bbox components [x1, y1, x2, y2] + x1, y1, x2, y2 = bbox[0], bbox[1], bbox[2], bbox[3] + + # Convert [x1, y1, x2, y2] to [xmin, ymin, width, height] format + # as required by DynamicObject2D.roi + xmin = int(x1) + ymin = int(y1) + width = int(x2 - x1) + height = int(y2 - y1) + + # Get label + label_idx = gt.get("label", 0) + semantic_label = self._convert_index_to_label(int(label_idx)) + + # Create DynamicObject2D (GT always has score 1.0) + # roi format: (xmin, ymin, width, height) + dynamic_obj = DynamicObject2D( + unix_time=unix_time, + frame_id=frame_id, + semantic_score=1.0, + semantic_label=semantic_label, + roi=(xmin, ymin, width, height), + uuid=None, + ) + gt_objects.append(dynamic_obj) + + return gt_objects + + def add_frame( + self, + predictions: List[Dict[str, Any]], + ground_truths: List[Dict[str, Any]], + frame_name: Optional[str] = None, + ) -> None: + """Add a frame of predictions and ground truths for evaluation. + + Args: + predictions: List of prediction dicts with keys: + - bbox: [x1, y1, x2, y2] (image coordinates) + - label: int (class index) + - score: float (confidence score) + ground_truths: List of ground truth dicts with keys: + - bbox: [x1, y1, x2, y2] (image coordinates) + - label: int (class index) + frame_name: Optional name for the frame. + """ + if self.evaluator is None: + self.reset() + + # Unix time in microseconds (int) + unix_time = int(time.time() * 1e6) + if frame_name is None: + frame_name = str(self._frame_count) + + # Convert predictions to DynamicObject2D + estimated_objects = self._predictions_to_dynamic_objects_2d(predictions, unix_time) + + # Convert ground truths to DynamicObject2D list + gt_objects = self._ground_truths_to_dynamic_objects_2d(ground_truths, unix_time) + + # Create FrameGroundTruth for 2D + frame_ground_truth = FrameGroundTruth( + unix_time=unix_time, + frame_name=frame_name, + objects=gt_objects, + transforms=None, + raw_data=None, + ) + + # Add frame result to evaluator + try: + self.evaluator.add_frame_result( + unix_time=unix_time, + ground_truth_now_frame=frame_ground_truth, + estimated_objects=estimated_objects, + critical_object_filter_config=self.critical_object_filter_config, + frame_pass_fail_config=self.frame_pass_fail_config, + ) + self._frame_count += 1 + except Exception as e: + logger.warning(f"Failed to add frame {frame_name}: {e}") + + def compute_metrics(self) -> Dict[str, float]: + """Compute metrics from all added frames. + + Returns: + Dictionary of metrics with keys like: + - mAP_iou_2d_0.5 + - mAP_iou_2d_0.75 + - car_AP_iou_2d_0.5 + - etc. + """ + if self.evaluator is None or self._frame_count == 0: + logger.warning("No frames to evaluate") + return {} + + try: + # Get scene result (aggregated metrics) + metrics_score: MetricsScore = self.evaluator.get_scene_result() + + # Process metrics into a flat dictionary + return self._process_metrics_score(metrics_score) + + except Exception as e: + logger.error(f"Error computing metrics: {e}") + import traceback + + traceback.print_exc() + return {} + + def _process_metrics_score(self, metrics_score: MetricsScore) -> Dict[str, float]: + """Process MetricsScore into a flat dictionary. + + Args: + metrics_score: MetricsScore instance from evaluator. + + Returns: + Flat dictionary of metrics. + """ + metric_dict = {} + + for map_instance in metrics_score.mean_ap_values: + matching_mode = map_instance.matching_mode.value.lower().replace(" ", "_") + + # Process individual AP values + for label, aps in map_instance.label_to_aps.items(): + label_name = label.value + + for ap in aps: + threshold = ap.matching_threshold + ap_value = ap.ap + + # Create the metric key + key = f"{label_name}_AP_{matching_mode}_{threshold}" + metric_dict[key] = ap_value + + # Add mAP value (no mAPH for 2D detection) + map_key = f"mAP_{matching_mode}" + metric_dict[map_key] = map_instance.map + + return metric_dict + + def get_summary(self) -> DetectionSummary: + """Get a summary of the evaluation including mAP and per-class metrics.""" + metrics = self.compute_metrics() + + # Extract primary metrics (first mAP value found) + primary_map = None + per_class_ap = {} + + for key, value in metrics.items(): + if key.startswith("mAP_") and primary_map is None: + primary_map = value + elif "_AP_" in key and not key.startswith("mAP"): + # Extract class name from key + parts = key.split("_AP_") + if len(parts) == 2: + class_name = parts[0] + if class_name not in per_class_ap: + per_class_ap[class_name] = value + + return DetectionSummary( + mAP=primary_map or 0.0, + per_class_ap=per_class_ap, + num_frames=self._frame_count, + detailed_metrics=metrics, + ) diff --git a/deployment/core/metrics/detection_3d_metrics.py b/deployment/core/metrics/detection_3d_metrics.py new file mode 100644 index 000000000..235ab795b --- /dev/null +++ b/deployment/core/metrics/detection_3d_metrics.py @@ -0,0 +1,494 @@ +""" +3D Detection Metrics Interface using autoware_perception_evaluation. + +This module provides an interface to compute 3D detection metrics (mAP, mAPH) +using autoware_perception_evaluation, ensuring consistent metrics between +training evaluation (T4MetricV2) and deployment evaluation. + +Usage: + config = Detection3DMetricsConfig( + class_names=["car", "truck", "bus", "bicycle", "pedestrian"], + frame_id="base_link", + ) + interface = Detection3DMetricsInterface(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + interface.add_frame( + predictions=pred, # List[Dict] with bbox_3d, label, score + ground_truths=gt, # List[Dict] with bbox_3d, label + ) + + # Compute metrics + metrics = interface.compute_metrics() + # Returns: {"mAP_center_distance_bev_0.5": 0.7, ...} +""" + +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import numpy as np +from perception_eval.common.dataset import FrameGroundTruth +from perception_eval.common.label import AutowareLabel, Label +from perception_eval.common.object import DynamicObject +from perception_eval.common.shape import Shape, ShapeType +from perception_eval.config.perception_evaluation_config import PerceptionEvaluationConfig +from perception_eval.evaluation.metrics import MetricsScore +from perception_eval.evaluation.result.perception_frame_config import ( + CriticalObjectFilterConfig, + PerceptionPassFailConfig, +) +from perception_eval.manager import PerceptionEvaluationManager +from pyquaternion import Quaternion + +from deployment.core.metrics.base_metrics_interface import BaseMetricsConfig, BaseMetricsInterface, DetectionSummary + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Detection3DMetricsConfig(BaseMetricsConfig): + """Configuration for 3D detection metrics. + + Attributes: + class_names: List of class names for evaluation. + frame_id: Frame ID for evaluation (e.g., "base_link"). + evaluation_config_dict: Configuration dict for perception evaluation. + Example: + { + "evaluation_task": "detection", + "target_labels": ["car", "truck", "bus", "bicycle", "pedestrian"], + "center_distance_bev_thresholds": [0.5, 1.0, 2.0, 4.0], + "plane_distance_thresholds": [2.0, 4.0], + "iou_2d_thresholds": None, + "iou_3d_thresholds": None, + "label_prefix": "autoware", + "max_distance": 121.0, + "min_distance": -121.0, + "min_point_numbers": 0, + } + critical_object_filter_config: Config for filtering critical objects. + Example: + { + "target_labels": ["car", "truck", "bus", "bicycle", "pedestrian"], + "ignore_attributes": None, + "max_distance_list": [121.0, 121.0, 121.0, 121.0, 121.0], + "min_distance_list": [-121.0, -121.0, -121.0, -121.0, -121.0], + } + frame_pass_fail_config: Config for pass/fail criteria. + Example: + { + "target_labels": ["car", "truck", "bus", "bicycle", "pedestrian"], + "matching_threshold_list": [2.0, 2.0, 2.0, 2.0, 2.0], + "confidence_threshold_list": None, + } + """ + + evaluation_config_dict: Optional[Dict[str, Any]] = None + critical_object_filter_config: Optional[Dict[str, Any]] = None + frame_pass_fail_config: Optional[Dict[str, Any]] = None + + def __post_init__(self): + # Set default evaluation config if not provided + if self.evaluation_config_dict is None: + default_eval_config = { + "evaluation_task": "detection", + "target_labels": self.class_names, + "center_distance_bev_thresholds": [0.5, 1.0, 2.0, 4.0], + "plane_distance_thresholds": [2.0, 4.0], + "iou_2d_thresholds": None, + "iou_3d_thresholds": None, + "label_prefix": "autoware", + "max_distance": 121.0, + "min_distance": -121.0, + "min_point_numbers": 0, + } + object.__setattr__(self, "evaluation_config_dict", default_eval_config) + + # Set default critical object filter config if not provided + if self.critical_object_filter_config is None: + num_classes = len(self.class_names) + default_filter_config = { + "target_labels": self.class_names, + "ignore_attributes": None, + "max_distance_list": [121.0] * num_classes, + "min_distance_list": [-121.0] * num_classes, + } + object.__setattr__(self, "critical_object_filter_config", default_filter_config) + + # Set default frame pass fail config if not provided + if self.frame_pass_fail_config is None: + num_classes = len(self.class_names) + default_pass_fail_config = { + "target_labels": self.class_names, + "matching_threshold_list": [2.0] * num_classes, + "confidence_threshold_list": None, + } + object.__setattr__(self, "frame_pass_fail_config", default_pass_fail_config) + + +class Detection3DMetricsInterface(BaseMetricsInterface): + """ + Interface for computing 3D detection metrics using autoware_perception_evaluation. + + This interface provides a simplified interface for the deployment framework to + compute mAP, mAPH, and other detection metrics that are consistent with + the T4MetricV2 used during training. + + Example usage: + config = Detection3DMetricsConfig( + class_names=["car", "truck", "bus", "bicycle", "pedestrian"], + frame_id="base_link", + ) + interface = Detection3DMetricsInterface(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + interface.add_frame( + predictions=pred, # List[Dict] with bbox_3d, label, score + ground_truths=gt, # List[Dict] with bbox_3d, label + ) + + # Compute metrics + metrics = interface.compute_metrics() + # Returns: {"mAP_center_distance_bev_0.5": 0.7, ...} + """ + + _UNKNOWN = "unknown" + + def __init__( + self, + config: Detection3DMetricsConfig, + data_root: str = "data/t4dataset/", + result_root_directory: str = "/tmp/perception_eval/", + ): + """ + Initialize the 3D detection metrics interface. + + Args: + config: Configuration for 3D detection metrics. + data_root: Root directory of the dataset. + result_root_directory: Directory for saving evaluation results. + """ + super().__init__(config) + self.data_root = data_root + self.result_root_directory = result_root_directory + + # Create perception evaluation config + self.perception_eval_config = PerceptionEvaluationConfig( + dataset_paths=data_root, + frame_id=config.frame_id, + result_root_directory=result_root_directory, + evaluation_config_dict=config.evaluation_config_dict, + load_raw_data=False, + ) + + # Create critical object filter config + self.critical_object_filter_config = CriticalObjectFilterConfig( + evaluator_config=self.perception_eval_config, + **config.critical_object_filter_config, + ) + + # Create frame pass fail config + self.frame_pass_fail_config = PerceptionPassFailConfig( + evaluator_config=self.perception_eval_config, + **config.frame_pass_fail_config, + ) + + # Initialize evaluation manager (will be created on first use or reset) + self.evaluator: Optional[PerceptionEvaluationManager] = None + + def reset(self) -> None: + """Reset the interface for a new evaluation session.""" + self.evaluator = PerceptionEvaluationManager( + evaluation_config=self.perception_eval_config, + load_ground_truth=False, + metric_output_dir=None, + ) + self._frame_count = 0 + + def _convert_index_to_label(self, label_index: int) -> Label: + """Convert a label index to a Label object. + + Args: + label_index: Index of the label in class_names. + + Returns: + Label object with AutowareLabel. + """ + if 0 <= label_index < len(self.class_names): + class_name = self.class_names[label_index] + else: + class_name = self._UNKNOWN + + autoware_label = AutowareLabel.__members__.get(class_name.upper(), AutowareLabel.UNKNOWN) + return Label(label=autoware_label, name=class_name) + + def _predictions_to_dynamic_objects( + self, + predictions: List[Dict[str, Any]], + unix_time: float, + ) -> List[DynamicObject]: + """Convert prediction dicts to DynamicObject instances. + + Args: + predictions: List of prediction dicts with keys: + - bbox_3d: [x, y, z, l, w, h, yaw] or [x, y, z, l, w, h, yaw, vx, vy] + (Same format as mmdet3d LiDARInstance3DBoxes) + - label: int (class index) + - score: float (confidence score) + unix_time: Unix timestamp for the frame. + + Returns: + List of DynamicObject instances. + """ + estimated_objects = [] + for pred in predictions: + bbox = pred.get("bbox_3d", []) + if len(bbox) < 7: + continue + + # Extract bbox components + # mmdet3d LiDARInstance3DBoxes format: [x, y, z, l, w, h, yaw, vx, vy] + # where l=length, w=width, h=height + x, y, z = bbox[0], bbox[1], bbox[2] + l, w, h = bbox[3], bbox[4], bbox[5] + yaw = bbox[6] + + # Velocity (optional) + vx = bbox[7] if len(bbox) > 7 else 0.0 + vy = bbox[8] if len(bbox) > 8 else 0.0 + + # Create quaternion from yaw + orientation = Quaternion(np.cos(yaw / 2), 0, 0, np.sin(yaw / 2)) + + # Get label + label_idx = pred.get("label", 0) + semantic_label = self._convert_index_to_label(int(label_idx)) + + # Get score + score = float(pred.get("score", 0.0)) + + # Shape size follows autoware_perception_evaluation convention: (length, width, height) + dynamic_obj = DynamicObject( + unix_time=unix_time, + frame_id=self.frame_id, + position=(x, y, z), + orientation=orientation, + shape=Shape(shape_type=ShapeType.BOUNDING_BOX, size=(l, w, h)), + velocity=(vx, vy, 0.0), + semantic_score=score, + semantic_label=semantic_label, + ) + estimated_objects.append(dynamic_obj) + + return estimated_objects + + def _ground_truths_to_frame_ground_truth( + self, + ground_truths: List[Dict[str, Any]], + unix_time: float, + frame_name: str = "0", + ) -> FrameGroundTruth: + """Convert ground truth dicts to FrameGroundTruth instance. + + Args: + ground_truths: List of ground truth dicts with keys: + - bbox_3d: [x, y, z, l, w, h, yaw] or [x, y, z, l, w, h, yaw, vx, vy] + (Same format as mmdet3d LiDARInstance3DBoxes) + - label: int (class index) + - num_lidar_pts: int (optional, number of lidar points) + unix_time: Unix timestamp for the frame. + frame_name: Name/ID of the frame. + + Returns: + FrameGroundTruth instance. + """ + gt_objects = [] + for gt in ground_truths: + bbox = gt.get("bbox_3d", []) + if len(bbox) < 7: + continue + + # Extract bbox components + # mmdet3d LiDARInstance3DBoxes format: [x, y, z, l, w, h, yaw, vx, vy] + # where l=length, w=width, h=height + x, y, z = bbox[0], bbox[1], bbox[2] + l, w, h = bbox[3], bbox[4], bbox[5] + yaw = bbox[6] + + # Velocity (optional) + vx = bbox[7] if len(bbox) > 7 else 0.0 + vy = bbox[8] if len(bbox) > 8 else 0.0 + + # Create quaternion from yaw + orientation = Quaternion(np.cos(yaw / 2), 0, 0, np.sin(yaw / 2)) + + # Get label + label_idx = gt.get("label", 0) + semantic_label = self._convert_index_to_label(int(label_idx)) + + # Get point count (optional) + num_pts = gt.get("num_lidar_pts", 0) + + # Shape size follows autoware_perception_evaluation convention: (length, width, height) + dynamic_obj = DynamicObject( + unix_time=unix_time, + frame_id=self.frame_id, + position=(x, y, z), + orientation=orientation, + shape=Shape(shape_type=ShapeType.BOUNDING_BOX, size=(l, w, h)), + velocity=(vx, vy, 0.0), + semantic_score=1.0, # GT always has score 1.0 + semantic_label=semantic_label, + pointcloud_num=int(num_pts), + ) + gt_objects.append(dynamic_obj) + + return FrameGroundTruth( + unix_time=unix_time, + frame_name=frame_name, + objects=gt_objects, + transforms=None, + raw_data=None, + ) + + def add_frame( + self, + predictions: List[Dict[str, Any]], + ground_truths: List[Dict[str, Any]], + frame_name: Optional[str] = None, + ) -> None: + """Add a frame of predictions and ground truths for evaluation. + + Args: + predictions: List of prediction dicts with keys: + - bbox_3d: [x, y, z, l, w, h, yaw] or [x, y, z, l, w, h, yaw, vx, vy] + - label: int (class index) + - score: float (confidence score) + ground_truths: List of ground truth dicts with keys: + - bbox_3d: [x, y, z, l, w, h, yaw] or [x, y, z, l, w, h, yaw, vx, vy] + - label: int (class index) + - num_lidar_pts: int (optional) + frame_name: Optional name for the frame. + """ + if self.evaluator is None: + self.reset() + + unix_time = time.time() + if frame_name is None: + frame_name = str(self._frame_count) + + # Convert predictions to DynamicObject + estimated_objects = self._predictions_to_dynamic_objects(predictions, unix_time) + + # Convert ground truths to FrameGroundTruth + frame_ground_truth = self._ground_truths_to_frame_ground_truth(ground_truths, unix_time, frame_name) + + # Add frame result to evaluator + try: + self.evaluator.add_frame_result( + unix_time=unix_time, + ground_truth_now_frame=frame_ground_truth, + estimated_objects=estimated_objects, + critical_object_filter_config=self.critical_object_filter_config, + frame_pass_fail_config=self.frame_pass_fail_config, + ) + self._frame_count += 1 + except Exception as e: + logger.warning(f"Failed to add frame {frame_name}: {e}") + + def compute_metrics(self) -> Dict[str, float]: + """Compute metrics from all added frames. + + Returns: + Dictionary of metrics with keys like: + - mAP_center_distance_bev_0.5 + - mAP_center_distance_bev_1.0 + - mAPH_center_distance_bev_0.5 + - car_AP_center_distance_bev_0.5 + - etc. + """ + if self.evaluator is None or self._frame_count == 0: + logger.warning("No frames to evaluate") + return {} + + try: + # Get scene result (aggregated metrics) + metrics_score: MetricsScore = self.evaluator.get_scene_result() + + # Process metrics into a flat dictionary + return self._process_metrics_score(metrics_score) + + except Exception as e: + logger.error(f"Error computing metrics: {e}") + import traceback + + traceback.print_exc() + return {} + + def _process_metrics_score(self, metrics_score: MetricsScore) -> Dict[str, float]: + """Process MetricsScore into a flat dictionary. + + Args: + metrics_score: MetricsScore instance from evaluator. + + Returns: + Flat dictionary of metrics. + """ + metric_dict = {} + + for map_instance in metrics_score.mean_ap_values: + matching_mode = map_instance.matching_mode.value.lower().replace(" ", "_") + + # Process individual AP values + for label, aps in map_instance.label_to_aps.items(): + label_name = label.value + + for ap in aps: + threshold = ap.matching_threshold + ap_value = ap.ap + + # Create the metric key + key = f"{label_name}_AP_{matching_mode}_{threshold}" + metric_dict[key] = ap_value + + # Add mAP and mAPH values + map_key = f"mAP_{matching_mode}" + maph_key = f"mAPH_{matching_mode}" + metric_dict[map_key] = map_instance.map + metric_dict[maph_key] = map_instance.maph + + return metric_dict + + def get_summary(self) -> DetectionSummary: + """Get a summary of the evaluation including mAP and per-class metrics.""" + metrics = self.compute_metrics() + + # Extract primary metrics (first mAP value found) + primary_map = None + primary_maph = None + per_class_ap = {} + + for key, value in metrics.items(): + if key.startswith("mAP_") and primary_map is None: + primary_map = value + elif key.startswith("mAPH_") and primary_maph is None: + primary_maph = value + elif "_AP_" in key and not key.startswith("mAP"): + # Extract class name from key + parts = key.split("_AP_") + if len(parts) == 2: + class_name = parts[0] + if class_name not in per_class_ap: + per_class_ap[class_name] = value + + return DetectionSummary( + mAP=primary_map or 0.0, + mAPH=primary_maph or 0.0, + per_class_ap=per_class_ap, + num_frames=self._frame_count, + detailed_metrics=metrics, + ) diff --git a/deployment/docs/README.md b/deployment/docs/README.md new file mode 100644 index 000000000..262f195cf --- /dev/null +++ b/deployment/docs/README.md @@ -0,0 +1,13 @@ +# Deployment Docs Index + +Reference guides extracted from the monolithic deployment README: + +- [`overview.md`](./overview.md) – high-level summary, design principles, and key features. +- [`architecture.md`](./architecture.md) – workflow diagram, core components, pipelines, and layout. +- [`usage.md`](./usage.md) – commands, runner setup, typed contexts, CLI args, export modes. +- [`configuration.md`](./configuration.md) – configuration structure, typed config classes, backend enums. +- [`projects.md`](./projects.md) – CenterPoint, YOLOX, and Calibration deployment specifics. +- [`export_pipeline.md`](./export_pipeline.md) – ONNX/TensorRT export details plus export pipelines. +- [`verification_evaluation.md`](./verification_evaluation.md) – verification mixin, evaluation metrics, core contract. +- [`best_practices.md`](./best_practices.md) – best practices, troubleshooting, and roadmap items. +- [`contributing.md`](./contributing.md) – steps for adding new deployment projects. diff --git a/deployment/docs/architecture.md b/deployment/docs/architecture.md new file mode 100644 index 000000000..900975dd1 --- /dev/null +++ b/deployment/docs/architecture.md @@ -0,0 +1,77 @@ +# Deployment Architecture + +## High-Level Workflow + +``` +┌─────────────────────────────────────────────────────────┐ +│ Project Entry Points │ +│ (projects/*/deploy/main.py) │ +│ - CenterPoint, YOLOX-ELAN, Calibration │ +└──────────────────┬──────────────────────────────────────┘ + │ +┌──────────────────▼──────────────────────────────────────┐ +│ BaseDeploymentRunner + Project Runners │ +│ - Coordinates load → export → verify → evaluate │ +│ - Delegates to helper orchestrators │ +│ - Projects extend the base runner for custom logic │ +└──────────────────┬──────────────────────────────────────┘ + │ + ┌──────────┴────────────┐ + │ │ +┌───────▼────────┐ ┌────────▼───────────────┐ +│ Exporters │ │ Helper Orchestrators │ +│ - ONNX / TRT │ │ - ArtifactManager │ +│ - Wrappers │ │ - VerificationOrch. │ +│ - Export Ppl. │ │ - EvaluationOrch. │ +└────────────────┘ └────────┬───────────────┘ + │ +┌───────────────────────────────▼─────────────────────────┐ +│ Evaluators & Pipelines │ +│ - BaseDeploymentPipeline + task-specific variants │ +│ - Backend-specific implementations (PyTorch/ONNX/TRT) │ +└────────────────────────────────────────────────────────┘ +``` + +## Core Components + +### BaseDeploymentRunner & Project Runners + +`BaseDeploymentRunner` orchestrates the export/verification/evaluation loop. Project runners (CenterPoint, YOLOX, Calibration, …): + +- Implement model loading. +- Inject wrapper classes and optional export pipelines. +- Reuse `ExporterFactory` to lazily create ONNX/TensorRT exporters. +- Delegate artifact registration plus verification/evaluation to the shared orchestrators. + +### Core Package (`deployment/core/`) + +- `BaseDeploymentConfig` – typed deployment configuration container. +- `Backend` – enum guaranteeing backend name consistency. +- `Artifact` – dataclass describing exported artifacts. +- `VerificationMixin` – recursive comparer for nested outputs. +- `BaseEvaluator` – task-specific evaluation contract. +- `BaseDataLoader` – data-loading abstraction. +- `build_preprocessing_pipeline` – extracts preprocessing steps from MMDet/MMDet3D configs. +- Typed value objects (`constants.py`, `runtime_config.py`, `task_config.py`, `results.py`) keep configuration and metrics structured. + +### Exporters & Export Pipelines + +- `exporters/common/` hosts the base exporters, typed config objects, and `ExporterFactory`. +- Project wrappers live in `exporters/{project}/model_wrappers.py`. +- Complex projects add export pipelines (e.g., `CenterPointONNXExportPipeline`) that orchestrate multi-file exports by composing the base exporters. + +### Pipelines + +`BaseDeploymentPipeline` defines `preprocess → run_model → postprocess`, while `PipelineFactory` builds backend-specific implementations for each task (`Detection2D`, `Detection3D`, `Classification`). Pipelines are encapsulated per backend (PyTorch/ONNX/TensorRT) under `deployment/pipelines/{task}/`. + +### File Structure Snapshot + +``` +deployment/ +├── core/ # Core dataclasses, configs, evaluators +├── exporters/ # Base exporters + project wrappers/export pipelines +├── pipelines/ # Task-specific pipelines per backend +├── runners/ # Shared runner + project adapters +``` + +Project entry points follow the same pattern under `projects/*/deploy/` with `main.py`, `data_loader.py`, `evaluator.py`, and `configs/deploy_config.py`. diff --git a/deployment/docs/best_practices.md b/deployment/docs/best_practices.md new file mode 100644 index 000000000..a7ddeec23 --- /dev/null +++ b/deployment/docs/best_practices.md @@ -0,0 +1,84 @@ +# Best Practices & Troubleshooting + +## Configuration Management + +- Keep deployment configs separate from training/model configs. +- Use relative paths for datasets and artifacts when possible. +- Document non-default configuration options in project READMEs. + +## Model Export + +- Inject wrapper classes (and optional export pipelines) into project runners; let `ExporterFactory` build exporters lazily. +- Store wrappers under `exporters/{model}/model_wrappers.py` and reuse `IdentityWrapper` when reshaping is unnecessary. +- Add export-pipeline modules only when orchestration beyond single file export is required. +- Always verify ONNX exports before TensorRT conversion. +- Choose TensorRT precision policies (`auto`, `fp16`, `fp32_tf32`, `strongly_typed`) based on deployment targets. + +## Unified Architecture Pattern + +``` +exporters/{model}/ +├── model_wrappers.py +├── [optional] onnx_export_pipeline.py +└── [optional] tensorrt_export_pipeline.py +``` + +- Simple models: use base exporters + wrappers, no subclassing. +- Complex models: compose export pipelines that call the base exporters multiple times. + +## Dependency Injection Pattern + +```python +runner = YOLOXOptElanDeploymentRunner( + ..., + onnx_wrapper_cls=YOLOXOptElanONNXWrapper, +) +``` + +- Keeps dependencies explicit. +- Enables lazy exporter construction. +- Simplifies testing via mock wrappers/pipelines. + +## Verification Tips + +- Start with strict tolerances (0.01) and relax only when necessary. +- Verify a representative sample set. +- Ensure preprocessing/postprocessing is consistent across backends. + +## Evaluation Tips + +- Align evaluation settings across backends. +- Report latency statistics alongside accuracy metrics. +- Compare backend-specific outputs for regressions. + +## Pipeline Development + +- Inherit from the correct task-specific base pipeline. +- Share preprocessing/postprocessing logic where possible. +- Keep backend-specific implementations focused on inference glue code. + +## Troubleshooting + +1. **ONNX export fails** + - Check for unsupported ops and validate input shapes. + - Try alternative opset versions. +2. **TensorRT build fails** + - Validate the ONNX model. + - Confirm input shape/profile configuration. + - Adjust workspace size if memory errors occur. +3. **Verification fails** + - Tweak tolerance settings. + - Confirm identical preprocessing across backends. + - Verify device assignments. +4. **Evaluation errors** + - Double-check data loader paths. + - Ensure model outputs match evaluator expectations. + - Confirm the correct `task_type` in config. + +## Future Enhancements + +- Support more task types (segmentation, etc.). +- Automatic precision tuning for TensorRT. +- Distributed evaluation support. +- MLOps pipeline integration. +- Performance profiling tools. diff --git a/deployment/docs/configuration.md b/deployment/docs/configuration.md new file mode 100644 index 000000000..06813a335 --- /dev/null +++ b/deployment/docs/configuration.md @@ -0,0 +1,141 @@ +# Configuration Reference + +Configurations remain dictionary-driven for flexibility, with typed dataclasses layered on top for validation and IDE support. + +## Structure + +```python +# Task type +task_type = "detection3d" # or "detection2d", "classification" + +# Checkpoint (single source of truth) +checkpoint_path = "model.pth" + +devices = dict( + cpu="cpu", + cuda="cuda:0", +) + +export = dict( + mode="both", # "onnx", "trt", "both", "none" + work_dir="work_dirs/deployment", + onnx_path=None, # Required when mode="trt" and ONNX already exists +) + +runtime_io = dict( + info_file="data/info.pkl", + sample_idx=0, +) + +model_io = dict( + input_name="input", + input_shape=(3, 960, 960), + input_dtype="float32", + output_name="output", + batch_size=1, + dynamic_axes={...}, +) + +onnx_config = dict( + opset_version=16, + do_constant_folding=True, + save_file="model.onnx", + multi_file=False, +) + +backend_config = dict( + common_config=dict( + precision_policy="auto", + max_workspace_size=1 << 30, + ), +) + +verification = dict( + enabled=True, + num_verify_samples=3, + tolerance=0.1, + devices=devices, + scenarios={ + "both": [ + {"ref_backend": "pytorch", "ref_device": "cpu", + "test_backend": "onnx", "test_device": "cuda"}, + ] + } +) + +evaluation = dict( + enabled=True, + num_samples=100, + verbose=False, + backends={ + "pytorch": {"enabled": True, "device": devices["cpu"]}, + "onnx": {"enabled": True, "device": devices["cpu"]}, + "tensorrt": {"enabled": True, "device": devices["cuda"]}, + } +) +``` + +### Device Aliases + +Keep device definitions centralized by declaring a top-level `devices` dictionary and referencing aliases (for example, `devices["cuda"]`). Updating the mapping once automatically propagates to export, evaluation, and verification blocks without digging into nested dictionaries. + +## Backend Enum + +Use `deployment.core.Backend` to avoid typos while keeping backward compatibility with plain strings. + +```python +from deployment.core import Backend + +evaluation = dict( + backends={ + Backend.PYTORCH: {"enabled": True, "device": devices["cpu"]}, + Backend.ONNX: {"enabled": True, "device": devices["cpu"]}, + Backend.TENSORRT: {"enabled": True, "device": devices["cuda"]}, + } +) +``` + +## Typed Exporter Configs + +Typed classes in `deployment.exporters.common.configs` provide schema validation and IDE hints. + +```python +from deployment.exporters.common.configs import ( + ONNXExportConfig, + TensorRTExportConfig, + TensorRTModelInputConfig, + TensorRTProfileConfig, +) + +onnx_config = ONNXExportConfig( + input_names=("input",), + output_names=("output",), + opset_version=16, + do_constant_folding=True, + simplify=True, + save_file="model.onnx", + batch_size=1, +) + +trt_config = TensorRTExportConfig( + precision_policy="auto", + max_workspace_size=1 << 30, + model_inputs=( + TensorRTModelInputConfig( + input_shapes={ + "input": TensorRTProfileConfig( + min_shape=(1, 3, 960, 960), + opt_shape=(1, 3, 960, 960), + max_shape=(1, 3, 960, 960), + ) + } + ), + ), +) +``` + +Use `from_mapping()` / `from_dict()` helpers to instantiate typed configs from existing dictionaries. + +## Example Config Paths + +- `deployment/projects/centerpoint/config/deploy_config.py` diff --git a/deployment/docs/contributing.md b/deployment/docs/contributing.md new file mode 100644 index 000000000..a2d1b5c6a --- /dev/null +++ b/deployment/docs/contributing.md @@ -0,0 +1,29 @@ +# Contributing to Deployment + +## Adding a New Project + +1. **Evaluator & Data Loader** + - Implement `BaseEvaluator` with task-specific metrics. + - Implement `BaseDataLoader` variant for the dataset(s). + +2. **Project Bundle** + - Create a new bundle under `deployment/projects//`. + - Put **all project deployment code** in one place: `runner.py`, `evaluator.py`, `data_loader.py`, `config/deploy_config.py`. + +3. **Pipelines** + - Add backend-specific pipelines under `deployment/projects//pipelines/` and register a factory into `deployment.pipelines.registry.pipeline_registry`. + +4. **Export Pipelines (optional)** + - If the project needs multi-stage export, implement under `deployment/projects//export/` (compose the generic exporters in `deployment/exporters/common/`). + +5. **CLI wiring** + - Register a `ProjectAdapter` in `deployment/projects//__init__.py`. + - The unified entry point is `python -m deployment.cli.main ...` + +6. **Documentation** + - Update `deployment/README.md` and the relevant docs in `deployment/docs/`. + - Document special requirements, configuration flags, or export pipelines. + +## Core Contract + +Before touching shared components, review `deployment/docs/core_contract.md` to understand allowed dependencies between runners, evaluators, pipelines, and exporters. Adhering to the contract keeps refactors safe and ensures new logic lands in the correct layer. diff --git a/deployment/docs/core_contract.md b/deployment/docs/core_contract.md new file mode 100644 index 000000000..dd5e9cdd8 --- /dev/null +++ b/deployment/docs/core_contract.md @@ -0,0 +1,57 @@ +## Deployment Core Contract + +This document defines the responsibilities and boundaries between the primary deployment components. Treat it as the “architecture contract” for contributors. + +### BaseDeploymentRunner (and project runners) +- Owns the end-to-end deployment flow: load PyTorch model → export ONNX/TensorRT → verify → evaluate. +- Constructs exporters via `ExporterFactory` and never embeds exporter-specific logic. +- Injects project-provided `BaseDataLoader`, `BaseEvaluator`, model configs, wrappers, and optional export pipelines. +- Ensures evaluators receive: + - Loaded PyTorch model (`set_pytorch_model`) + - Runtime/export artifacts (via `ArtifactManager`) + - Verification/evaluation requests (via orchestrators) +- Must not contain task-specific preprocessing/postprocessing; defer to evaluators/pipelines. + +### BaseEvaluator (and task evaluators) +- The single base class for all task evaluators, integrating `VerificationMixin`. +- Provides the unified evaluation loop: iterate samples → infer → accumulate → compute metrics. +- Requires a `TaskProfile` (task name, class names) and a `BaseMetricsInterface` at construction. +- Responsible for: + - Creating backend pipelines through `PipelineFactory` + - Preparing verification inputs from the data loader + - Computing task metrics using metrics interfaces + - Printing/reporting evaluation summaries +- Subclasses implement task-specific hooks: + - `_create_pipeline(model_spec, device)` → create backend pipeline + - `_prepare_input(sample, data_loader, device)` → extract model input + inference kwargs + - `_parse_predictions(pipeline_output)` → normalize raw output + - `_parse_ground_truths(gt_data)` → extract ground truth + - `_add_to_interface(predictions, ground_truths)` → feed metrics interface + - `_build_results(latencies, breakdowns, num_samples)` → construct final results dict + - `print_results(results)` → format and display results +- Inherits `VerificationMixin` automatically; subclasses only need `_get_output_names()` if custom names are desired. +- Provides common utilities: `_ensure_model_on_device()`, `_compute_latency_breakdown()`, `compute_latency_stats()`. + +### BaseDeploymentPipeline & PipelineFactory +- `BaseDeploymentPipeline` defines the inference template (`preprocess → run_model → postprocess`). +- Backend-specific subclasses handle only the inference mechanics for their backend. +- `PipelineFactory` is the single entrypoint for creating pipelines per task/backend: + - Hides backend instantiation details from evaluators. + - Ensures consistent constructor signatures (PyTorch models vs. ONNX paths vs. TensorRT engines). + - Central location for future pipeline wiring (new tasks/backends). +- Pipelines must avoid loading artifacts or computing metrics; they only execute inference. + +### Metrics Interfaces (Autoware-based interfaces) +- Provide a uniform interface for adding frames and computing summaries regardless of task. +- Encapsulate conversion from model predictions/ground truth to Autoware perception evaluation inputs. +- Return metric dictionaries that evaluators incorporate into `EvalResultDict` results. +- Should not access loaders, runners, or exporters directly; evaluators pass in the data they need. + +### Summary of Allowed Dependencies +- **Runner → Evaluator** (injection) ✓ +- **Evaluator → PipelineFactory / Pipelines / Metrics Interfaces** ✓ +- **PipelineFactory → Pipelines** ✓ +- **Pipelines ↔ Metrics Interfaces** ✗ (evaluators mediate) +- **Metrics Interfaces → Runner/PipelineFactory** ✗ + +Adhering to this contract keeps responsibilities isolated, simplifies testing, and allows independent refactors of runners, evaluators, pipelines, and metrics logic. diff --git a/deployment/docs/export_pipeline.md b/deployment/docs/export_pipeline.md new file mode 100644 index 000000000..e6a9ed963 --- /dev/null +++ b/deployment/docs/export_pipeline.md @@ -0,0 +1,50 @@ +# Export Pipelines + +## ONNX Export + +1. **Model preparation** – load PyTorch model and apply the wrapper if output reshaping is required. +2. **Input preparation** – grab a representative sample from the data loader. +3. **Export** – call `torch.onnx.export()` with the configured settings. +4. **Simplification** – optionally run ONNX simplification. +5. **Save** – store artifacts under `work_dir/onnx/`. + +## TensorRT Export + +1. **Validate ONNX** – ensure the ONNX model exists and is compatible. +2. **Network creation** – parse ONNX and build a TensorRT network. +3. **Precision policy** – apply the configured precision mode (`auto`, `fp16`, `fp32_tf32`, `strongly_typed`). +4. **Optimization profile** – configure dynamic-shape ranges. +5. **Engine build** – compile and serialize the engine. +6. **Save** – store artifacts under `work_dir/tensorrt/`. + +## Multi-File Export (CenterPoint) + +CenterPoint splits the model into multiple ONNX/TensorRT artifacts: + +- `voxel_encoder.onnx` +- `backbone_head.onnx` + +Export pipelines orchestrate: + +- Sequential export of each component. +- Input/output wiring between stages. +- Directory structure management. + +## Verification-Oriented Exports + +- Exporters register artifacts via `ArtifactManager`, making the exported files discoverable for verification and evaluation. +- Wrappers ensure consistent tensor ordering and shape expectations across backends. + +## Dependency Injection Pattern + +Projects inject wrappers and export pipelines when instantiating the runner: + +```python +runner = CenterPointDeploymentRunner( + ..., + onnx_pipeline=CenterPointONNXExportPipeline(...), + tensorrt_pipeline=CenterPointTensorRTExportPipeline(...), +) +``` + +Simple projects can skip export pipelines entirely and rely on the base exporters provided by `ExporterFactory`. diff --git a/deployment/docs/overview.md b/deployment/docs/overview.md new file mode 100644 index 000000000..bebb7a47b --- /dev/null +++ b/deployment/docs/overview.md @@ -0,0 +1,59 @@ +# Deployment Overview + +The AWML Deployment Framework provides a standardized, task-agnostic approach to exporting PyTorch models to ONNX and TensorRT with verification and evaluation baked in. It abstracts the common workflow steps while leaving space for project-specific customization so that CenterPoint, YOLOX, CalibrationStatusClassification, and future models can share the same deployment flow. + +## Design Principles + +1. **Unified interface** – a shared `BaseDeploymentRunner` with thin project-specific subclasses. +2. **Task-agnostic core** – base classes support detection, classification, and segmentation tasks. +3. **Backend flexibility** – PyTorch, ONNX, and TensorRT backends are first-class citizens. +4. **Pipeline architecture** – common pre/postprocessing with backend-specific inference stages. +5. **Configuration-driven** – configs plus typed dataclasses provide predictable defaults and IDE support. +6. **Dependency injection** – exporters, wrappers, and export pipelines are explicitly wired for clarity and testability. +7. **Type-safe building blocks** – typed configs, runtime contexts, and result objects reduce runtime surprises. +8. **Extensible verification** – mixins compare nested outputs so that evaluators stay lightweight. + +## Key Features + +### Unified Deployment Workflow + +``` +Load Model → Export ONNX → Export TensorRT → Verify → Evaluate +``` + +### Scenario-Based Verification + +`VerificationMixin` normalizes devices, reuses pipelines from `PipelineFactory`, and recursively compares nested outputs with per-node logging. Scenarios define which backend pairs to compare. + +```python +verification = dict( + enabled=True, + scenarios={ + "both": [ + {"ref_backend": "pytorch", "ref_device": "cpu", + "test_backend": "onnx", "test_device": "cpu"}, + {"ref_backend": "onnx", "ref_device": "cpu", + "test_backend": "tensorrt", "test_device": "cuda:0"}, + ] + } +) +``` + +### Multi-Backend Evaluation + +Evaluators return typed results via `EvalResultDict` (TypedDict) ensuring consistent structure across backends. Metrics interfaces (`Detection3DMetricsInterface`, `Detection2DMetricsInterface`, `ClassificationMetricsInterface`) compute task-specific metrics using `autoware_perception_evaluation`. + +### Pipeline Architecture + +Shared preprocessing/postprocessing steps plug into backend-specific inference. Preprocessing can be generated from MMDet/MMDet3D configs via `build_preprocessing_pipeline`. + +### Flexible Export Modes + +- `mode="onnx"` – PyTorch → ONNX only. +- `mode="trt"` – Build TensorRT from an existing ONNX export. +- `mode="both"` – Full export pipeline. +- `mode="none"` – Skip export and only run evaluation. + +### TensorRT Precision Policies + +Supports `auto`, `fp16`, `fp32_tf32`, and `strongly_typed` modes with typed configuration to keep engine builds reproducible. diff --git a/deployment/docs/projects.md b/deployment/docs/projects.md new file mode 100644 index 000000000..e6ea0d118 --- /dev/null +++ b/deployment/docs/projects.md @@ -0,0 +1,74 @@ +# Project Guides + +## CenterPoint (3D Detection) + +**Highlights** + +- Multi-file ONNX export (voxel encoder + backbone/head) orchestrated via export pipelines. +- ONNX-compatible model configuration that mirrors training graph. +- Composed exporters keep logic reusable. + +**Pipelines & Wrappers** + +- `CenterPointONNXExportPipeline` – drives multiple ONNX exports using the generic `ONNXExporter`. +- `CenterPointTensorRTExportPipeline` – converts each ONNX file via the generic `TensorRTExporter`. +- `CenterPointONNXWrapper` – identity wrapper. + +**Key Files** + +- `deployment/cli/main.py` (single entrypoint) +- `deployment/projects/centerpoint/entrypoint.py` +- `deployment/projects/centerpoint/evaluator.py` +- `deployment/projects/centerpoint/pipelines/` +- `deployment/projects/centerpoint/export/` + +**Pipeline Structure** + +``` +preprocess() → run_voxel_encoder() → process_middle_encoder() → +run_backbone_head() → postprocess() +``` + +## YOLOX (2D Detection) + +**Highlights** + +- Standard single-file ONNX export. +- `YOLOXOptElanONNXWrapper` reshapes output to Tier4-compatible format. +- ReLU6 → ReLU replacement for ONNX compatibility. + +**Export Stack** + +- `ONNXExporter` and `TensorRTExporter` instantiated via `ExporterFactory` with the YOLOX wrapper. + +**Key Files** + +- `deployment/cli/main.py` (single entrypoint) +- `deployment/projects/yolox_opt_elan/` (planned bundle; not migrated yet) + +**Pipeline Structure** + +``` +preprocess() → run_model() → postprocess() +``` + +## CalibrationStatusClassification + +**Highlights** + +- Binary classification deployment with calibrated/miscalibrated data loaders. +- Single-file ONNX export with no extra output reshaping. + +**Export Stack** + +- `ONNXExporter` and `TensorRTExporter` with `CalibrationONNXWrapper` (identity wrapper). + +**Key Files** + +- `deployment/projects/calibration_status_classification/legacy/main.py` (legacy script) + +**Pipeline Structure** + +``` +preprocess() → run_model() → postprocess() +``` diff --git a/deployment/docs/usage.md b/deployment/docs/usage.md new file mode 100644 index 000000000..4c81382ba --- /dev/null +++ b/deployment/docs/usage.md @@ -0,0 +1,107 @@ +# Usage & Entry Points + +## Basic Commands + +```bash +# Single deployment entrypoint (project is a subcommand) +python -m deployment.cli.main centerpoint \ + \ + + +# Example with CenterPoint-specific flag +python -m deployment.cli.main centerpoint \ + \ + \ + --rot-y-axis-reference +``` + +## Creating a Project Runner + +Projects pass lightweight configuration objects (wrapper classes and optional export pipelines) into the runner. Exporters are created lazily via `ExporterFactory`. + +```python +# Project bundles live under deployment/projects/ and are resolved by the CLI. +# The runtime layer is under deployment/runtime/*. +``` + +Key points: + +- Pass wrapper classes (and optional export pipelines) instead of exporter instances. +- Exporters are constructed lazily inside `BaseDeploymentRunner`. +- Entry points remain explicit and easily testable. + +## Typed Context Objects + +Typed contexts carry parameters through the workflow, improving IDE discoverability and refactor safety. + +```python +from deployment.core import ExportContext, YOLOXExportContext, CenterPointExportContext + +results = runner.run(context=YOLOXExportContext( + sample_idx=0, + model_cfg_path="/path/to/config.py", +)) +``` + +Available contexts: + +- `ExportContext` – default context with `sample_idx` and `extra` dict. +- `YOLOXExportContext` – adds `model_cfg_path`. +- `CenterPointExportContext` – adds `rot_y_axis_reference`. +- `CalibrationExportContext` – calibration-specific options. + +Create custom contexts by subclassing `ExportContext` and adding dataclass fields. + +## Command-Line Arguments + +```bash +python deploy/main.py \ + \ # Deployment configuration file + \ # Model configuration file + --log-level # Optional: DEBUG, INFO, WARNING, ERROR (default: INFO) +``` + +## Export Modes + +### ONNX Only + +```python +checkpoint_path = "model.pth" + +export = dict( + mode="onnx", + work_dir="work_dirs/deployment", +) +``` + +### TensorRT From Existing ONNX + +```python +export = dict( + mode="trt", + onnx_path="work_dirs/deployment/onnx/model.onnx", + work_dir="work_dirs/deployment", +) +``` + +### Full Export Pipeline + +```python +checkpoint_path = "model.pth" + +export = dict( + mode="both", + work_dir="work_dirs/deployment", +) +``` + +### Evaluation-Only + +```python +checkpoint_path = "model.pth" + +export = dict( + mode="none", + work_dir="work_dirs/deployment", +) +``` diff --git a/deployment/docs/verification_evaluation.md b/deployment/docs/verification_evaluation.md new file mode 100644 index 000000000..e4f13e4df --- /dev/null +++ b/deployment/docs/verification_evaluation.md @@ -0,0 +1,65 @@ +# Verification & Evaluation + +## Verification + +`VerificationMixin` coordinates scenario-based comparisons: + +1. Resolve reference/test pipelines through `PipelineFactory`. +2. Normalize devices per backend (PyTorch → CPU, TensorRT → `cuda:0`, …). +3. Run inference on shared samples. +4. Recursively compare nested outputs with tolerance controls. +5. Emit per-sample pass/fail statistics. + +Example configuration: + +```python +verification = dict( + enabled=True, + scenarios={ + "both": [ + { + "ref_backend": "pytorch", + "ref_device": "cpu", + "test_backend": "onnx", + "test_device": "cpu" + } + ] + }, + tolerance=0.1, + num_verify_samples=3, +) +``` + +## Evaluation + +Task-specific evaluators share typed metrics so reports stay consistent across backends. + +### Detection + +- mAP and per-class AP. +- Latency statistics (mean, std, min, max). + +### Classification + +- Accuracy, precision, recall. +- Per-class metrics and confusion matrix. +- Latency statistics. + +Evaluation configuration example: + +```python +evaluation = dict( + enabled=True, + num_samples=100, + verbose=False, + backends={ + "pytorch": {"enabled": True, "device": "cpu"}, + "onnx": {"enabled": True, "device": "cpu"}, + "tensorrt": {"enabled": True, "device": "cuda:0"}, + } +) +``` + +## Core Contract + +`deployment/docs/core_contract.md` documents the responsibilities and allowed dependencies between runners, evaluators, pipelines, `PipelineFactory`, and metrics interfaces. Following the contract keeps refactors safe and ensures new projects remain compatible with shared infrastructure. diff --git a/deployment/exporters/__init__.py b/deployment/exporters/__init__.py new file mode 100644 index 000000000..31f34d6b6 --- /dev/null +++ b/deployment/exporters/__init__.py @@ -0,0 +1,17 @@ +"""Model exporters for different backends.""" + +from deployment.exporters.common.base_exporter import BaseExporter +from deployment.exporters.common.configs import ONNXExportConfig, TensorRTExportConfig +from deployment.exporters.common.model_wrappers import BaseModelWrapper, IdentityWrapper +from deployment.exporters.common.onnx_exporter import ONNXExporter +from deployment.exporters.common.tensorrt_exporter import TensorRTExporter + +__all__ = [ + "BaseExporter", + "ONNXExportConfig", + "TensorRTExportConfig", + "ONNXExporter", + "TensorRTExporter", + "BaseModelWrapper", + "IdentityWrapper", +] diff --git a/deployment/exporters/common/base_exporter.py b/deployment/exporters/common/base_exporter.py new file mode 100644 index 000000000..057ef9712 --- /dev/null +++ b/deployment/exporters/common/base_exporter.py @@ -0,0 +1,82 @@ +""" +Abstract base class for model exporters. + +Provides a unified interface for exporting models to different formats. +""" + +import logging +from abc import ABC, abstractmethod +from typing import Any, Optional + +import torch + +from deployment.exporters.common.configs import BaseExporterConfig +from deployment.exporters.common.model_wrappers import BaseModelWrapper + + +class BaseExporter(ABC): + """ + Abstract base class for model exporters. + + This class defines a unified interface for exporting models + to different backend formats (ONNX, TensorRT, TorchScript, etc.). + + Enhanced features: + - Support for model wrappers (preprocessing before export) + - Flexible configuration with overrides + - Better logging and error handling + """ + + def __init__( + self, + config: BaseExporterConfig, + model_wrapper: Optional[BaseModelWrapper] = None, + logger: Optional[logging.Logger] = None, + ): + """ + Initialize exporter. + + Args: + config: Typed export configuration dataclass (e.g., ``ONNXExportConfig``, + ``TensorRTExportConfig``). This ensures type safety and clear schema. + model_wrapper: Optional model wrapper class or callable. + If a class is provided, it will be instantiated with the model. + If an instance is provided, it should be a callable that takes a model. + logger: Optional logger instance + """ + self.config: BaseExporterConfig = config + self.logger = logger or logging.getLogger(__name__) + self._model_wrapper = model_wrapper + + def prepare_model(self, model: torch.nn.Module) -> torch.nn.Module: + """ + Prepare model for export (apply wrapper if configured). + + Args: + model: Original PyTorch model + + Returns: + Prepared model (wrapped if wrapper configured) + """ + if self._model_wrapper is None: + return model + + self.logger.info("Applying model wrapper for export") + + return self._model_wrapper(model) + + @abstractmethod + def export(self, model: torch.nn.Module, sample_input: Any, output_path: str, **kwargs) -> None: + """ + Export model to target format. + + Args: + model: PyTorch model to export + sample_input: Example model input(s) for tracing/shape inference + output_path: Path to save exported model + **kwargs: Additional format-specific arguments + + Raises: + RuntimeError: If export fails + """ + raise NotImplementedError diff --git a/deployment/exporters/common/configs.py b/deployment/exporters/common/configs.py new file mode 100644 index 000000000..76d6bc4b1 --- /dev/null +++ b/deployment/exporters/common/configs.py @@ -0,0 +1,155 @@ +"""Typed configuration helpers shared by exporter implementations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Iterable, Mapping, Optional, Tuple + + +def _empty_mapping() -> Mapping[Any, Any]: + """Return an immutable empty mapping.""" + return MappingProxyType({}) + + +@dataclass(frozen=True) +class TensorRTProfileConfig: + """Optimization profile description for a TensorRT input tensor.""" + + min_shape: Tuple[int, ...] = field(default_factory=tuple) + opt_shape: Tuple[int, ...] = field(default_factory=tuple) + max_shape: Tuple[int, ...] = field(default_factory=tuple) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TensorRTProfileConfig: + return cls( + min_shape=cls._normalize_shape(data.get("min_shape")), + opt_shape=cls._normalize_shape(data.get("opt_shape")), + max_shape=cls._normalize_shape(data.get("max_shape")), + ) + + @staticmethod + def _normalize_shape(shape: Optional[Iterable[int]]) -> Tuple[int, ...]: + if shape is None: + return tuple() + return tuple(int(dim) for dim in shape) + + def has_complete_profile(self) -> bool: + return bool(self.min_shape and self.opt_shape and self.max_shape) + + +@dataclass(frozen=True) +class TensorRTModelInputConfig: + """Typed container for TensorRT model input shape settings.""" + + input_shapes: Mapping[str, TensorRTProfileConfig] = field(default_factory=_empty_mapping) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TensorRTModelInputConfig: + input_shapes_raw = data.get("input_shapes", {}) or {} + profile_map = { + name: TensorRTProfileConfig.from_dict(shape_dict or {}) for name, shape_dict in input_shapes_raw.items() + } + return cls(input_shapes=MappingProxyType(profile_map)) + + +class BaseExporterConfig: + """ + Base class for typed exporter configuration dataclasses. + + Concrete configs should extend this class and provide typed fields + for all configuration parameters. + """ + + pass + + +@dataclass(frozen=True) +class ONNXExportConfig(BaseExporterConfig): + """ + Typed schema describing ONNX exporter configuration. + + Attributes: + input_names: Ordered collection of input tensor names. + output_names: Ordered collection of output tensor names. + dynamic_axes: Optional dynamic axes mapping identical to torch.onnx API. + simplify: Whether to run onnx-simplifier after export. + opset_version: ONNX opset to target. + export_params: Whether to embed weights inside the ONNX file. + keep_initializers_as_inputs: Mirror of torch.onnx flag. + verbose: Whether to log torch.onnx export graph debugging. + do_constant_folding: Whether to enable constant folding. + save_file: Output filename for the ONNX model. + batch_size: Fixed batch size for export (None for dynamic batch). + """ + + input_names: Tuple[str, ...] = ("input",) + output_names: Tuple[str, ...] = ("output",) + dynamic_axes: Optional[Mapping[str, Mapping[int, str]]] = None + simplify: bool = True + opset_version: int = 16 + export_params: bool = True + keep_initializers_as_inputs: bool = False + verbose: bool = False + do_constant_folding: bool = True + save_file: str = "model.onnx" + batch_size: Optional[int] = None + + @classmethod + def from_mapping(cls, data: Mapping[str, Any]) -> ONNXExportConfig: + """Instantiate config from a plain mapping.""" + return cls( + input_names=tuple(data.get("input_names", cls.input_names)), + output_names=tuple(data.get("output_names", cls.output_names)), + dynamic_axes=data.get("dynamic_axes"), + simplify=data.get("simplify", cls.simplify), + opset_version=data.get("opset_version", cls.opset_version), + export_params=data.get("export_params", cls.export_params), + keep_initializers_as_inputs=data.get("keep_initializers_as_inputs", cls.keep_initializers_as_inputs), + verbose=data.get("verbose", cls.verbose), + do_constant_folding=data.get("do_constant_folding", cls.do_constant_folding), + save_file=data.get("save_file", cls.save_file), + batch_size=data.get("batch_size", cls.batch_size), + ) + + +@dataclass(frozen=True) +class TensorRTExportConfig(BaseExporterConfig): + """ + Typed schema describing TensorRT exporter configuration. + + Attributes: + precision_policy: Name of the precision policy (matches PrecisionPolicy enum). + policy_flags: Mapping of TensorRT builder/network flags. + max_workspace_size: Workspace size in bytes. + model_inputs: Tuple of TensorRTModelInputConfig entries describing shapes. + """ + + precision_policy: str = "auto" + policy_flags: Mapping[str, bool] = field(default_factory=dict) + max_workspace_size: int = 1 << 30 + model_inputs: Tuple[TensorRTModelInputConfig, ...] = field(default_factory=tuple) + + @classmethod + def from_mapping(cls, data: Mapping[str, Any]) -> TensorRTExportConfig: + """Instantiate config from a plain mapping.""" + inputs_raw = data.get("model_inputs") or () + parsed_inputs = tuple( + entry if isinstance(entry, TensorRTModelInputConfig) else TensorRTModelInputConfig.from_dict(entry) + for entry in inputs_raw + ) + return cls( + precision_policy=str(data.get("precision_policy", cls.precision_policy)), + policy_flags=MappingProxyType(dict(data.get("policy_flags", {}))), + max_workspace_size=int(data.get("max_workspace_size", cls.max_workspace_size)), + model_inputs=parsed_inputs, + ) + + +__all__ = [ + "BaseExporterConfig", + "ONNXExportConfig", + "TensorRTExportConfig", + "TensorRTModelInputConfig", + "TensorRTProfileConfig", +] diff --git a/deployment/exporters/common/factory.py b/deployment/exporters/common/factory.py new file mode 100644 index 000000000..9533f2d12 --- /dev/null +++ b/deployment/exporters/common/factory.py @@ -0,0 +1,49 @@ +""" +Factory helpers for creating exporter instances from deployment configs. +""" + +from __future__ import annotations + +import logging +from typing import Type + +from deployment.core import BaseDeploymentConfig +from deployment.exporters.common.model_wrappers import BaseModelWrapper +from deployment.exporters.common.onnx_exporter import ONNXExporter +from deployment.exporters.common.tensorrt_exporter import TensorRTExporter + + +class ExporterFactory: + """ + Factory class for instantiating exporters using deployment configs. + """ + + @staticmethod + def create_onnx_exporter( + config: BaseDeploymentConfig, + wrapper_cls: Type[BaseModelWrapper], + logger: logging.Logger, + ) -> ONNXExporter: + """ + Build an ONNX exporter using the deployment config settings. + """ + + return ONNXExporter( + config=config.get_onnx_settings(), + model_wrapper=wrapper_cls, + logger=logger, + ) + + @staticmethod + def create_tensorrt_exporter( + config: BaseDeploymentConfig, + logger: logging.Logger, + ) -> TensorRTExporter: + """ + Build a TensorRT exporter using the deployment config settings. + """ + + return TensorRTExporter( + config=config.get_tensorrt_settings(), + logger=logger, + ) diff --git a/deployment/exporters/common/model_wrappers.py b/deployment/exporters/common/model_wrappers.py new file mode 100644 index 000000000..24b798ba3 --- /dev/null +++ b/deployment/exporters/common/model_wrappers.py @@ -0,0 +1,69 @@ +""" +Base model wrappers for ONNX export. + +This module provides the base classes for model wrappers that prepare models +for ONNX export with specific output formats and processing requirements. + +Each project should define its own wrapper in {project}/model_wrappers.py, +either by using IdentityWrapper or by creating a custom wrapper that inherits +from BaseModelWrapper. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict + +import torch +import torch.nn as nn + + +class BaseModelWrapper(nn.Module, ABC): + """ + Abstract base class for ONNX export model wrappers. + + Wrappers modify model forward pass to produce ONNX-compatible outputs + with specific formats required by deployment backends. + + Each project should create its own wrapper class that inherits from this + base class if special output format conversion is needed. + """ + + def __init__(self, model: nn.Module, **kwargs): + """ + Initialize wrapper. + + Args: + model: PyTorch model to wrap + **kwargs: Wrapper-specific arguments + """ + super().__init__() + self.model = model + self._wrapper_config = kwargs + + @abstractmethod + def forward(self, *args, **kwargs): + """ + Forward pass for ONNX export. + + Must be implemented by subclasses to define ONNX-specific output format. + """ + raise NotImplementedError + + def get_config(self) -> Dict[str, Any]: + """Get wrapper configuration.""" + return self._wrapper_config + + +class IdentityWrapper(BaseModelWrapper): + """ + Identity wrapper that doesn't modify the model. + + Useful for models that don't need special ONNX export handling. + This is the default wrapper for most models. + """ + + def __init__(self, model: nn.Module, **kwargs): + super().__init__(model, **kwargs) + + def forward(self, *args, **kwargs): + """Forward pass without modification.""" + return self.model(*args, **kwargs) diff --git a/deployment/exporters/common/onnx_exporter.py b/deployment/exporters/common/onnx_exporter.py new file mode 100644 index 000000000..ca1ed9631 --- /dev/null +++ b/deployment/exporters/common/onnx_exporter.py @@ -0,0 +1,205 @@ +"""ONNX model exporter.""" + +import logging +import os +from dataclasses import replace +from typing import Any, Optional + +import onnx +import onnxsim +import torch + +from deployment.exporters.common.base_exporter import BaseExporter +from deployment.exporters.common.configs import ONNXExportConfig + + +class ONNXExporter(BaseExporter): + """ + ONNX model exporter with enhanced features. + + Exports PyTorch models to ONNX format with: + - Optional model wrapping for ONNX-specific output formats + - Optional model simplification + - Multi-file export support for complex models + - Configuration override capability + """ + + def __init__( + self, + config: ONNXExportConfig, + model_wrapper: Optional[Any] = None, + logger: logging.Logger = None, + ): + """ + Initialize ONNX exporter. + + Args: + config: ONNX export configuration dataclass instance. + model_wrapper: Optional model wrapper class (e.g., YOLOXOptElanONNXWrapper) + logger: Optional logger instance + """ + super().__init__(config, model_wrapper=model_wrapper, logger=logger) + self._validate_config(config) + + def _validate_config(self, config: ONNXExportConfig) -> None: + """ + Validate ONNX export configuration. + + Args: + config: Configuration to validate + + Raises: + ValueError: If configuration is invalid + """ + if config.opset_version < 11: + raise ValueError(f"opset_version must be >= 11, got {config.opset_version}") + + if not config.input_names: + raise ValueError("input_names cannot be empty") + + if not config.output_names: + raise ValueError("output_names cannot be empty") + + if len(config.input_names) != len(set(config.input_names)): + raise ValueError("input_names contains duplicates") + + if len(config.output_names) != len(set(config.output_names)): + raise ValueError("output_names contains duplicates") + + def export( + self, + model: torch.nn.Module, + sample_input: Any, + output_path: str, + *, + config_override: Optional[ONNXExportConfig] = None, + ) -> None: + """ + Export model to ONNX format. + + Args: + model: PyTorch model to export + sample_input: Sample input tensor + output_path: Path to save ONNX model + config_override: Optional configuration override. If provided, will be merged + with base config using dataclasses.replace. + + Raises: + RuntimeError: If export fails + ValueError: If configuration is invalid + """ + model = self._prepare_for_onnx(model) + export_cfg = self._build_export_config(config_override) + self._do_onnx_export(model, sample_input, output_path, export_cfg) + if export_cfg.simplify: + self._simplify_model(output_path) + + def _prepare_for_onnx(self, model: torch.nn.Module) -> torch.nn.Module: + """ + Prepare model for ONNX export. + + Applies model wrapper if configured and sets model to eval mode. + + Args: + model: PyTorch model to prepare + + Returns: + Prepared model ready for ONNX export + """ + model = self.prepare_model(model) + model.eval() + return model + + def _build_export_config(self, config_override: Optional[ONNXExportConfig] = None) -> ONNXExportConfig: + """ + Build export configuration by merging base config with override. + + Args: + config_override: Optional configuration override. If provided, all fields + from the override will replace corresponding fields in base config. + + Returns: + Merged configuration ready for export + + Raises: + ValueError: If merged configuration is invalid + """ + if config_override is None: + export_cfg = self.config + else: + export_cfg = replace(self.config, **config_override.__dict__) + + # Validate merged config + self._validate_config(export_cfg) + return export_cfg + + def _do_onnx_export( + self, + model: torch.nn.Module, + sample_input: Any, + output_path: str, + export_cfg: ONNXExportConfig, + ) -> None: + """ + Perform ONNX export using torch.onnx.export. + + Args: + model: Prepared PyTorch model + sample_input: Sample input tensor + output_path: Path to save ONNX model + export_cfg: Export configuration + + Raises: + RuntimeError: If export fails + """ + self.logger.info("Exporting model to ONNX format...") + if hasattr(sample_input, "shape"): + self.logger.info(f" Input shape: {sample_input.shape}") + self.logger.info(f" Output path: {output_path}") + self.logger.info(f" Opset version: {export_cfg.opset_version}") + + # Ensure output directory exists + os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True) + + try: + with torch.no_grad(): + torch.onnx.export( + model, + sample_input, + output_path, + export_params=export_cfg.export_params, + keep_initializers_as_inputs=export_cfg.keep_initializers_as_inputs, + opset_version=export_cfg.opset_version, + do_constant_folding=export_cfg.do_constant_folding, + input_names=list(export_cfg.input_names), + output_names=list(export_cfg.output_names), + dynamic_axes=export_cfg.dynamic_axes, + verbose=export_cfg.verbose, + ) + + self.logger.info(f"ONNX export completed: {output_path}") + + except Exception as e: + self.logger.error(f"ONNX export failed: {e}") + import traceback + + self.logger.error(traceback.format_exc()) + raise RuntimeError("ONNX export failed") from e + + def _simplify_model(self, onnx_path: str) -> None: + """ + Simplify ONNX model using onnxsim. + + Args: + onnx_path: Path to ONNX model file + """ + self.logger.info("Simplifying ONNX model...") + try: + model_simplified, success = onnxsim.simplify(onnx_path) + if success: + onnx.save(model_simplified, onnx_path) + self.logger.info("ONNX model simplified successfully") + else: + self.logger.warning("ONNX model simplification failed") + except Exception as e: + self.logger.warning(f"ONNX simplification error: {e}") diff --git a/deployment/exporters/common/tensorrt_exporter.py b/deployment/exporters/common/tensorrt_exporter.py new file mode 100644 index 000000000..0ecede689 --- /dev/null +++ b/deployment/exporters/common/tensorrt_exporter.py @@ -0,0 +1,406 @@ +"""TensorRT model exporter.""" + +import logging +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple + +import tensorrt as trt +import torch + +from deployment.core.artifacts import Artifact +from deployment.exporters.common.base_exporter import BaseExporter +from deployment.exporters.common.configs import TensorRTExportConfig, TensorRTModelInputConfig, TensorRTProfileConfig + + +class TensorRTExporter(BaseExporter): + """ + TensorRT model exporter. + + Converts ONNX models to TensorRT engine format with precision policy support. + """ + + def __init__( + self, + config: TensorRTExportConfig, + model_wrapper: Optional[Any] = None, + logger: logging.Logger = None, + ): + """ + Initialize TensorRT exporter. + + Args: + config: TensorRT export configuration dataclass instance. + model_wrapper: Optional model wrapper class (usually not needed for TensorRT) + logger: Optional logger instance + """ + super().__init__(config, model_wrapper=model_wrapper, logger=logger) + self.logger = logger or logging.getLogger(__name__) + + def export( + self, + model: torch.nn.Module, # Not used for TensorRT, kept for interface compatibility + sample_input: Any, + output_path: str, + onnx_path: str = None, + ) -> Artifact: + """ + Export ONNX model to TensorRT engine. + + Args: + model: Not used (TensorRT converts from ONNX) + sample_input: Sample input for shape configuration + output_path: Path to save TensorRT engine + onnx_path: Path to source ONNX model + + Returns: + Artifact object representing the exported TensorRT engine + + Raises: + RuntimeError: If export fails + ValueError: If ONNX path is missing + """ + if onnx_path is None: + raise ValueError("onnx_path is required for TensorRT export") + + precision_policy = self.config.precision_policy + self.logger.info(f"Building TensorRT engine with precision policy: {precision_policy}") + self.logger.info(f" ONNX source: {onnx_path}") + self.logger.info(f" Engine output: {output_path}") + + return self._do_tensorrt_export(onnx_path, output_path, sample_input) + + def _do_tensorrt_export( + self, + onnx_path: str, + output_path: str, + sample_input: Any, + ) -> Artifact: + """ + Export a single ONNX file to TensorRT engine. + + This method handles the complete export workflow with proper resource management. + + Args: + onnx_path: Path to source ONNX model + output_path: Path to save TensorRT engine + sample_input: Sample input for shape configuration + + Returns: + Artifact object representing the exported TensorRT engine + + Raises: + RuntimeError: If export fails + """ + # Initialize TensorRT + trt_logger = trt.Logger(trt.Logger.WARNING) + trt.init_libnvinfer_plugins(trt_logger, "") + + builder = trt.Builder(trt_logger) + try: + builder_config, network, parser = self._create_builder_and_network(builder, trt_logger) + try: + self._parse_onnx(parser, network, onnx_path) + self._configure_input_profiles(builder, builder_config, network, sample_input) + serialized_engine = self._build_engine(builder, builder_config, network) + self._save_engine(serialized_engine, output_path) + return Artifact(path=output_path, multi_file=False) + finally: + del parser + del network + finally: + del builder + + def _create_builder_and_network( + self, + builder: trt.Builder, + trt_logger: trt.Logger, + ) -> Tuple[trt.IBuilderConfig, trt.INetworkDefinition, trt.OnnxParser]: + """ + Create builder config, network, and parser. + + Args: + builder: TensorRT builder instance + trt_logger: TensorRT logger instance + + Returns: + Tuple of (builder_config, network, parser) + """ + builder_config = builder.create_builder_config() + + max_workspace_size = self.config.max_workspace_size + builder_config.set_memory_pool_limit(pool=trt.MemoryPoolType.WORKSPACE, pool_size=max_workspace_size) + + # Create network with appropriate flags + flags = 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH) + + # Handle strongly typed flag (network creation flag) + policy_flags = self.config.policy_flags + if policy_flags.get("STRONGLY_TYPED", False): + flags |= 1 << int(trt.NetworkDefinitionCreationFlag.STRONGLY_TYPED) + self.logger.info("Using strongly typed TensorRT network creation") + + network = builder.create_network(flags) + + # Apply precision flags to builder config + for flag_name, enabled in policy_flags.items(): + if flag_name == "STRONGLY_TYPED": + continue + if enabled and hasattr(trt.BuilderFlag, flag_name): + builder_config.set_flag(getattr(trt.BuilderFlag, flag_name)) + self.logger.info(f"BuilderFlag.{flag_name} enabled") + + parser = trt.OnnxParser(network, trt_logger) + + return builder_config, network, parser + + def _parse_onnx( + self, + parser: trt.OnnxParser, + network: trt.INetworkDefinition, + onnx_path: str, + ) -> None: + """ + Parse ONNX model into TensorRT network. + + Args: + parser: TensorRT ONNX parser instance + network: TensorRT network definition + onnx_path: Path to ONNX model file + + Raises: + RuntimeError: If parsing fails + """ + with open(onnx_path, "rb") as f: + if not parser.parse(f.read()): + self._log_parser_errors(parser) + raise RuntimeError("TensorRT export failed: unable to parse ONNX file") + self.logger.info("Successfully parsed ONNX file") + + def _configure_input_profiles( + self, + builder: trt.Builder, + builder_config: trt.IBuilderConfig, + network: trt.INetworkDefinition, + sample_input: Any, + ) -> None: + """ + Configure TensorRT optimization profiles for input shapes. + + Creates an optimization profile and configures min/opt/max shapes for each input. + See `_configure_input_shapes` for details on shape configuration. + + Note: + ONNX `dynamic_axes` and TensorRT profiles serve different purposes: + + - **ONNX dynamic_axes**: Used during ONNX export to define which dimensions + are symbolic (dynamic) in the ONNX graph. This allows the ONNX model to + accept inputs of varying sizes at those dimensions. + + - **TensorRT profile**: Defines the runtime shape envelope (min/opt/max) that + TensorRT will optimize for. TensorRT builds kernels optimized for shapes + within this envelope. The profile must be compatible with the ONNX dynamic + axes, but they are configured separately and serve different roles: + - dynamic_axes: Export-time graph structure + - TRT profile: Runtime optimization envelope + + They are related but not equivalent. The ONNX model may have dynamic axes, + but TensorRT still needs explicit min/opt/max shapes to build optimized kernels. + + Args: + builder: TensorRT builder instance + builder_config: TensorRT builder config + network: TensorRT network definition + sample_input: Sample input for shape configuration (typically obtained via + BaseDataLoader.get_shape_sample()) + """ + profile = builder.create_optimization_profile() + self._configure_input_shapes(profile, sample_input, network) + builder_config.add_optimization_profile(profile) + + def _build_engine( + self, + builder: trt.Builder, + builder_config: trt.IBuilderConfig, + network: trt.INetworkDefinition, + ) -> bytes: + """ + Build TensorRT engine from network. + + Args: + builder: TensorRT builder instance + builder_config: TensorRT builder config + network: TensorRT network definition + + Returns: + Serialized engine as bytes + + Raises: + RuntimeError: If engine building fails + """ + self.logger.info("Building TensorRT engine (this may take a while)...") + serialized_engine = builder.build_serialized_network(network, builder_config) + + if serialized_engine is None: + self.logger.error("Failed to build TensorRT engine") + raise RuntimeError("TensorRT export failed: builder returned None") + + return serialized_engine + + def _save_engine( + self, + serialized_engine: bytes, + output_path: str, + ) -> None: + """ + Save serialized TensorRT engine to file. + + Args: + serialized_engine: Serialized engine bytes + output_path: Path to save engine file + """ + with open(output_path, "wb") as f: + f.write(serialized_engine) + + max_workspace_size = self.config.max_workspace_size + self.logger.info(f"TensorRT engine saved to {output_path}") + self.logger.info(f"Engine max workspace size: {max_workspace_size / (1024**3):.2f} GB") + + def _configure_input_shapes( + self, + profile: trt.IOptimizationProfile, + sample_input: Any, + network: trt.INetworkDefinition = None, + ) -> None: + """ + Configure input shapes for TensorRT optimization profile. + + Note: + ONNX dynamic_axes is used for export; TRT profile is the runtime envelope; + they are related but not equivalent. + + - **ONNX dynamic_axes**: Controls symbolic dimensions in the ONNX graph during + export. Defines which dimensions can vary at runtime in the ONNX model. + + - **TensorRT profile (min/opt/max)**: Defines the runtime shape envelope that + TensorRT optimizes for. TensorRT builds kernels optimized for shapes within + this envelope. The profile must be compatible with the ONNX dynamic axes, + but they are configured separately: + - dynamic_axes: Export-time graph structure (what dimensions are variable) + - TRT profile: Runtime optimization envelope (what shapes to optimize for) + + They are complementary but independent. The ONNX model may have dynamic axes, + but TensorRT still needs explicit min/opt/max shapes to build optimized kernels. + + Raises: + ValueError: If neither model_inputs config nor sample_input is provided + """ + model_inputs_cfg = self.config.model_inputs + + # Validate that we have shape information + first_input_shapes = None + if model_inputs_cfg: + first_input_shapes = self._extract_input_shapes(model_inputs_cfg[0]) + + if not model_inputs_cfg or not first_input_shapes: + if sample_input is None: + raise ValueError( + "TensorRT export requires shape information. Please provide either:\n" + " 1. Explicit 'model_inputs' with 'input_shapes' (min/opt/max) in config, OR\n" + " 2. A 'sample_input' tensor for automatic shape inference\n" + "\n" + "Current config has:\n" + f" - model_inputs: {model_inputs_cfg}\n" + f" - sample_input: {sample_input}\n" + "\n" + "Example config:\n" + " backend_config = dict(\n" + " model_inputs=[\n" + " dict(\n" + " input_shapes={\n" + " 'input': dict(\n" + " min_shape=(1, 3, 960, 960),\n" + " opt_shape=(1, 3, 960, 960),\n" + " max_shape=(1, 3, 960, 960),\n" + " )\n" + " }\n" + " )\n" + " ]\n" + " )" + ) + # If we have sample_input but no config, we could infer shapes + # For now, just require explicit config + self.logger.warning( + "sample_input provided but no explicit model_inputs config. " + "TensorRT export may fail if ONNX has dynamic dimensions." + ) + + if not model_inputs_cfg: + raise ValueError("model_inputs is not set in the config") + + # model_inputs is already a Tuple[TensorRTModelInputConfig, ...] + first_entry = model_inputs_cfg[0] + input_shapes = first_input_shapes + + if not input_shapes: + raise ValueError("TensorRT model_inputs[0] missing 'input_shapes' definitions") + + for input_name, profile_cfg in input_shapes.items(): + min_shape, opt_shape, max_shape = self._resolve_profile_shapes(profile_cfg, sample_input, input_name) + self.logger.info(f"Setting {input_name} shapes - min: {min_shape}, opt: {opt_shape}, max: {max_shape}") + profile.set_shape(input_name, min_shape, opt_shape, max_shape) + + def _log_parser_errors(self, parser: trt.OnnxParser) -> None: + """Log TensorRT parser errors.""" + self.logger.error("Failed to parse ONNX model") + for error in range(parser.num_errors): + self.logger.error(f"Parser error: {parser.get_error(error)}") + + def _extract_input_shapes(self, entry: Any) -> Mapping[str, Any]: + if isinstance(entry, TensorRTModelInputConfig): + return entry.input_shapes + if isinstance(entry, Mapping): + return entry.get("input_shapes", {}) or {} + raise TypeError(f"Unsupported TensorRT model input entry: {type(entry)}") + + def _resolve_profile_shapes( + self, + profile_cfg: Any, + sample_input: Any, + input_name: str, + ) -> Sequence[Sequence[int]]: + if isinstance(profile_cfg, TensorRTProfileConfig): + min_shape = self._shape_to_list(profile_cfg.min_shape) + opt_shape = self._shape_to_list(profile_cfg.opt_shape) + max_shape = self._shape_to_list(profile_cfg.max_shape) + elif isinstance(profile_cfg, Mapping): + min_shape = self._shape_to_list(profile_cfg.get("min_shape")) + opt_shape = self._shape_to_list(profile_cfg.get("opt_shape")) + max_shape = self._shape_to_list(profile_cfg.get("max_shape")) + else: + raise TypeError(f"Unsupported TensorRT profile type for input '{input_name}': {type(profile_cfg)}") + + return ( + self._ensure_shape(min_shape, sample_input, input_name, "min"), + self._ensure_shape(opt_shape, sample_input, input_name, "opt"), + self._ensure_shape(max_shape, sample_input, input_name, "max"), + ) + + @staticmethod + def _shape_to_list(shape: Optional[Sequence[int]]) -> Optional[Sequence[int]]: + if shape is None: + return None + return [int(dim) for dim in shape] + + def _ensure_shape( + self, + shape: Optional[Sequence[int]], + sample_input: Any, + input_name: str, + bucket: str, + ) -> Sequence[int]: + if shape: + return list(shape) + if sample_input is None or not hasattr(sample_input, "shape"): + raise ValueError(f"{bucket}_shape missing for {input_name} and sample_input is not provided") + inferred = list(sample_input.shape) + self.logger.debug("Falling back to sample_input.shape=%s for %s:%s", inferred, input_name, bucket) + return inferred diff --git a/deployment/exporters/export_pipelines/__init__.py b/deployment/exporters/export_pipelines/__init__.py new file mode 100644 index 000000000..e65b55e0a --- /dev/null +++ b/deployment/exporters/export_pipelines/__init__.py @@ -0,0 +1,16 @@ +"""Export pipeline interfaces and component extraction helpers.""" + +from deployment.exporters.export_pipelines.base import OnnxExportPipeline, TensorRTExportPipeline +from deployment.exporters.export_pipelines.interfaces import ( + ExportableComponent, + ModelComponentExtractor, +) + +__all__ = [ + # Base export pipelines + "OnnxExportPipeline", + "TensorRTExportPipeline", + # Component extraction interfaces + "ModelComponentExtractor", + "ExportableComponent", +] diff --git a/deployment/exporters/export_pipelines/base.py b/deployment/exporters/export_pipelines/base.py new file mode 100644 index 000000000..1b0ff7d0b --- /dev/null +++ b/deployment/exporters/export_pipelines/base.py @@ -0,0 +1,72 @@ +""" +Base export pipeline interfaces for specialized export flows. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from deployment.core.artifacts import Artifact +from deployment.core.config.base_config import BaseDeploymentConfig +from deployment.core.io.base_data_loader import BaseDataLoader + + +class OnnxExportPipeline(ABC): + """ + Base interface for ONNX export pipelines. + """ + + @abstractmethod + def export( + self, + *, + model: Any, + data_loader: BaseDataLoader, + output_dir: str, + config: BaseDeploymentConfig, + sample_idx: int = 0, + ) -> Artifact: + """ + Execute the ONNX export pipeline and return the produced artifact. + + Args: + model: PyTorch model to export + data_loader: Data loader for samples + output_dir: Directory for output files + config: Deployment configuration + sample_idx: Sample index for tracing + + Returns: + Artifact describing the exported ONNX output + """ + + +class TensorRTExportPipeline(ABC): + """ + Base interface for TensorRT export pipelines. + """ + + @abstractmethod + def export( + self, + *, + onnx_path: str, + output_dir: str, + config: BaseDeploymentConfig, + device: str, + data_loader: BaseDataLoader, + ) -> Artifact: + """ + Execute the TensorRT export pipeline and return the produced artifact. + + Args: + onnx_path: Path to ONNX model file/directory + output_dir: Directory for output files + config: Deployment configuration + device: CUDA device string + data_loader: Data loader for samples + + Returns: + Artifact describing the exported TensorRT output + """ diff --git a/deployment/exporters/export_pipelines/interfaces.py b/deployment/exporters/export_pipelines/interfaces.py new file mode 100644 index 000000000..a2ebd7ee7 --- /dev/null +++ b/deployment/exporters/export_pipelines/interfaces.py @@ -0,0 +1,66 @@ +""" +Interfaces for export pipeline components. + +This module defines interfaces that allow project-specific code to provide +model-specific knowledge to generic deployment export pipelines. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, List, Optional, Tuple + +import torch + +from deployment.exporters.common.configs import ONNXExportConfig + + +@dataclass(frozen=True) +class ExportableComponent: + """ + A model component ready for ONNX export. + + Attributes: + name: Component name (e.g., "voxel_encoder", "backbone_head") + module: PyTorch module to export + sample_input: Sample input tensor for tracing + config_override: Optional ONNX export config override + """ + + name: str + module: torch.nn.Module + sample_input: Any + config_override: Optional[ONNXExportConfig] = None + + +class ModelComponentExtractor(ABC): + """ + Interface for extracting exportable model components. + + This interface allows project-specific code to provide model-specific + knowledge (model structure, component extraction, input preparation) + without the deployment framework needing to know about specific models. + + This solves the dependency inversion problem: instead of deployment + framework importing from projects/, projects/ implement this interface + and inject it into export pipelines. + """ + + @abstractmethod + def extract_components(self, model: torch.nn.Module, sample_data: Any) -> List[ExportableComponent]: + """ + Extract all components that need to be exported to ONNX. + + This method should handle all model-specific logic: + - Running model inference to prepare inputs + - Creating combined modules (e.g., backbone+neck+head) + - Preparing sample inputs for each component + - Specifying ONNX export configs for each component + + Args: + model: PyTorch model to extract components from + sample_data: Sample data for preparing inputs + + Returns: + List of ExportableComponent instances ready for ONNX export + """ + pass diff --git a/deployment/pipelines/__init__.py b/deployment/pipelines/__init__.py new file mode 100644 index 000000000..8eaa99d9c --- /dev/null +++ b/deployment/pipelines/__init__.py @@ -0,0 +1,18 @@ +"""Deployment pipeline infrastructure. + +Project-specific pipeline implementations live under `deployment/projects//pipelines/` +and should register themselves into `deployment.pipelines.registry.pipeline_registry`. +""" + +from deployment.pipelines.base_factory import BasePipelineFactory +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.factory import PipelineFactory +from deployment.pipelines.registry import PipelineRegistry, pipeline_registry + +__all__ = [ + "BaseDeploymentPipeline", + "BasePipelineFactory", + "PipelineRegistry", + "pipeline_registry", + "PipelineFactory", +] diff --git a/deployment/pipelines/base_factory.py b/deployment/pipelines/base_factory.py new file mode 100644 index 000000000..f0f358e14 --- /dev/null +++ b/deployment/pipelines/base_factory.py @@ -0,0 +1,53 @@ +""" +Base Pipeline Factory for Project-specific Pipeline Creation. + +Flattened from `deployment/pipelines/common/base_factory.py`. +""" + +import logging +from abc import ABC, abstractmethod +from typing import Any, Optional + +from deployment.core.backend import Backend +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class BasePipelineFactory(ABC): + """Project-specific factory interface for building deployment pipelines. + + A project registers a subclass into `deployment.pipelines.registry.pipeline_registry`. + Evaluators then call into the registry/factory to instantiate the correct pipeline + for a given (project, backend) pair. + """ + + @classmethod + @abstractmethod + def get_project_name(cls) -> str: + raise NotImplementedError + + @classmethod + @abstractmethod + def create_pipeline( + cls, + model_spec: ModelSpec, + pytorch_model: Any, + device: Optional[str] = None, + **kwargs, + ) -> BaseDeploymentPipeline: + raise NotImplementedError + + @classmethod + def get_supported_backends(cls) -> list: + return [Backend.PYTORCH, Backend.ONNX, Backend.TENSORRT] + + @classmethod + def _validate_backend(cls, backend: Backend) -> None: + supported = cls.get_supported_backends() + if backend not in supported: + supported_names = [b.value for b in supported] + raise ValueError( + f"Unsupported backend '{backend.value}' for {cls.get_project_name()}. Supported backends: {supported_names}" + ) diff --git a/deployment/pipelines/base_pipeline.py b/deployment/pipelines/base_pipeline.py new file mode 100644 index 000000000..ff7bc7c93 --- /dev/null +++ b/deployment/pipelines/base_pipeline.py @@ -0,0 +1,125 @@ +""" +Base Deployment Pipeline for Unified Model Deployment. + +Flattened from `deployment/pipelines/common/base_pipeline.py`. +""" + +import logging +import time +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Tuple, Union + +import torch + +from deployment.core.evaluation.evaluator_types import InferenceResult + +logger = logging.getLogger(__name__) + + +class BaseDeploymentPipeline(ABC): + """Base contract for a deployment inference pipeline. + + A pipeline is responsible for the classic 3-stage inference flow: + `preprocess -> run_model -> postprocess`. + + The default `infer()` implementation measures per-stage latency and returns an + `InferenceResult` with optional breakdown information. + """ + + def __init__(self, model: Any, device: str = "cpu", task_type: str = "unknown", backend_type: str = "unknown"): + self.model = model + self.device = torch.device(device) if isinstance(device, str) else device + self.task_type = task_type + self.backend_type = backend_type + self._stage_latencies: Dict[str, float] = {} + + logger.info(f"Initialized {self.__class__.__name__} on device: {self.device}") + + @abstractmethod + def preprocess(self, input_data: Any, **kwargs) -> Any: + raise NotImplementedError + + @abstractmethod + def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple[Any, Dict[str, float]]]: + raise NotImplementedError + + @abstractmethod + def postprocess(self, model_output: Any, metadata: Dict = None) -> Any: + raise NotImplementedError + + def infer( + self, input_data: Any, metadata: Optional[Dict] = None, return_raw_outputs: bool = False, **kwargs + ) -> InferenceResult: + if metadata is None: + metadata = {} + + latency_breakdown: Dict[str, float] = {} + + try: + start_time = time.perf_counter() + + preprocessed = self.preprocess(input_data, **kwargs) + + preprocess_metadata = {} + model_input = preprocessed + if isinstance(preprocessed, tuple) and len(preprocessed) == 2 and isinstance(preprocessed[1], dict): + model_input, preprocess_metadata = preprocessed + + preprocess_time = time.perf_counter() + latency_breakdown["preprocessing_ms"] = (preprocess_time - start_time) * 1000 + + merged_metadata = {} + merged_metadata.update(metadata or {}) + merged_metadata.update(preprocess_metadata) + + model_start = time.perf_counter() + model_result = self.run_model(model_input) + model_time = time.perf_counter() + latency_breakdown["model_ms"] = (model_time - model_start) * 1000 + + if isinstance(model_result, tuple) and len(model_result) == 2: + model_output, stage_latencies = model_result + if isinstance(stage_latencies, dict): + latency_breakdown.update(stage_latencies) + else: + model_output = model_result + + # Legacy stage latency aggregation (kept) + if hasattr(self, "_stage_latencies") and isinstance(self._stage_latencies, dict): + latency_breakdown.update(self._stage_latencies) + self._stage_latencies = {} + + total_latency = (time.perf_counter() - start_time) * 1000 + + if return_raw_outputs: + return InferenceResult(output=model_output, latency_ms=total_latency, breakdown=latency_breakdown) + + postprocess_start = time.perf_counter() + predictions = self.postprocess(model_output, merged_metadata) + postprocess_time = time.perf_counter() + latency_breakdown["postprocessing_ms"] = (postprocess_time - postprocess_start) * 1000 + + total_latency = (time.perf_counter() - start_time) * 1000 + return InferenceResult(output=predictions, latency_ms=total_latency, breakdown=latency_breakdown) + + except Exception: + logger.exception("Inference failed.") + raise + + def cleanup(self) -> None: + pass + + def __repr__(self): + return ( + f"{self.__class__.__name__}(" + f"device={self.device}, " + f"task={self.task_type}, " + f"backend={self.backend_type})" + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() + return False diff --git a/deployment/pipelines/factory.py b/deployment/pipelines/factory.py new file mode 100644 index 000000000..b7ef2290f --- /dev/null +++ b/deployment/pipelines/factory.py @@ -0,0 +1,109 @@ +""" +Pipeline Factory for Centralized Pipeline Instantiation. + +This module provides a unified interface for creating deployment pipelines +using the registry pattern. Each project registers its own factory, and +this module provides convenience methods for pipeline creation. + +Architecture: + - Each project implements `BasePipelineFactory` in its own directory + - Factories are registered with `pipeline_registry` using decorators + - This factory provides a unified interface for pipeline creation + +Usage: + from deployment.pipelines.factory import PipelineFactory + pipeline = PipelineFactory.create("centerpoint", model_spec, pytorch_model) + + # Or use registry directly: + from deployment.pipelines.registry import pipeline_registry + pipeline = pipeline_registry.create_pipeline("centerpoint", model_spec, pytorch_model) +""" + +import logging +from typing import Any, List, Optional + +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.registry import pipeline_registry + +logger = logging.getLogger(__name__) + + +class PipelineFactory: + """ + Factory for creating deployment pipelines. + + This class provides a unified interface for creating pipelines across + different projects and backends. It delegates to project-specific + factories through the pipeline registry. + + Example: + # Create a pipeline using the generic method + pipeline = PipelineFactory.create("centerpoint", model_spec, pytorch_model) + + # List available projects + projects = PipelineFactory.list_projects() + """ + + @staticmethod + def create( + project_name: str, + model_spec: ModelSpec, + pytorch_model: Any, + device: Optional[str] = None, + **kwargs, + ) -> BaseDeploymentPipeline: + """ + Create a pipeline for the specified project. + + Args: + project_name: Name of the project (e.g., "centerpoint", "yolox") + model_spec: Model specification (backend/device/path) + pytorch_model: PyTorch model instance + device: Override device (uses model_spec.device if None) + **kwargs: Project-specific arguments + + Returns: + Pipeline instance + + Raises: + KeyError: If project is not registered + ValueError: If backend is not supported + + Example: + >>> pipeline = PipelineFactory.create( + ... "centerpoint", + ... model_spec, + ... pytorch_model, + ... ) + """ + return pipeline_registry.create_pipeline( + project_name=project_name, + model_spec=model_spec, + pytorch_model=pytorch_model, + device=device, + **kwargs, + ) + + @staticmethod + def list_projects() -> List[str]: + """ + List all registered projects. + + Returns: + List of registered project names + """ + return pipeline_registry.list_projects() + + @staticmethod + def is_project_registered(project_name: str) -> bool: + """ + Check if a project is registered. + + Args: + project_name: Name of the project + + Returns: + True if project is registered + """ + return pipeline_registry.is_registered(project_name) diff --git a/deployment/pipelines/gpu_resource_mixin.py b/deployment/pipelines/gpu_resource_mixin.py new file mode 100644 index 000000000..a55ef1b88 --- /dev/null +++ b/deployment/pipelines/gpu_resource_mixin.py @@ -0,0 +1,141 @@ +""" +GPU Resource Management utilities for TensorRT Pipelines. + +Flattened from `deployment/pipelines/common/gpu_resource_mixin.py`. +""" + +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +import pycuda.driver as cuda +import torch + +logger = logging.getLogger(__name__) + + +def clear_cuda_memory() -> None: + """Best-effort CUDA memory cleanup for long-running deployment workflows.""" + if torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.synchronize() + + +class GPUResourceMixin(ABC): + """Mixin that provides idempotent GPU resource cleanup. + + Subclasses implement `_release_gpu_resources()` and this mixin ensures cleanup + is called exactly once (including via context-manager or destructor paths). + """ + + _cleanup_called: bool = False + + @abstractmethod + def _release_gpu_resources(self) -> None: + raise NotImplementedError + + def cleanup(self) -> None: + if self._cleanup_called: + return + + try: + self._release_gpu_resources() + clear_cuda_memory() + self._cleanup_called = True + logger.debug(f"{self.__class__.__name__}: GPU resources released") + except Exception as e: + logger.warning(f"Error during GPU resource cleanup: {e}") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() + return False + + def __del__(self): + try: + self.cleanup() + except Exception: + pass + + +class TensorRTResourceManager: + """Helper that tracks CUDA allocations/stream for TensorRT inference. + + This is intentionally minimal: allocate device buffers, provide a stream, + and free everything on context exit. + """ + + def __init__(self): + self._allocations: List[Any] = [] + self._stream: Optional[Any] = None + + def allocate(self, nbytes: int) -> Any: + allocation = cuda.mem_alloc(nbytes) + self._allocations.append(allocation) + return allocation + + def get_stream(self) -> Any: + if self._stream is None: + self._stream = cuda.Stream() + return self._stream + + def synchronize(self) -> None: + if self._stream is not None: + self._stream.synchronize() + + def _release_all(self) -> None: + for allocation in self._allocations: + try: + allocation.free() + except Exception: + pass + self._allocations.clear() + self._stream = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.synchronize() + self._release_all() + return False + + +def release_tensorrt_resources( + engines: Optional[Dict[str, Any]] = None, + contexts: Optional[Dict[str, Any]] = None, + cuda_buffers: Optional[List[Any]] = None, +) -> None: + """Best-effort release of TensorRT engines/contexts and CUDA buffers. + + This is defensive cleanup for cases where objects need explicit deletion and + CUDA buffers need manual `free()`. + """ + if contexts: + for _, context in list(contexts.items()): + if context is not None: + try: + del context + except Exception: + pass + contexts.clear() + + if engines: + for _, engine in list(engines.items()): + if engine is not None: + try: + del engine + except Exception: + pass + engines.clear() + + if cuda_buffers: + for buffer in cuda_buffers: + if buffer is not None: + try: + buffer.free() + except Exception: + pass + cuda_buffers.clear() diff --git a/deployment/pipelines/registry.py b/deployment/pipelines/registry.py new file mode 100644 index 000000000..7cf7ffc03 --- /dev/null +++ b/deployment/pipelines/registry.py @@ -0,0 +1,75 @@ +""" +Pipeline Registry for Dynamic Project Pipeline Registration. + +Flattened from `deployment/pipelines/common/registry.py`. +""" + +import logging +from typing import Any, Dict, Optional, Type + +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.pipelines.base_factory import BasePipelineFactory +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class PipelineRegistry: + """Registry for mapping project names to pipeline factories. + + Factories are responsible for creating a `BaseDeploymentPipeline` instance + given a `ModelSpec` and (optionally) a loaded PyTorch model. + """ + + def __init__(self): + self._factories: Dict[str, Type[BasePipelineFactory]] = {} + + def register(self, factory_cls: Type[BasePipelineFactory]) -> Type[BasePipelineFactory]: + if not issubclass(factory_cls, BasePipelineFactory): + raise TypeError(f"Factory class must inherit from BasePipelineFactory, got {factory_cls.__name__}") + + project_name = factory_cls.get_project_name() + + if project_name in self._factories: + logger.warning( + f"Overwriting existing factory for project '{project_name}': " + f"{self._factories[project_name].__name__} -> {factory_cls.__name__}" + ) + + self._factories[project_name] = factory_cls + logger.debug(f"Registered pipeline factory: {project_name} -> {factory_cls.__name__}") + return factory_cls + + def get_factory(self, project_name: str) -> Type[BasePipelineFactory]: + if project_name not in self._factories: + available = list(self._factories.keys()) + raise KeyError(f"No factory registered for project '{project_name}'. Available projects: {available}") + return self._factories[project_name] + + def create_pipeline( + self, + project_name: str, + model_spec: ModelSpec, + pytorch_model: Any, + device: Optional[str] = None, + **kwargs, + ) -> BaseDeploymentPipeline: + factory = self.get_factory(project_name) + return factory.create_pipeline( + model_spec=model_spec, + pytorch_model=pytorch_model, + device=device, + **kwargs, + ) + + def list_projects(self) -> list: + return list(self._factories.keys()) + + def is_registered(self, project_name: str) -> bool: + return project_name in self._factories + + def reset(self) -> None: + self._factories.clear() + + +pipeline_registry = PipelineRegistry() diff --git a/deployment/projects/__init__.py b/deployment/projects/__init__.py new file mode 100644 index 000000000..649917eb7 --- /dev/null +++ b/deployment/projects/__init__.py @@ -0,0 +1,9 @@ +"""Deployment project bundles. + +Each subpackage under `deployment/projects//` should register a +`ProjectAdapter` into `deployment.projects.registry.project_registry`. +""" + +from deployment.projects.registry import ProjectAdapter, project_registry + +__all__ = ["ProjectAdapter", "project_registry"] diff --git a/deployment/projects/centerpoint/__init__.py b/deployment/projects/centerpoint/__init__.py new file mode 100644 index 000000000..e7cae0e0c --- /dev/null +++ b/deployment/projects/centerpoint/__init__.py @@ -0,0 +1,22 @@ +"""CenterPoint deployment bundle. + +This package owns all CenterPoint deployment-specific code (runner/evaluator/loader/pipelines/export). +It registers a ProjectAdapter into the global `project_registry` so the unified CLI can invoke it. +""" + +from __future__ import annotations + +from deployment.projects.centerpoint.cli import add_args +from deployment.projects.centerpoint.entrypoint import run + +# Trigger pipeline factory registration for this project. +from deployment.projects.centerpoint.pipelines.factory import CenterPointPipelineFactory # noqa: F401 +from deployment.projects.registry import ProjectAdapter, project_registry + +project_registry.register( + ProjectAdapter( + name="centerpoint", + add_args=add_args, + run=run, + ) +) diff --git a/deployment/projects/centerpoint/cli.py b/deployment/projects/centerpoint/cli.py new file mode 100644 index 000000000..cc040e0d9 --- /dev/null +++ b/deployment/projects/centerpoint/cli.py @@ -0,0 +1,14 @@ +"""CenterPoint CLI extensions.""" + +from __future__ import annotations + +import argparse + + +def add_args(parser: argparse.ArgumentParser) -> None: + """Register CenterPoint-specific CLI flags onto a project subparser.""" + parser.add_argument( + "--rot-y-axis-reference", + action="store_true", + help="Convert rotation to y-axis clockwise reference (CenterPoint ONNX-compatible format)", + ) diff --git a/deployment/projects/centerpoint/config/deploy_config.py b/deployment/projects/centerpoint/config/deploy_config.py new file mode 100644 index 000000000..d7e1894cd --- /dev/null +++ b/deployment/projects/centerpoint/config/deploy_config.py @@ -0,0 +1,160 @@ +""" +CenterPoint Deployment Configuration + +NOTE: This file was moved under deployment/projects/centerpoint/config/ as part of the +proposed unified deployment architecture. +""" + +# ============================================================================ +# Task type for pipeline building +# Options: 'detection2d', 'detection3d', 'classification', 'segmentation' +# ============================================================================ +task_type = "detection3d" + +# ============================================================================ +# Checkpoint Path - Single source of truth for PyTorch model +# ============================================================================ +checkpoint_path = "work_dirs/centerpoint/best_checkpoint.pth" + +# ============================================================================ +# Device settings (shared by export, evaluation, verification) +# ============================================================================ +devices = dict( + cpu="cpu", + cuda="cuda:0", +) + +# ============================================================================ +# Export Configuration +# ============================================================================ +export = dict( + mode="none", + work_dir="work_dirs/centerpoint_deployment", + onnx_path=None, +) + +# ============================================================================ +# Runtime I/O settings +# ============================================================================ +runtime_io = dict( + info_file="data/t4dataset/info/t4dataset_j6gen2_infos_val.pkl", + sample_idx=1, +) + +# ============================================================================ +# Model Input/Output Configuration +# ============================================================================ +model_io = dict( + input_name="voxels", + input_shape=(32, 4), + input_dtype="float32", + additional_inputs=[ + dict(name="num_points", shape=(-1,), dtype="int32"), + dict(name="coors", shape=(-1, 4), dtype="int32"), + ], + head_output_names=("heatmap", "reg", "height", "dim", "rot", "vel"), + batch_size=None, + dynamic_axes={ + "voxels": {0: "num_voxels"}, + "num_points": {0: "num_voxels"}, + "coors": {0: "num_voxels"}, + }, +) + +# ============================================================================ +# ONNX Export Configuration +# ============================================================================ +onnx_config = dict( + opset_version=16, + do_constant_folding=True, + export_params=True, + keep_initializers_as_inputs=False, + simplify=False, + multi_file=True, + components=dict( + voxel_encoder=dict( + name="pts_voxel_encoder", + onnx_file="pts_voxel_encoder.onnx", + engine_file="pts_voxel_encoder.engine", + ), + backbone_head=dict( + name="pts_backbone_neck_head", + onnx_file="pts_backbone_neck_head.onnx", + engine_file="pts_backbone_neck_head.engine", + ), + ), +) + +# ============================================================================ +# Backend Configuration (mainly for TensorRT) +# ============================================================================ +backend_config = dict( + common_config=dict( + precision_policy="auto", + max_workspace_size=2 << 30, + ), + model_inputs=[ + dict( + input_shapes=dict( + input_features=dict( + min_shape=[1000, 32, 11], + opt_shape=[20000, 32, 11], + max_shape=[64000, 32, 11], + ), + spatial_features=dict( + min_shape=[1, 32, 760, 760], + opt_shape=[1, 32, 760, 760], + max_shape=[1, 32, 760, 760], + ), + ) + ) + ], +) + +# ============================================================================ +# Evaluation Configuration +# ============================================================================ +evaluation = dict( + enabled=True, + num_samples=1, + verbose=True, + backends=dict( + pytorch=dict( + enabled=True, + device=devices["cuda"], + ), + onnx=dict( + enabled=True, + device=devices["cuda"], + model_dir="work_dirs/centerpoint_deployment/onnx/", + ), + tensorrt=dict( + enabled=True, + device=devices["cuda"], + engine_dir="work_dirs/centerpoint_deployment/tensorrt/", + ), + ), +) + +# ============================================================================ +# Verification Configuration +# ============================================================================ +verification = dict( + enabled=False, + tolerance=1e-1, + num_verify_samples=1, + devices=devices, + scenarios=dict( + both=[ + dict(ref_backend="pytorch", ref_device="cpu", test_backend="onnx", test_device="cpu"), + dict(ref_backend="onnx", ref_device="cuda", test_backend="tensorrt", test_device="cuda"), + ], + onnx=[ + dict(ref_backend="pytorch", ref_device="cpu", test_backend="onnx", test_device="cpu"), + ], + trt=[ + dict(ref_backend="onnx", ref_device="cuda", test_backend="tensorrt", test_device="cuda"), + ], + none=[], + ), +) diff --git a/deployment/projects/centerpoint/data_loader.py b/deployment/projects/centerpoint/data_loader.py new file mode 100644 index 000000000..0c713a3ea --- /dev/null +++ b/deployment/projects/centerpoint/data_loader.py @@ -0,0 +1,200 @@ +""" +CenterPoint DataLoader for deployment. + +Moved from projects/CenterPoint/deploy/data_loader.py into the unified deployment bundle. +""" + +import os +import pickle +from typing import Any, Dict, List, Optional, Union + +import numpy as np +import torch +from mmengine.config import Config + +from deployment.core import BaseDataLoader, build_preprocessing_pipeline + + +class CenterPointDataLoader(BaseDataLoader): + """Deployment dataloader for CenterPoint. + + Responsibilities: + - Load `info_file` (pickle) entries describing samples. + - Build and run the MMEngine preprocessing pipeline for each sample. + - Provide `load_sample()` for export helpers that need raw sample metadata. + """ + + def __init__( + self, + info_file: str, + model_cfg: Config, + device: str = "cpu", + task_type: Optional[str] = None, + ): + super().__init__( + config={ + "info_file": info_file, + "device": device, + } + ) + + if not os.path.exists(info_file): + raise FileNotFoundError(f"Info file not found: {info_file}") + + self.info_file = info_file + self.model_cfg = model_cfg + self.device = device + + self.data_infos = self._load_info_file() + self.pipeline = build_preprocessing_pipeline(model_cfg, task_type=task_type) + + def _to_tensor( + self, + data: Union[torch.Tensor, np.ndarray, List[Union[torch.Tensor, np.ndarray]]], + name: str = "data", + ) -> torch.Tensor: + if isinstance(data, torch.Tensor): + return data.to(self.device) + + if isinstance(data, np.ndarray): + return torch.from_numpy(data).to(self.device) + + if isinstance(data, list): + if len(data) == 0: + raise ValueError(f"Empty list for '{name}' in pipeline output.") + + first_item = data[0] + if isinstance(first_item, torch.Tensor): + return first_item.to(self.device) + if isinstance(first_item, np.ndarray): + return torch.from_numpy(first_item).to(self.device) + + raise ValueError( + f"Unexpected type for {name}[0]: {type(first_item)}. Expected torch.Tensor or np.ndarray." + ) + + raise ValueError( + f"Unexpected type for '{name}': {type(data)}. Expected torch.Tensor, np.ndarray, or list of tensors/arrays." + ) + + def _load_info_file(self) -> list: + try: + with open(self.info_file, "rb") as f: + data = pickle.load(f) + except Exception as e: + raise ValueError(f"Failed to load info file: {e}") from e + + if isinstance(data, dict): + if "data_list" in data: + data_list = data["data_list"] + elif "infos" in data: + data_list = data["infos"] + else: + raise ValueError(f"Expected 'data_list' or 'infos' in info file, found keys: {list(data.keys())}") + elif isinstance(data, list): + data_list = data + else: + raise ValueError(f"Unexpected info file format: {type(data)}") + + if not data_list: + raise ValueError("No samples found in info file") + + return data_list + + def load_sample(self, index: int) -> Dict[str, Any]: + if index >= len(self.data_infos): + raise IndexError(f"Sample index {index} out of range (0-{len(self.data_infos)-1})") + + info = self.data_infos[index] + + lidar_points = info.get("lidar_points", {}) + if not lidar_points: + lidar_path = info.get("lidar_path", info.get("velodyne_path", "")) + lidar_points = {"lidar_path": lidar_path} + + if "lidar_path" in lidar_points and not lidar_points["lidar_path"].startswith("/"): + data_root = getattr(self.model_cfg, "data_root", "data/t4dataset/") + if not data_root.endswith("/"): + data_root += "/" + if not lidar_points["lidar_path"].startswith(data_root): + lidar_points["lidar_path"] = data_root + lidar_points["lidar_path"] + + instances = info.get("instances", []) + + sample = { + "lidar_points": lidar_points, + "sample_idx": info.get("sample_idx", index), + "timestamp": info.get("timestamp", 0), + } + + if instances: + gt_bboxes_3d = [] + gt_labels_3d = [] + + for instance in instances: + if "bbox_3d" in instance and "bbox_label_3d" in instance: + if instance.get("bbox_3d_isvalid", True): + gt_bboxes_3d.append(instance["bbox_3d"]) + gt_labels_3d.append(instance["bbox_label_3d"]) + + if gt_bboxes_3d: + sample["gt_bboxes_3d"] = np.array(gt_bboxes_3d, dtype=np.float32) + sample["gt_labels_3d"] = np.array(gt_labels_3d, dtype=np.int64) + + if "images" in info or "img_path" in info: + sample["images"] = info.get("images", {}) + if "img_path" in info: + sample["img_path"] = info["img_path"] + + return sample + + def preprocess(self, sample: Dict[str, Any]) -> Union[Dict[str, torch.Tensor], torch.Tensor]: + results = self.pipeline(sample) + + if "inputs" not in results: + raise ValueError( + "Expected 'inputs' key in pipeline results (MMDet3D 3.x format). " + f"Found keys: {list(results.keys())}. " + "Please ensure your test pipeline includes Pack3DDetInputs transform." + ) + + pipeline_inputs = results["inputs"] + if "points" not in pipeline_inputs: + available_keys = list(pipeline_inputs.keys()) + raise ValueError( + "Expected 'points' key in pipeline inputs for CenterPoint. " + f"Available keys: {available_keys}. " + "For CenterPoint, voxelization is performed by the model's data_preprocessor." + ) + + points_tensor = self._to_tensor(pipeline_inputs["points"], name="points") + if points_tensor.ndim != 2: + raise ValueError(f"Expected points tensor with shape [N, point_features], got shape {points_tensor.shape}") + + return {"points": points_tensor} + + def get_num_samples(self) -> int: + return len(self.data_infos) + + def get_ground_truth(self, index: int) -> Dict[str, Any]: + sample = self.load_sample(index) + + gt_bboxes_3d = sample.get("gt_bboxes_3d", np.zeros((0, 7), dtype=np.float32)) + gt_labels_3d = sample.get("gt_labels_3d", np.zeros((0,), dtype=np.int64)) + + if isinstance(gt_bboxes_3d, (list, tuple)): + gt_bboxes_3d = np.array(gt_bboxes_3d, dtype=np.float32) + if isinstance(gt_labels_3d, (list, tuple)): + gt_labels_3d = np.array(gt_labels_3d, dtype=np.int64) + + return { + "gt_bboxes_3d": gt_bboxes_3d, + "gt_labels_3d": gt_labels_3d, + "sample_idx": sample.get("sample_idx", index), + } + + def get_class_names(self) -> List[str]: + if hasattr(self.model_cfg, "class_names"): + return self.model_cfg.class_names + + raise ValueError("class_names must be defined in model_cfg.") diff --git a/deployment/projects/centerpoint/entrypoint.py b/deployment/projects/centerpoint/entrypoint.py new file mode 100644 index 000000000..2fd6a7022 --- /dev/null +++ b/deployment/projects/centerpoint/entrypoint.py @@ -0,0 +1,59 @@ +"""CenterPoint deployment entrypoint invoked by the unified CLI.""" + +from __future__ import annotations + +import logging + +from mmengine.config import Config + +from deployment.core.config.base_config import BaseDeploymentConfig, setup_logging +from deployment.core.contexts import CenterPointExportContext +from deployment.core.metrics.detection_3d_metrics import Detection3DMetricsConfig +from deployment.projects.centerpoint.data_loader import CenterPointDataLoader +from deployment.projects.centerpoint.evaluator import CenterPointEvaluator +from deployment.projects.centerpoint.model_loader import extract_t4metric_v2_config +from deployment.projects.centerpoint.runner import CenterPointDeploymentRunner + + +def run(args) -> int: + """Run the CenterPoint deployment workflow for the unified CLI. + + This wires together the CenterPoint bundle components (data loader, evaluator, + runner) and executes export/verification/evaluation according to `deploy_cfg`. + """ + logger = setup_logging(args.log_level) + + deploy_cfg = Config.fromfile(args.deploy_cfg) + model_cfg = Config.fromfile(args.model_cfg) + config = BaseDeploymentConfig(deploy_cfg) + + logger.info("=" * 80) + logger.info("CenterPoint Deployment Pipeline (Unified CLI)") + logger.info("=" * 80) + + data_loader = CenterPointDataLoader( + info_file=config.runtime_config.info_file, + model_cfg=model_cfg, + device="cpu", + task_type=config.task_type, + ) + logger.info(f"Loaded {data_loader.get_num_samples()} samples") + + metrics_config = extract_t4metric_v2_config(model_cfg, logger=logger) + + evaluator = CenterPointEvaluator( + model_cfg=model_cfg, + metrics_config=metrics_config, + ) + + runner = CenterPointDeploymentRunner( + data_loader=data_loader, + evaluator=evaluator, + config=config, + model_cfg=model_cfg, + logger=logger, + ) + + context = CenterPointExportContext(rot_y_axis_reference=bool(getattr(args, "rot_y_axis_reference", False))) + runner.run(context=context) + return 0 diff --git a/deployment/projects/centerpoint/evaluator.py b/deployment/projects/centerpoint/evaluator.py new file mode 100644 index 000000000..60f1df90e --- /dev/null +++ b/deployment/projects/centerpoint/evaluator.py @@ -0,0 +1,172 @@ +""" +CenterPoint Evaluator for deployment. + +Moved from projects/CenterPoint/deploy/evaluator.py into the unified deployment bundle. +""" + +import logging +from typing import Any, Dict, List, Tuple + +from mmengine.config import Config + +from deployment.core import ( + BaseEvaluator, + Detection3DMetricsConfig, + Detection3DMetricsInterface, + EvalResultDict, + ModelSpec, + TaskProfile, +) +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.pipelines.factory import PipelineFactory +from deployment.projects.centerpoint.config.deploy_config import model_io + +logger = logging.getLogger(__name__) + + +class CenterPointEvaluator(BaseEvaluator): + """Evaluator implementation for CenterPoint 3D detection. + + This builds a task profile (class names, display name) and uses the configured + `Detection3DMetricsInterface` to compute metrics from pipeline outputs. + """ + + def __init__( + self, + model_cfg: Config, + metrics_config: Detection3DMetricsConfig, + ): + if hasattr(model_cfg, "class_names"): + class_names = model_cfg.class_names + else: + raise ValueError("class_names must be provided via model_cfg.class_names.") + + task_profile = TaskProfile( + task_name="centerpoint_3d_detection", + display_name="CenterPoint 3D Object Detection", + class_names=tuple(class_names), + num_classes=len(class_names), + ) + + metrics_interface = Detection3DMetricsInterface(metrics_config) + + super().__init__( + metrics_interface=metrics_interface, + task_profile=task_profile, + model_cfg=model_cfg, + ) + + def set_onnx_config(self, model_cfg: Config) -> None: + self.model_cfg = model_cfg + + def _get_output_names(self) -> List[str]: + return list(model_io["head_output_names"]) + + def _create_pipeline(self, model_spec: ModelSpec, device: str) -> Any: + return PipelineFactory.create( + project_name="centerpoint", + model_spec=model_spec, + pytorch_model=self.pytorch_model, + device=device, + ) + + def _prepare_input( + self, + sample: Dict[str, Any], + data_loader: BaseDataLoader, + device: str, + ) -> Tuple[Any, Dict[str, Any]]: + if "points" in sample: + points = sample["points"] + else: + input_data = data_loader.preprocess(sample) + points = input_data.get("points", input_data) + + metadata = sample.get("metainfo", {}) + return points, metadata + + def _parse_predictions(self, pipeline_output: Any) -> List[Dict]: + return pipeline_output if isinstance(pipeline_output, list) else [] + + def _parse_ground_truths(self, gt_data: Dict[str, Any]) -> List[Dict]: + ground_truths = [] + + if "gt_bboxes_3d" in gt_data and "gt_labels_3d" in gt_data: + gt_bboxes_3d = gt_data["gt_bboxes_3d"] + gt_labels_3d = gt_data["gt_labels_3d"] + + for i in range(len(gt_bboxes_3d)): + ground_truths.append({"bbox_3d": gt_bboxes_3d[i].tolist(), "label": int(gt_labels_3d[i])}) + + return ground_truths + + def _add_to_interface(self, predictions: List[Dict], ground_truths: List[Dict]) -> None: + self.metrics_interface.add_frame(predictions, ground_truths) + + def _build_results( + self, + latencies: List[float], + latency_breakdowns: List[Dict[str, float]], + num_samples: int, + ) -> EvalResultDict: + latency_stats = self.compute_latency_stats(latencies) + latency_payload = latency_stats.to_dict() + + if latency_breakdowns: + latency_payload["latency_breakdown"] = self._compute_latency_breakdown(latency_breakdowns).to_dict() + + map_results = self.metrics_interface.compute_metrics() + summary = self.metrics_interface.get_summary() + summary_dict = summary.to_dict() if hasattr(summary, "to_dict") else summary + + return { + "mAP": summary_dict.get("mAP", 0.0), + "mAPH": summary_dict.get("mAPH", 0.0), + "per_class_ap": summary_dict.get("per_class_ap", {}), + "detailed_metrics": map_results, + "latency": latency_payload, + "num_samples": num_samples, + } + + def print_results(self, results: EvalResultDict) -> None: + print("\n" + "=" * 80) + print(f"{self.task_profile.display_name} - Evaluation Results") + print("(Using autoware_perception_evaluation for consistent metrics)") + print("=" * 80) + + print("\nDetection Metrics:") + print(f" mAP: {results.get('mAP', 0.0):.4f}") + print(f" mAPH: {results.get('mAPH', 0.0):.4f}") + + if "per_class_ap" in results: + print("\nPer-Class AP:") + for class_id, ap in results["per_class_ap"].items(): + class_name = ( + class_id + if isinstance(class_id, str) + else (self.class_names[class_id] if class_id < len(self.class_names) else f"class_{class_id}") + ) + print(f" {class_name:<12}: {ap:.4f}") + + if "latency" in results: + latency = results["latency"] + print("\nLatency Statistics:") + print(f" Mean: {latency['mean_ms']:.2f} ms") + print(f" Std: {latency['std_ms']:.2f} ms") + print(f" Min: {latency['min_ms']:.2f} ms") + print(f" Max: {latency['max_ms']:.2f} ms") + print(f" Median: {latency['median_ms']:.2f} ms") + + if "latency_breakdown" in latency: + breakdown = latency["latency_breakdown"] + print("\nStage-wise Latency Breakdown:") + model_substages = {"voxel_encoder_ms", "middle_encoder_ms", "backbone_head_ms"} + for stage, stats in breakdown.items(): + stage_name = stage.replace("_ms", "").replace("_", " ").title() + if stage in model_substages: + print(f" {stage_name:16s}: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms") + else: + print(f" {stage_name:18s}: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms") + + print(f"\nTotal Samples: {results.get('num_samples', 0)}") + print("=" * 80) diff --git a/deployment/projects/centerpoint/export/component_extractor.py b/deployment/projects/centerpoint/export/component_extractor.py new file mode 100644 index 000000000..5c5aed120 --- /dev/null +++ b/deployment/projects/centerpoint/export/component_extractor.py @@ -0,0 +1,120 @@ +""" +CenterPoint-specific component extractor. + +Moved from projects/CenterPoint/deploy/component_extractor.py into the unified deployment bundle. +""" + +import logging +from typing import Any, List, Tuple + +import torch + +from deployment.exporters.common.configs import ONNXExportConfig +from deployment.exporters.export_pipelines.interfaces import ExportableComponent, ModelComponentExtractor +from deployment.projects.centerpoint.config.deploy_config import model_io, onnx_config +from deployment.projects.centerpoint.onnx_models.centerpoint_onnx import CenterPointHeadONNX + +logger = logging.getLogger(__name__) + + +class CenterPointComponentExtractor(ModelComponentExtractor): + """Extract exportable CenterPoint submodules for multi-file ONNX export. + + For CenterPoint we export two components: + - `voxel_encoder` (pts_voxel_encoder) + - `backbone_neck_head` (pts_backbone + pts_neck + pts_bbox_head) + """ + + def __init__(self, logger: logging.Logger = None, simplify: bool = True): + self.logger = logger or logging.getLogger(__name__) + self.simplify = simplify + + def extract_components(self, model: torch.nn.Module, sample_data: Any) -> List[ExportableComponent]: + input_features, voxel_dict = sample_data + self.logger.info("Extracting CenterPoint components for export...") + + voxel_component = self._create_voxel_encoder_component(model, input_features) + backbone_component = self._create_backbone_component(model, input_features, voxel_dict) + + self.logger.info("Extracted 2 components: voxel_encoder, backbone_neck_head") + return [voxel_component, backbone_component] + + def _create_voxel_encoder_component( + self, model: torch.nn.Module, input_features: torch.Tensor + ) -> ExportableComponent: + voxel_cfg = onnx_config["components"]["voxel_encoder"] + return ExportableComponent( + name=voxel_cfg["name"], + module=model.pts_voxel_encoder, + sample_input=input_features, + config_override=ONNXExportConfig( + input_names=("input_features",), + output_names=("pillar_features",), + dynamic_axes={ + "input_features": {0: "num_voxels", 1: "num_max_points"}, + "pillar_features": {0: "num_voxels"}, + }, + opset_version=16, + do_constant_folding=True, + simplify=self.simplify, + save_file=voxel_cfg["onnx_file"], + ), + ) + + def _create_backbone_component( + self, model: torch.nn.Module, input_features: torch.Tensor, voxel_dict: dict + ) -> ExportableComponent: + backbone_input = self._prepare_backbone_input(model, input_features, voxel_dict) + backbone_module = self._create_backbone_module(model) + output_names = self._get_output_names(model) + + dynamic_axes = { + "spatial_features": {0: "batch_size", 2: "height", 3: "width"}, + } + for name in output_names: + dynamic_axes[name] = {0: "batch_size", 2: "height", 3: "width"} + + backbone_cfg = onnx_config["components"]["backbone_head"] + return ExportableComponent( + name=backbone_cfg["name"], + module=backbone_module, + sample_input=backbone_input, + config_override=ONNXExportConfig( + input_names=("spatial_features",), + output_names=output_names, + dynamic_axes=dynamic_axes, + opset_version=16, + do_constant_folding=True, + simplify=self.simplify, + save_file=backbone_cfg["onnx_file"], + ), + ) + + def _prepare_backbone_input( + self, model: torch.nn.Module, input_features: torch.Tensor, voxel_dict: dict + ) -> torch.Tensor: + with torch.no_grad(): + voxel_features = model.pts_voxel_encoder(input_features).squeeze(1) + coors = voxel_dict["coors"] + batch_size = int(coors[-1, 0].item()) + 1 if len(coors) > 0 else 1 + spatial_features = model.pts_middle_encoder(voxel_features, coors, batch_size) + return spatial_features + + def _create_backbone_module(self, model: torch.nn.Module) -> torch.nn.Module: + return CenterPointHeadONNX(model.pts_backbone, model.pts_neck, model.pts_bbox_head) + + def _get_output_names(self, model: torch.nn.Module) -> Tuple[str, ...]: + if hasattr(model, "pts_bbox_head") and hasattr(model.pts_bbox_head, "output_names"): + output_names = model.pts_bbox_head.output_names + if isinstance(output_names, (list, tuple)): + return tuple(output_names) + return (output_names,) + return model_io["head_output_names"] + + def extract_features(self, model: torch.nn.Module, data_loader: Any, sample_idx: int) -> Tuple[torch.Tensor, dict]: + if hasattr(model, "_extract_features"): + return model._extract_features(data_loader, sample_idx) + raise AttributeError( + "CenterPoint model must have _extract_features method for ONNX export. " + "Please ensure the model is built with ONNX compatibility." + ) diff --git a/deployment/projects/centerpoint/export/onnx_export_pipeline.py b/deployment/projects/centerpoint/export/onnx_export_pipeline.py new file mode 100644 index 000000000..6938336b7 --- /dev/null +++ b/deployment/projects/centerpoint/export/onnx_export_pipeline.py @@ -0,0 +1,128 @@ +""" +CenterPoint ONNX export pipeline using composition. + +Moved from deployment/exporters/centerpoint/onnx_export_pipeline.py into the CenterPoint deployment bundle. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Iterable, Optional, Tuple + +import torch + +from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig +from deployment.exporters.common.factory import ExporterFactory +from deployment.exporters.common.model_wrappers import IdentityWrapper +from deployment.exporters.export_pipelines.base import OnnxExportPipeline +from deployment.exporters.export_pipelines.interfaces import ExportableComponent, ModelComponentExtractor + + +class CenterPointONNXExportPipeline(OnnxExportPipeline): + """ONNX export pipeline for CenterPoint (multi-file export). + + Uses a `ModelComponentExtractor` to split the model into exportable components + and exports each with the configured ONNX exporter. + """ + + def __init__( + self, + exporter_factory: type[ExporterFactory], + component_extractor: ModelComponentExtractor, + config: BaseDeploymentConfig, + logger: Optional[logging.Logger] = None, + ): + self.exporter_factory = exporter_factory + self.component_extractor = component_extractor + self.config = config + self.logger = logger or logging.getLogger(__name__) + + def export( + self, + *, + model: torch.nn.Module, + data_loader: BaseDataLoader, + output_dir: str, + config: BaseDeploymentConfig, + sample_idx: int = 0, + ) -> Artifact: + output_dir_path = Path(output_dir) + output_dir_path.mkdir(parents=True, exist_ok=True) + + self._log_header(output_dir_path, sample_idx) + sample_data = self._extract_sample_data(model, data_loader, sample_idx) + components = self.component_extractor.extract_components(model, sample_data) + + exported_paths = self._export_components(components, output_dir_path) + self._log_summary(exported_paths) + + return Artifact(path=str(output_dir_path), multi_file=True) + + def _log_header(self, output_dir: Path, sample_idx: int) -> None: + self.logger.info("=" * 80) + self.logger.info("Exporting CenterPoint to ONNX (multi-file)") + self.logger.info("=" * 80) + self.logger.info(f"Output directory: {output_dir}") + self.logger.info(f"Using sample index: {sample_idx}") + + def _extract_sample_data( + self, + model: torch.nn.Module, + data_loader: BaseDataLoader, + sample_idx: int, + ) -> Tuple[torch.Tensor, dict]: + if not hasattr(self.component_extractor, "extract_features"): + raise AttributeError("Component extractor must provide extract_features method") + + self.logger.info("Extracting features from sample data...") + try: + return self.component_extractor.extract_features(model, data_loader, sample_idx) + except Exception as exc: + self.logger.error("Failed to extract features", exc_info=exc) + raise RuntimeError("Feature extraction failed") from exc + + def _export_components( + self, + components: Iterable[ExportableComponent], + output_dir: Path, + ) -> Tuple[str, ...]: + exported_paths: list[str] = [] + component_list = list(components) + total = len(component_list) + + for index, component in enumerate(component_list, start=1): + self.logger.info(f"\n[{index}/{total}] Exporting {component.name}...") + exporter = self._build_onnx_exporter() + output_path = output_dir / f"{component.name}.onnx" + + try: + exporter.export( + model=component.module, + sample_input=component.sample_input, + output_path=str(output_path), + config_override=component.config_override, + ) + except Exception as exc: + self.logger.error(f"Failed to export {component.name}", exc_info=exc) + raise RuntimeError(f"{component.name} export failed") from exc + + exported_paths.append(str(output_path)) + self.logger.info(f"Exported {component.name}: {output_path}") + + return tuple(exported_paths) + + def _build_onnx_exporter(self): + return self.exporter_factory.create_onnx_exporter( + config=self.config, + wrapper_cls=IdentityWrapper, + logger=self.logger, + ) + + def _log_summary(self, exported_paths: Tuple[str, ...]) -> None: + self.logger.info("\n" + "=" * 80) + self.logger.info("CenterPoint ONNX export successful") + self.logger.info("=" * 80) + for path in exported_paths: + self.logger.info(f" • {os.path.basename(path)}") diff --git a/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py b/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py new file mode 100644 index 000000000..d390371b0 --- /dev/null +++ b/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py @@ -0,0 +1,103 @@ +""" +CenterPoint TensorRT export pipeline using composition. + +Moved from deployment/exporters/centerpoint/tensorrt_export_pipeline.py into the CenterPoint deployment bundle. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import List, Optional + +import torch + +from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig +from deployment.exporters.common.factory import ExporterFactory +from deployment.exporters.export_pipelines.base import TensorRTExportPipeline + + +class CenterPointTensorRTExportPipeline(TensorRTExportPipeline): + """TensorRT export pipeline for CenterPoint. + + Consumes a directory of ONNX files (multi-file export) and builds a TensorRT + engine per component into `output_dir`. + """ + + _CUDA_DEVICE_PATTERN = re.compile(r"^cuda:\d+$") + + def __init__( + self, + exporter_factory: type[ExporterFactory], + config: BaseDeploymentConfig, + logger: Optional[logging.Logger] = None, + ): + self.exporter_factory = exporter_factory + self.config = config + self.logger = logger or logging.getLogger(__name__) + + def _validate_cuda_device(self, device: str) -> int: + if not self._CUDA_DEVICE_PATTERN.match(device): + raise ValueError( + f"Invalid CUDA device format: '{device}'. Expected format: 'cuda:N' (e.g., 'cuda:0', 'cuda:1')" + ) + return int(device.split(":")[1]) + + def export( + self, + *, + onnx_path: str, + output_dir: str, + config: BaseDeploymentConfig, + device: str, + data_loader: BaseDataLoader, + ) -> Artifact: + onnx_dir = onnx_path + + if device is None: + raise ValueError("CUDA device must be provided for TensorRT export") + if onnx_dir is None: + raise ValueError("onnx_dir must be provided for CenterPoint TensorRT export") + + onnx_dir_path = Path(onnx_dir) + if not onnx_dir_path.is_dir(): + raise ValueError(f"onnx_path must be a directory for multi-file export, got: {onnx_dir}") + + device_id = self._validate_cuda_device(device) + torch.cuda.set_device(device_id) + self.logger.info(f"Using CUDA device: {device}") + + output_dir_path = Path(output_dir) + output_dir_path.mkdir(parents=True, exist_ok=True) + + onnx_files = self._discover_onnx_files(onnx_dir_path) + if not onnx_files: + raise FileNotFoundError(f"No ONNX files found in {onnx_dir_path}") + + num_files = len(onnx_files) + for i, onnx_file in enumerate(onnx_files, 1): + trt_path = output_dir_path / f"{onnx_file.stem}.engine" + + self.logger.info(f"\n[{i}/{num_files}] Converting {onnx_file.name} → {trt_path.name}...") + exporter = self._build_tensorrt_exporter() + + artifact = exporter.export( + model=None, + sample_input=None, + output_path=str(trt_path), + onnx_path=str(onnx_file), + ) + self.logger.info(f"TensorRT engine saved: {artifact.path}") + + self.logger.info(f"\nAll TensorRT engines exported successfully to {output_dir_path}") + return Artifact(path=str(output_dir_path), multi_file=True) + + def _discover_onnx_files(self, onnx_dir: Path) -> List[Path]: + return sorted( + (path for path in onnx_dir.iterdir() if path.is_file() and path.suffix.lower() == ".onnx"), + key=lambda p: p.name, + ) + + def _build_tensorrt_exporter(self): + return self.exporter_factory.create_tensorrt_exporter(config=self.config, logger=self.logger) diff --git a/deployment/projects/centerpoint/model_loader.py b/deployment/projects/centerpoint/model_loader.py new file mode 100644 index 000000000..f48558e63 --- /dev/null +++ b/deployment/projects/centerpoint/model_loader.py @@ -0,0 +1,144 @@ +""" +CenterPoint deployment utilities: ONNX-compatible model building and metrics config extraction. + +Moved from projects/CenterPoint/deploy/utils.py into the unified deployment bundle. +""" + +import copy +import logging +from typing import List, Optional, Tuple + +import torch +from mmengine.config import Config +from mmengine.registry import MODELS, init_default_scope +from mmengine.runner import load_checkpoint + +from deployment.core.metrics.detection_3d_metrics import Detection3DMetricsConfig +from deployment.projects.centerpoint.onnx_models import register_models + + +def create_onnx_model_cfg( + model_cfg: Config, + device: str, + rot_y_axis_reference: bool = False, +) -> Config: + """Create a model config that swaps modules to ONNX-friendly variants. + + This mutates the `model_cfg.model` subtree to reference classes registered by + `deployment.projects.centerpoint.onnx_models` (e.g., `CenterPointONNX`). + """ + onnx_cfg = model_cfg.copy() + model_config = copy.deepcopy(onnx_cfg.model) + + model_config.type = "CenterPointONNX" + model_config.point_channels = model_config.pts_voxel_encoder.in_channels + model_config.device = device + + if model_config.pts_voxel_encoder.type == "PillarFeatureNet": + model_config.pts_voxel_encoder.type = "PillarFeatureNetONNX" + elif model_config.pts_voxel_encoder.type == "BackwardPillarFeatureNet": + model_config.pts_voxel_encoder.type = "BackwardPillarFeatureNetONNX" + + model_config.pts_bbox_head.type = "CenterHeadONNX" + model_config.pts_bbox_head.separate_head.type = "SeparateHeadONNX" + model_config.pts_bbox_head.rot_y_axis_reference = rot_y_axis_reference + + if ( + getattr(model_config, "pts_backbone", None) + and getattr(model_config.pts_backbone, "type", None) == "ConvNeXt_PC" + ): + model_config.pts_backbone.with_cp = False + + onnx_cfg.model = model_config + return onnx_cfg + + +def build_model_from_cfg(model_cfg: Config, checkpoint_path: str, device: str) -> torch.nn.Module: + """Build a model from MMEngine config and load checkpoint weights.""" + # Ensure CenterPoint ONNX variants are registered into MODELS before building. + # This is required because the config uses string types like "CenterPointONNX", "CenterHeadONNX", etc. + register_models() + init_default_scope("mmdet3d") + model_config = copy.deepcopy(model_cfg.model) + model = MODELS.build(model_config) + model.to(device) + load_checkpoint(model, checkpoint_path, map_location=device) + model.eval() + model.cfg = model_cfg + return model + + +def build_centerpoint_onnx_model( + base_model_cfg: Config, + checkpoint_path: str, + device: str, + rot_y_axis_reference: bool = False, +) -> Tuple[torch.nn.Module, Config]: + """Convenience wrapper to build an ONNX-compatible CenterPoint model + cfg.""" + onnx_cfg = create_onnx_model_cfg( + base_model_cfg, + device=device, + rot_y_axis_reference=rot_y_axis_reference, + ) + model = build_model_from_cfg(onnx_cfg, checkpoint_path, device=device) + return model, onnx_cfg + + +def extract_t4metric_v2_config( + model_cfg: Config, + class_names: Optional[List[str]] = None, + logger: Optional[logging.Logger] = None, +) -> Detection3DMetricsConfig: + """Extract `Detection3DMetricsConfig` from an MMEngine model config. + + Expects the config to contain a `T4MetricV2` evaluator (val or test). + """ + if logger is None: + logger = logging.getLogger(__name__) + + if class_names is None: + if hasattr(model_cfg, "class_names"): + class_names = model_cfg.class_names + else: + raise ValueError("class_names must be provided or defined in model_cfg.class_names") + + evaluator_cfg = None + if hasattr(model_cfg, "val_evaluator"): + evaluator_cfg = model_cfg.val_evaluator + elif hasattr(model_cfg, "test_evaluator"): + evaluator_cfg = model_cfg.test_evaluator + else: + raise ValueError("No val_evaluator or test_evaluator found in model_cfg") + + def get_cfg_value(cfg, key, default=None): + if cfg is None: + return default + if isinstance(cfg, dict): + return cfg.get(key, default) + return getattr(cfg, key, default) + + evaluator_type = get_cfg_value(evaluator_cfg, "type") + if evaluator_type != "T4MetricV2": + raise ValueError(f"Evaluator type is '{evaluator_type}', not 'T4MetricV2'") + + perception_configs = get_cfg_value(evaluator_cfg, "perception_evaluator_configs", {}) + evaluation_config_dict = get_cfg_value(perception_configs, "evaluation_config_dict") + frame_id = get_cfg_value(perception_configs, "frame_id", "base_link") + + critical_object_filter_config = get_cfg_value(evaluator_cfg, "critical_object_filter_config") + frame_pass_fail_config = get_cfg_value(evaluator_cfg, "frame_pass_fail_config") + + if evaluation_config_dict and hasattr(evaluation_config_dict, "to_dict"): + evaluation_config_dict = dict(evaluation_config_dict) + if critical_object_filter_config and hasattr(critical_object_filter_config, "to_dict"): + critical_object_filter_config = dict(critical_object_filter_config) + if frame_pass_fail_config and hasattr(frame_pass_fail_config, "to_dict"): + frame_pass_fail_config = dict(frame_pass_fail_config) + + return Detection3DMetricsConfig( + class_names=class_names, + frame_id=frame_id, + evaluation_config_dict=evaluation_config_dict, + critical_object_filter_config=critical_object_filter_config, + frame_pass_fail_config=frame_pass_fail_config, + ) diff --git a/deployment/projects/centerpoint/onnx_models/__init__.py b/deployment/projects/centerpoint/onnx_models/__init__.py new file mode 100644 index 000000000..94e04a288 --- /dev/null +++ b/deployment/projects/centerpoint/onnx_models/__init__.py @@ -0,0 +1,28 @@ +"""CenterPoint deploy-only ONNX model definitions. + +These modules exist to support ONNX export / ONNX-friendly execution graphs. +They are registered into MMEngine's `MODELS` registry via import side-effects +(`@MODELS.register_module()`). + +Important: +- Call `register_models()` before building models that reference types like + "CenterPointONNX", "CenterHeadONNX", "SeparateHeadONNX", + "PillarFeatureNetONNX", "BackwardPillarFeatureNetONNX". +""" + +from __future__ import annotations + + +def register_models() -> None: + """Register CenterPoint ONNX model variants into MMEngine's `MODELS` registry. + + The underlying modules use `@MODELS.register_module()`; importing them is enough + to register the types referenced by config strings (e.g., `CenterPointONNX`). + """ + # Importing modules triggers `@MODELS.register_module()` registrations. + from deployment.projects.centerpoint.onnx_models import centerpoint_head_onnx as _ # noqa: F401 + from deployment.projects.centerpoint.onnx_models import centerpoint_onnx as _ # noqa: F401 + from deployment.projects.centerpoint.onnx_models import pillar_encoder_onnx as _ # noqa: F401 + + +__all__ = ["register_models"] diff --git a/projects/CenterPoint/models/dense_heads/centerpoint_head_onnx.py b/deployment/projects/centerpoint/onnx_models/centerpoint_head_onnx.py similarity index 95% rename from projects/CenterPoint/models/dense_heads/centerpoint_head_onnx.py rename to deployment/projects/centerpoint/onnx_models/centerpoint_head_onnx.py index c12df19cd..ee2a85491 100644 --- a/projects/CenterPoint/models/dense_heads/centerpoint_head_onnx.py +++ b/deployment/projects/centerpoint/onnx_models/centerpoint_head_onnx.py @@ -1,3 +1,9 @@ +"""CenterPoint deploy-only ONNX head variants. + +These heads adjust output ordering and forward behavior to improve ONNX export +and downstream inference compatibility. +""" + from typing import Dict, List, Tuple import torch @@ -5,7 +11,7 @@ from mmdet3d.registry import MODELS from mmengine.logging import MMLogger -from .centerpoint_head import CenterHead +from projects.CenterPoint.models.dense_heads.centerpoint_head import CenterHead @MODELS.register_module() diff --git a/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py b/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py new file mode 100644 index 000000000..ec83124d6 --- /dev/null +++ b/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py @@ -0,0 +1,147 @@ +"""CenterPoint deploy-only ONNX model variants. + +These modules provide ONNX-friendly model wrappers and detector variants used by +the deployment/export pipeline (not training). +""" + +import os +from typing import Dict, List, Tuple + +import numpy as np +import torch +from mmdet3d.models.detectors.centerpoint import CenterPoint +from mmdet3d.registry import MODELS +from mmengine.logging import MMLogger +from torch import nn + + +class CenterPointHeadONNX(nn.Module): + """Head module for centerpoint with BACKBONE, NECK and BBOX_HEAD""" + + def __init__(self, backbone: nn.Module, neck: nn.Module, bbox_head: nn.Module): + super(CenterPointHeadONNX, self).__init__() + self.backbone: nn.Module = backbone + self.neck: nn.Module = neck + self.bbox_head: nn.Module = bbox_head + self._logger = MMLogger.get_current_instance() + self._logger.info("Running CenterPointHeadONNX!") + + def forward(self, x: torch.Tensor) -> Tuple[List[Dict[str, torch.Tensor]]]: + """ + Note: + torch.onnx.export() doesn't support triple-nested output + + Args: + x (torch.Tensor): (B, C, H, W) + Returns: + tuple[list[dict[str, any]]]: + (num_classes x [num_detect x {'reg', 'height', 'dim', 'rot', 'vel', 'heatmap'}]) + """ + x = self.backbone(x) + if self.neck is not None: + x = self.neck(x) + x = self.bbox_head(x) + + return x + + +@MODELS.register_module() +class CenterPointONNX(CenterPoint): + """onnx support impl of mmdet3d.models.detectors.CenterPoint""" + + def __init__(self, point_channels: int = 5, device: str = "cpu", **kwargs): + super().__init__(**kwargs) + self._point_channels = point_channels + self._device = device + # Handle both "cuda:0" and "gpu" device strings + if self._device.startswith("cuda") or self._device == "gpu": + self._torch_device = torch.device(self._device if self._device.startswith("cuda") else "cuda:0") + else: + self._torch_device = torch.device("cpu") + self._logger = MMLogger.get_current_instance() + self._logger.info("Running CenterPointONNX!") + + def _get_inputs(self, data_loader, sample_idx=0): + """ + Generate inputs from the provided data loader. + + Args: + data_loader: Loader that implements ``load_sample``. + sample_idx: Index of the sample to fetch. + """ + if data_loader is None: + raise ValueError("data_loader is required for CenterPoint ONNX export") + + if not hasattr(data_loader, "load_sample"): + raise AttributeError("data_loader must implement 'load_sample(sample_idx)'") + + sample = data_loader.load_sample(sample_idx) + + if "lidar_points" not in sample: + raise KeyError("Sample does not contain 'lidar_points'") + + lidar_path = sample["lidar_points"].get("lidar_path") + if not lidar_path: + raise ValueError("Sample must provide 'lidar_path' inside 'lidar_points'") + + if not os.path.exists(lidar_path): + raise FileNotFoundError(f"Lidar path not found: {lidar_path}") + + points = self._load_point_cloud(lidar_path) + points = torch.from_numpy(points).to(self._torch_device) + points = [points] + return {"points": points, "data_samples": None} + + def _load_point_cloud(self, lidar_path: str) -> np.ndarray: + """ + Load point cloud from file. + + Args: + lidar_path: Path to point cloud file (.bin or .pcd) + + Returns: + Point cloud array (N, 5) where 5 = (x, y, z, intensity, ring_id) + """ + if lidar_path.endswith(".bin"): + # Load binary point cloud (KITTI/nuScenes format) + # T4 dataset has 5 features: x, y, z, intensity, ring_id + points = np.fromfile(lidar_path, dtype=np.float32).reshape(-1, 5) + + # Don't pad here - let the voxelization process handle feature expansion + # The voxelization process will add cluster_center (+3) and voxel_center (+3) features + # So 5 + 3 + 3 = 11 features total + + elif lidar_path.endswith(".pcd"): + # Load PCD format (placeholder - would need pypcd or similar) + raise NotImplementedError("PCD format loading not implemented yet") + else: + raise ValueError(f"Unsupported point cloud format: {lidar_path}") + + return points + + def _extract_features(self, data_loader, sample_idx=0): + """ + Extract features using samples from the provided data loader. + """ + if data_loader is None: + raise ValueError("data_loader is required to extract features") + + assert self.data_preprocessor is not None and hasattr(self.data_preprocessor, "voxelize") + + # Ensure data preprocessor is on the correct device + if hasattr(self.data_preprocessor, "to"): + self.data_preprocessor.to(self._torch_device) + + inputs = self._get_inputs(data_loader, sample_idx) + voxel_dict = self.data_preprocessor.voxelize(points=inputs["points"], data_samples=inputs["data_samples"]) + + # Ensure all voxel tensors are on the correct device + for key in ["voxels", "num_points", "coors"]: + if key in voxel_dict and isinstance(voxel_dict[key], torch.Tensor): + voxel_dict[key] = voxel_dict[key].to(self._torch_device) + + assert self.pts_voxel_encoder is not None and hasattr(self.pts_voxel_encoder, "get_input_features") + input_features = self.pts_voxel_encoder.get_input_features( + voxel_dict["voxels"], voxel_dict["num_points"], voxel_dict["coors"] + ) + return input_features, voxel_dict diff --git a/projects/CenterPoint/models/voxel_encoders/pillar_encoder_onnx.py b/deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py similarity index 96% rename from projects/CenterPoint/models/voxel_encoders/pillar_encoder_onnx.py rename to deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py index 73ab10d90..45f81bbde 100644 --- a/projects/CenterPoint/models/voxel_encoders/pillar_encoder_onnx.py +++ b/deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py @@ -1,3 +1,9 @@ +"""CenterPoint deploy-only ONNX voxel encoder variants. + +These variants expose helper APIs and forward shapes that are friendlier for ONNX export +and componentized inference pipelines. +""" + from typing import Optional import torch @@ -7,7 +13,7 @@ from mmengine.logging import MMLogger from torch import Tensor -from .pillar_encoder import BackwardPillarFeatureNet +from projects.CenterPoint.models.voxel_encoders.pillar_encoder import BackwardPillarFeatureNet @MODELS.register_module() diff --git a/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py b/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py new file mode 100644 index 000000000..31015d625 --- /dev/null +++ b/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py @@ -0,0 +1,157 @@ +""" +CenterPoint Deployment Pipeline Base Class. + +Moved from deployment/pipelines/centerpoint/centerpoint_pipeline.py into the CenterPoint deployment bundle. +""" + +import logging +import time +from abc import abstractmethod +from typing import Dict, List, Tuple + +import torch +from mmdet3d.structures import Det3DDataSample, LiDARInstance3DBoxes + +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class CenterPointDeploymentPipeline(BaseDeploymentPipeline): + """Base pipeline for CenterPoint staged inference. + + This normalizes preprocessing/postprocessing for CenterPoint and provides + common helpers (e.g., middle encoder processing) used by PyTorch/ONNX/TensorRT + backend-specific pipelines. + """ + + def __init__(self, pytorch_model, device: str = "cuda", backend_type: str = "unknown"): + cfg = getattr(pytorch_model, "cfg", None) + + class_names = getattr(cfg, "class_names", None) + if class_names is None: + raise ValueError("class_names not found in pytorch_model.cfg") + + point_cloud_range = getattr(cfg, "point_cloud_range", None) + voxel_size = getattr(cfg, "voxel_size", None) + + super().__init__( + model=pytorch_model, + device=device, + task_type="detection3d", + backend_type=backend_type, + ) + + self.num_classes = len(class_names) + self.class_names = class_names + self.point_cloud_range = point_cloud_range + self.voxel_size = voxel_size + self.pytorch_model = pytorch_model + self._stage_latencies = {} + + def preprocess(self, points: torch.Tensor, **kwargs) -> Tuple[Dict[str, torch.Tensor], Dict]: + points_tensor = points.to(self.device) + + data_samples = [Det3DDataSample()] + with torch.no_grad(): + batch_inputs = self.pytorch_model.data_preprocessor( + {"inputs": {"points": [points_tensor]}, "data_samples": data_samples} + ) + + voxel_dict = batch_inputs["inputs"]["voxels"] + voxels = voxel_dict["voxels"] + num_points = voxel_dict["num_points"] + coors = voxel_dict["coors"] + + input_features = None + with torch.no_grad(): + if hasattr(self.pytorch_model.pts_voxel_encoder, "get_input_features"): + input_features = self.pytorch_model.pts_voxel_encoder.get_input_features(voxels, num_points, coors) + + preprocessed_dict = { + "input_features": input_features, + "voxels": voxels, + "num_points": num_points, + "coors": coors, + } + + return preprocessed_dict, {} + + def process_middle_encoder(self, voxel_features: torch.Tensor, coors: torch.Tensor) -> torch.Tensor: + voxel_features = voxel_features.to(self.device) + coors = coors.to(self.device) + batch_size = int(coors[-1, 0].item()) + 1 if len(coors) > 0 else 1 + with torch.no_grad(): + spatial_features = self.pytorch_model.pts_middle_encoder(voxel_features, coors, batch_size) + return spatial_features + + def postprocess(self, head_outputs: List[torch.Tensor], sample_meta: Dict) -> List[Dict]: + head_outputs = [out.to(self.device) for out in head_outputs] + if len(head_outputs) != 6: + raise ValueError(f"Expected 6 head outputs, got {len(head_outputs)}") + + heatmap, reg, height, dim, rot, vel = head_outputs + + if hasattr(self.pytorch_model, "pts_bbox_head"): + rot_y_axis_reference = getattr(self.pytorch_model.pts_bbox_head, "_rot_y_axis_reference", False) + if rot_y_axis_reference: + dim = dim[:, [1, 0, 2], :, :] + rot = rot * (-1.0) + rot = rot[:, [1, 0], :, :] + + preds_dict = {"heatmap": heatmap, "reg": reg, "height": height, "dim": dim, "rot": rot, "vel": vel} + preds_dicts = ([preds_dict],) + + if "box_type_3d" not in sample_meta: + sample_meta["box_type_3d"] = LiDARInstance3DBoxes + batch_input_metas = [sample_meta] + + with torch.no_grad(): + predictions_list = self.pytorch_model.pts_bbox_head.predict_by_feat( + preds_dicts=preds_dicts, batch_input_metas=batch_input_metas + ) + + results = [] + for pred_instances in predictions_list: + bboxes_3d = pred_instances.bboxes_3d.tensor.cpu().numpy() + scores_3d = pred_instances.scores_3d.cpu().numpy() + labels_3d = pred_instances.labels_3d.cpu().numpy() + + for i in range(len(bboxes_3d)): + results.append( + { + "bbox_3d": bboxes_3d[i][:7].tolist(), + "score": float(scores_3d[i]), + "label": int(labels_3d[i]), + } + ) + + return results + + @abstractmethod + def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: + raise NotImplementedError + + @abstractmethod + def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: + raise NotImplementedError + + def run_model(self, preprocessed_input: Dict[str, torch.Tensor]) -> Tuple[List[torch.Tensor], Dict[str, float]]: + stage_latencies = {} + + start = time.perf_counter() + voxel_features = self.run_voxel_encoder(preprocessed_input["input_features"]) + stage_latencies["voxel_encoder_ms"] = (time.perf_counter() - start) * 1000 + + start = time.perf_counter() + spatial_features = self.process_middle_encoder(voxel_features, preprocessed_input["coors"]) + stage_latencies["middle_encoder_ms"] = (time.perf_counter() - start) * 1000 + + start = time.perf_counter() + head_outputs = self.run_backbone_head(spatial_features) + stage_latencies["backbone_head_ms"] = (time.perf_counter() - start) * 1000 + + return head_outputs, stage_latencies + + def __repr__(self): + return f"{self.__class__.__name__}(device={self.device})" diff --git a/deployment/projects/centerpoint/pipelines/factory.py b/deployment/projects/centerpoint/pipelines/factory.py new file mode 100644 index 000000000..af3bfceab --- /dev/null +++ b/deployment/projects/centerpoint/pipelines/factory.py @@ -0,0 +1,64 @@ +""" +CenterPoint Pipeline Factory. + +Registers CenterPoint pipelines into the global pipeline_registry so evaluators can create pipelines +via `deployment.pipelines.factory.PipelineFactory`. +""" + +import logging +from typing import Any, Optional + +from deployment.core.backend import Backend +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.pipelines.base_factory import BasePipelineFactory +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.registry import pipeline_registry +from deployment.projects.centerpoint.pipelines.onnx import CenterPointONNXPipeline +from deployment.projects.centerpoint.pipelines.pytorch import CenterPointPyTorchPipeline +from deployment.projects.centerpoint.pipelines.tensorrt import CenterPointTensorRTPipeline + +logger = logging.getLogger(__name__) + + +@pipeline_registry.register +class CenterPointPipelineFactory(BasePipelineFactory): + """Pipeline factory for CenterPoint across supported backends.""" + + @classmethod + def get_project_name(cls) -> str: + return "centerpoint" + + @classmethod + def create_pipeline( + cls, + model_spec: ModelSpec, + pytorch_model: Any, + device: Optional[str] = None, + **kwargs, + ) -> BaseDeploymentPipeline: + device = device or model_spec.device + backend = model_spec.backend + + cls._validate_backend(backend) + + if backend is Backend.PYTORCH: + logger.info(f"Creating CenterPoint PyTorch pipeline on {device}") + return CenterPointPyTorchPipeline(pytorch_model, device=device) + + if backend is Backend.ONNX: + logger.info(f"Creating CenterPoint ONNX pipeline from {model_spec.path} on {device}") + return CenterPointONNXPipeline( + pytorch_model, + onnx_dir=model_spec.path, + device=device, + ) + + if backend is Backend.TENSORRT: + logger.info(f"Creating CenterPoint TensorRT pipeline from {model_spec.path} on {device}") + return CenterPointTensorRTPipeline( + pytorch_model, + tensorrt_dir=model_spec.path, + device=device, + ) + + raise ValueError(f"Unsupported backend: {backend.value}") diff --git a/deployment/projects/centerpoint/pipelines/onnx.py b/deployment/projects/centerpoint/pipelines/onnx.py new file mode 100644 index 000000000..f42713fbc --- /dev/null +++ b/deployment/projects/centerpoint/pipelines/onnx.py @@ -0,0 +1,86 @@ +""" +CenterPoint ONNX Pipeline Implementation. + +Moved from deployment/pipelines/centerpoint/centerpoint_onnx.py into the CenterPoint deployment bundle. +""" + +import logging +import os.path as osp +from typing import List + +import numpy as np +import onnxruntime as ort +import torch + +from deployment.projects.centerpoint.pipelines.centerpoint_pipeline import CenterPointDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class CenterPointONNXPipeline(CenterPointDeploymentPipeline): + """ONNXRuntime-based CenterPoint pipeline (componentized inference).""" + + def __init__(self, pytorch_model, onnx_dir: str, device: str = "cpu"): + super().__init__(pytorch_model, device, backend_type="onnx") + + self.onnx_dir = onnx_dir + self._load_onnx_models(device) + logger.info(f"ONNX pipeline initialized with models from: {onnx_dir}") + + def _load_onnx_models(self, device: str): + voxel_encoder_path = osp.join(self.onnx_dir, "pts_voxel_encoder.onnx") + backbone_head_path = osp.join(self.onnx_dir, "pts_backbone_neck_head.onnx") + + if not osp.exists(voxel_encoder_path): + raise FileNotFoundError(f"Voxel encoder ONNX not found: {voxel_encoder_path}") + if not osp.exists(backbone_head_path): + raise FileNotFoundError(f"Backbone head ONNX not found: {backbone_head_path}") + + so = ort.SessionOptions() + so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + so.log_severity_level = 3 + + if device.startswith("cuda"): + providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] + logger.info("Using CUDA execution provider for ONNX") + else: + providers = ["CPUExecutionProvider"] + logger.info("Using CPU execution provider for ONNX") + + try: + self.voxel_encoder_session = ort.InferenceSession(voxel_encoder_path, sess_options=so, providers=providers) + logger.info(f"Loaded voxel encoder: {voxel_encoder_path}") + except Exception as e: + raise RuntimeError(f"Failed to load voxel encoder ONNX: {e}") + + try: + self.backbone_head_session = ort.InferenceSession(backbone_head_path, sess_options=so, providers=providers) + logger.info(f"Loaded backbone+head: {backbone_head_path}") + except Exception as e: + raise RuntimeError(f"Failed to load backbone+head ONNX: {e}") + + def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: + input_array = input_features.cpu().numpy().astype(np.float32) + input_name = self.voxel_encoder_session.get_inputs()[0].name + output_name = self.voxel_encoder_session.get_outputs()[0].name + + outputs = self.voxel_encoder_session.run([output_name], {input_name: input_array}) + + voxel_features = torch.from_numpy(outputs[0]).to(self.device) + if voxel_features.ndim == 3 and voxel_features.shape[1] == 1: + voxel_features = voxel_features.squeeze(1) + return voxel_features + + def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: + input_array = spatial_features.cpu().numpy().astype(np.float32) + + input_name = self.backbone_head_session.get_inputs()[0].name + output_names = [output.name for output in self.backbone_head_session.get_outputs()] + + outputs = self.backbone_head_session.run(output_names, {input_name: input_array}) + head_outputs = [torch.from_numpy(out).to(self.device) for out in outputs] + + if len(head_outputs) != 6: + raise ValueError(f"Expected 6 head outputs, got {len(head_outputs)}") + + return head_outputs diff --git a/deployment/projects/centerpoint/pipelines/pytorch.py b/deployment/projects/centerpoint/pipelines/pytorch.py new file mode 100644 index 000000000..3f43aab55 --- /dev/null +++ b/deployment/projects/centerpoint/pipelines/pytorch.py @@ -0,0 +1,87 @@ +""" +CenterPoint PyTorch Pipeline Implementation. + +Moved from deployment/pipelines/centerpoint/centerpoint_pytorch.py into the CenterPoint deployment bundle. +""" + +import logging +from typing import Dict, List + +import torch + +from deployment.projects.centerpoint.pipelines.centerpoint_pipeline import CenterPointDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class CenterPointPyTorchPipeline(CenterPointDeploymentPipeline): + """PyTorch-based CenterPoint pipeline (staged to match ONNX/TensorRT outputs).""" + + def __init__(self, pytorch_model, device: str = "cuda"): + super().__init__(pytorch_model, device, backend_type="pytorch") + logger.info("PyTorch pipeline initialized (ONNX-compatible staged inference)") + + def infer(self, points: torch.Tensor, sample_meta: Dict = None, return_raw_outputs: bool = False): + if sample_meta is None: + sample_meta = {} + return super().infer(points, sample_meta, return_raw_outputs=return_raw_outputs) + + def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: + if input_features is None: + raise ValueError("input_features is None. This should not happen for ONNX models.") + + input_features = input_features.to(self.device) + + with torch.no_grad(): + voxel_features = self.pytorch_model.pts_voxel_encoder(input_features) + + if voxel_features.ndim == 3: + if voxel_features.shape[1] == 1: + voxel_features = voxel_features.squeeze(1) + elif voxel_features.shape[2] == 1: + voxel_features = voxel_features.squeeze(2) + else: + raise RuntimeError( + f"Voxel encoder output has unexpected 3D shape: {voxel_features.shape}. " + f"Expected 2D output [N_voxels, feature_dim]. Input was: {input_features.shape}" + ) + elif voxel_features.ndim > 3: + raise RuntimeError( + f"Voxel encoder output has {voxel_features.ndim}D shape: {voxel_features.shape}. " + "Expected 2D output [N_voxels, feature_dim]." + ) + + return voxel_features + + def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: + spatial_features = spatial_features.to(self.device) + + with torch.no_grad(): + x = self.pytorch_model.pts_backbone(spatial_features) + + if hasattr(self.pytorch_model, "pts_neck") and self.pytorch_model.pts_neck is not None: + x = self.pytorch_model.pts_neck(x) + + head_outputs_tuple = self.pytorch_model.pts_bbox_head(x) + + if isinstance(head_outputs_tuple, tuple) and len(head_outputs_tuple) > 0: + first_element = head_outputs_tuple[0] + + if isinstance(first_element, torch.Tensor): + head_outputs = list(head_outputs_tuple) + elif isinstance(first_element, list) and len(first_element) > 0: + preds_dict = first_element[0] + head_outputs = [ + preds_dict["heatmap"], + preds_dict["reg"], + preds_dict["height"], + preds_dict["dim"], + preds_dict["rot"], + preds_dict["vel"], + ] + else: + raise ValueError(f"Unexpected task_outputs format: {type(first_element)}") + else: + raise ValueError(f"Unexpected head_outputs format: {type(head_outputs_tuple)}") + + return head_outputs diff --git a/deployment/projects/centerpoint/pipelines/tensorrt.py b/deployment/projects/centerpoint/pipelines/tensorrt.py new file mode 100644 index 000000000..525c2bbd1 --- /dev/null +++ b/deployment/projects/centerpoint/pipelines/tensorrt.py @@ -0,0 +1,178 @@ +""" +CenterPoint TensorRT Pipeline Implementation. + +Moved from deployment/pipelines/centerpoint/centerpoint_tensorrt.py into the CenterPoint deployment bundle. +""" + +import logging +import os.path as osp +from typing import List + +import numpy as np +import pycuda.autoinit # noqa: F401 +import pycuda.driver as cuda +import tensorrt as trt +import torch + +from deployment.pipelines.gpu_resource_mixin import ( + GPUResourceMixin, + TensorRTResourceManager, + release_tensorrt_resources, +) +from deployment.projects.centerpoint.config.deploy_config import onnx_config +from deployment.projects.centerpoint.pipelines.centerpoint_pipeline import CenterPointDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class CenterPointTensorRTPipeline(GPUResourceMixin, CenterPointDeploymentPipeline): + """TensorRT-based CenterPoint pipeline (engine-per-component inference).""" + + def __init__(self, pytorch_model, tensorrt_dir: str, device: str = "cuda"): + if not device.startswith("cuda"): + raise ValueError("TensorRT requires CUDA device") + + super().__init__(pytorch_model, device, backend_type="tensorrt") + + self.tensorrt_dir = tensorrt_dir + self._engines = {} + self._contexts = {} + self._logger = trt.Logger(trt.Logger.WARNING) + self._cleanup_called = False + + self._load_tensorrt_engines() + logger.info(f"TensorRT pipeline initialized with engines from: {tensorrt_dir}") + + def _load_tensorrt_engines(self): + trt.init_libnvinfer_plugins(self._logger, "") + runtime = trt.Runtime(self._logger) + + component_cfg = onnx_config.get("components", {}) + voxel_cfg = component_cfg.get("voxel_encoder", {}) + backbone_cfg = component_cfg.get("backbone_head", {}) + engine_files = { + "voxel_encoder": voxel_cfg.get("engine_file", "pts_voxel_encoder.engine"), + "backbone_neck_head": backbone_cfg.get("engine_file", "pts_backbone_neck_head.engine"), + } + + for component, engine_file in engine_files.items(): + engine_path = osp.join(self.tensorrt_dir, engine_file) + if not osp.exists(engine_path): + raise FileNotFoundError(f"TensorRT engine not found: {engine_path}") + + with open(engine_path, "rb") as f: + engine = runtime.deserialize_cuda_engine(f.read()) + if engine is None: + raise RuntimeError(f"Failed to deserialize engine: {engine_path}") + + context = engine.create_execution_context() + if context is None: + raise RuntimeError( + f"Failed to create execution context for {component}. " "This is likely due to GPU out-of-memory." + ) + + self._engines[component] = engine + self._contexts[component] = context + logger.info(f"Loaded TensorRT engine: {component}") + + def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: + engine = self._engines["voxel_encoder"] + context = self._contexts["voxel_encoder"] + if context is None: + raise RuntimeError("voxel_encoder context is None - likely failed to initialize due to GPU OOM") + + input_array = input_features.cpu().numpy().astype(np.float32) + if not input_array.flags["C_CONTIGUOUS"]: + input_array = np.ascontiguousarray(input_array) + + input_name, output_name = self._get_io_names(engine, single_output=True) + context.set_input_shape(input_name, input_array.shape) + output_shape = context.get_tensor_shape(output_name) + output_array = np.empty(output_shape, dtype=np.float32) + if not output_array.flags["C_CONTIGUOUS"]: + output_array = np.ascontiguousarray(output_array) + + with TensorRTResourceManager() as manager: + d_input = manager.allocate(input_array.nbytes) + d_output = manager.allocate(output_array.nbytes) + stream = manager.get_stream() + + context.set_tensor_address(input_name, int(d_input)) + context.set_tensor_address(output_name, int(d_output)) + + cuda.memcpy_htod_async(d_input, input_array, stream) + context.execute_async_v3(stream_handle=stream.handle) + cuda.memcpy_dtoh_async(output_array, d_output, stream) + manager.synchronize() + + voxel_features = torch.from_numpy(output_array).to(self.device) + voxel_features = voxel_features.squeeze(1) + return voxel_features + + def _get_io_names(self, engine, single_output: bool = False): + input_name = None + output_names = [] + + for i in range(engine.num_io_tensors): + tensor_name = engine.get_tensor_name(i) + if engine.get_tensor_mode(tensor_name) == trt.TensorIOMode.INPUT: + input_name = tensor_name + elif engine.get_tensor_mode(tensor_name) == trt.TensorIOMode.OUTPUT: + output_names.append(tensor_name) + + if input_name is None: + raise RuntimeError("Could not find input tensor name") + if not output_names: + raise RuntimeError("Could not find output tensor names") + + if single_output: + return input_name, output_names[0] + return input_name, output_names + + def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: + engine = self._engines["backbone_neck_head"] + context = self._contexts["backbone_neck_head"] + if context is None: + raise RuntimeError("backbone_neck_head context is None - likely failed to initialize due to GPU OOM") + + input_array = spatial_features.cpu().numpy().astype(np.float32) + if not input_array.flags["C_CONTIGUOUS"]: + input_array = np.ascontiguousarray(input_array) + + input_name, output_names = self._get_io_names(engine, single_output=False) + context.set_input_shape(input_name, input_array.shape) + + output_arrays = {} + for output_name in output_names: + output_shape = context.get_tensor_shape(output_name) + output_array = np.empty(output_shape, dtype=np.float32) + if not output_array.flags["C_CONTIGUOUS"]: + output_array = np.ascontiguousarray(output_array) + output_arrays[output_name] = output_array + + with TensorRTResourceManager() as manager: + d_input = manager.allocate(input_array.nbytes) + d_outputs = {name: manager.allocate(arr.nbytes) for name, arr in output_arrays.items()} + stream = manager.get_stream() + + context.set_tensor_address(input_name, int(d_input)) + for output_name in output_names: + context.set_tensor_address(output_name, int(d_outputs[output_name])) + + cuda.memcpy_htod_async(d_input, input_array, stream) + context.execute_async_v3(stream_handle=stream.handle) + + for output_name in output_names: + cuda.memcpy_dtoh_async(output_arrays[output_name], d_outputs[output_name], stream) + manager.synchronize() + + head_outputs = [torch.from_numpy(output_arrays[name]).to(self.device) for name in output_names] + if len(head_outputs) != 6: + raise ValueError(f"Expected 6 head outputs, got {len(head_outputs)}") + return head_outputs + + def _release_gpu_resources(self) -> None: + release_tensorrt_resources( + engines=getattr(self, "_engines", None), + contexts=getattr(self, "_contexts", None), + ) diff --git a/deployment/projects/centerpoint/runner.py b/deployment/projects/centerpoint/runner.py new file mode 100644 index 000000000..47e35338e --- /dev/null +++ b/deployment/projects/centerpoint/runner.py @@ -0,0 +1,99 @@ +""" +CenterPoint-specific deployment runner. + +Moved from deployment/runners/projects/centerpoint_runner.py into the unified deployment bundle. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from deployment.core.contexts import CenterPointExportContext, ExportContext +from deployment.exporters.common.factory import ExporterFactory +from deployment.exporters.common.model_wrappers import IdentityWrapper +from deployment.projects.centerpoint.export.component_extractor import CenterPointComponentExtractor +from deployment.projects.centerpoint.export.onnx_export_pipeline import CenterPointONNXExportPipeline +from deployment.projects.centerpoint.export.tensorrt_export_pipeline import CenterPointTensorRTExportPipeline +from deployment.projects.centerpoint.model_loader import build_centerpoint_onnx_model +from deployment.runtime.runner import BaseDeploymentRunner + + +class CenterPointDeploymentRunner(BaseDeploymentRunner): + """CenterPoint deployment runner. + + Implements project-specific model loading and wiring to export pipelines, + while reusing the project-agnostic orchestration in `BaseDeploymentRunner`. + """ + + def __init__( + self, + data_loader, + evaluator, + config, + model_cfg, + logger: logging.Logger, + onnx_pipeline=None, + tensorrt_pipeline=None, + ): + simplify_onnx = config.get_onnx_settings().simplify + component_extractor = CenterPointComponentExtractor(logger=logger, simplify=simplify_onnx) + + super().__init__( + data_loader=data_loader, + evaluator=evaluator, + config=config, + model_cfg=model_cfg, + logger=logger, + onnx_wrapper_cls=IdentityWrapper, + onnx_pipeline=onnx_pipeline, + tensorrt_pipeline=tensorrt_pipeline, + ) + + if self._onnx_pipeline is None: + self._onnx_pipeline = CenterPointONNXExportPipeline( + exporter_factory=ExporterFactory, + component_extractor=component_extractor, + config=self.config, + logger=self.logger, + ) + + if self._tensorrt_pipeline is None: + self._tensorrt_pipeline = CenterPointTensorRTExportPipeline( + exporter_factory=ExporterFactory, + config=self.config, + logger=self.logger, + ) + + def load_pytorch_model(self, checkpoint_path: str, context: ExportContext) -> Any: + rot_y_axis_reference: bool = False + if isinstance(context, CenterPointExportContext): + rot_y_axis_reference = context.rot_y_axis_reference + else: + rot_y_axis_reference = context.get("rot_y_axis_reference", False) + + model, onnx_cfg = build_centerpoint_onnx_model( + base_model_cfg=self.model_cfg, + checkpoint_path=checkpoint_path, + device="cpu", + rot_y_axis_reference=rot_y_axis_reference, + ) + + self.model_cfg = onnx_cfg + self._inject_model_to_evaluator(model, onnx_cfg) + return model + + def _inject_model_to_evaluator(self, model: Any, onnx_cfg: Any) -> None: + try: + self.evaluator.set_onnx_config(onnx_cfg) + self.logger.info("Injected ONNX-compatible config to evaluator") + except Exception as e: + self.logger.error(f"Failed to inject ONNX config: {e}") + raise + + try: + self.evaluator.set_pytorch_model(model) + self.logger.info("Injected PyTorch model to evaluator") + except Exception as e: + self.logger.error(f"Failed to inject PyTorch model: {e}") + raise diff --git a/deployment/projects/registry.py b/deployment/projects/registry.py new file mode 100644 index 000000000..a64bc73a7 --- /dev/null +++ b/deployment/projects/registry.py @@ -0,0 +1,55 @@ +""" +Project registry for deployment bundles. + +Each deployment project registers an adapter that knows how to: +- add its CLI args +- construct data_loader / evaluator / runner +- execute the deployment workflow + +This keeps `deployment/cli/main.py` project-agnostic. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, Optional + + +@dataclass(frozen=True) +class ProjectAdapter: + """Minimal adapter interface for a deployment project.""" + + name: str + add_args: Callable # (argparse.ArgumentParser) -> None + run: Callable # (argparse.Namespace) -> int + + +class ProjectRegistry: + """In-memory registry of deployment project adapters. + + The unified CLI discovers and imports `deployment.projects.` packages; + each package registers a `ProjectAdapter` here. This keeps core/cli code + project-agnostic while enabling project-specific argument wiring and run logic. + """ + + def __init__(self) -> None: + self._adapters: Dict[str, ProjectAdapter] = {} + + def register(self, adapter: ProjectAdapter) -> None: + name = adapter.name.strip().lower() + if not name: + raise ValueError("ProjectAdapter.name must be non-empty") + self._adapters[name] = adapter + + def get(self, name: str) -> ProjectAdapter: + key = (name or "").strip().lower() + if key not in self._adapters: + available = ", ".join(sorted(self._adapters.keys())) + raise KeyError(f"Unknown project '{name}'. Available: [{available}]") + return self._adapters[key] + + def list(self) -> list[str]: + return sorted(self._adapters.keys()) + + +project_registry = ProjectRegistry() diff --git a/deployment/runtime/__init__.py b/deployment/runtime/__init__.py new file mode 100644 index 000000000..6f0d383a2 --- /dev/null +++ b/deployment/runtime/__init__.py @@ -0,0 +1,25 @@ +"""Shared deployment runtime (runner + orchestrators). + +This package contains the project-agnostic runtime execution layer: +- BaseDeploymentRunner +- Export/Verification/Evaluation orchestrators +- ArtifactManager + +Project-specific code should live under `deployment/projects//`. +""" + +from deployment.runtime.artifact_manager import ArtifactManager +from deployment.runtime.evaluation_orchestrator import EvaluationOrchestrator +from deployment.runtime.export_orchestrator import ExportOrchestrator, ExportResult +from deployment.runtime.runner import BaseDeploymentRunner, DeploymentResult +from deployment.runtime.verification_orchestrator import VerificationOrchestrator + +__all__ = [ + "ArtifactManager", + "ExportOrchestrator", + "ExportResult", + "VerificationOrchestrator", + "EvaluationOrchestrator", + "BaseDeploymentRunner", + "DeploymentResult", +] diff --git a/deployment/runtime/artifact_manager.py b/deployment/runtime/artifact_manager.py new file mode 100644 index 000000000..17845e386 --- /dev/null +++ b/deployment/runtime/artifact_manager.py @@ -0,0 +1,86 @@ +""" +Artifact management for deployment workflows. + +This module handles registration and resolution of model artifacts (PyTorch checkpoints, +ONNX models, TensorRT engines) across different backends. +""" + +import logging +import os.path as osp +from collections.abc import Mapping +from typing import Any, Dict, Optional, Tuple + +from deployment.core.artifacts import Artifact +from deployment.core.backend import Backend +from deployment.core.config.base_config import BaseDeploymentConfig + + +class ArtifactManager: + """ + Manages model artifacts and path resolution for deployment workflows. + + Resolution Order (consistent for all backends): + 1. Registered artifacts (from export operations) - highest priority + 2. Explicit paths from evaluation.backends. config: + - ONNX: evaluation.backends.onnx.model_dir + - TensorRT: evaluation.backends.tensorrt.engine_dir + 3. Backend-specific fallback paths: + - PyTorch: checkpoint_path + - ONNX: export.onnx_path + """ + + def __init__(self, config: BaseDeploymentConfig, logger: logging.Logger): + self.config = config + self.logger = logger + self.artifacts: Dict[str, Artifact] = {} + + def register_artifact(self, backend: Backend, artifact: Artifact) -> None: + self.artifacts[backend.value] = artifact + self.logger.debug(f"Registered {backend.value} artifact: {artifact.path}") + + def get_artifact(self, backend: Backend) -> Optional[Artifact]: + return self.artifacts.get(backend.value) + + def resolve_artifact(self, backend: Backend) -> Tuple[Optional[Artifact], bool]: + artifact = self.artifacts.get(backend.value) + if artifact: + return artifact, artifact.exists() + + config_path = self._get_config_path(backend) + if config_path: + is_dir = osp.isdir(config_path) if osp.exists(config_path) else False + artifact = Artifact(path=config_path, multi_file=is_dir) + return artifact, artifact.exists() + + return None, False + + def _get_config_path(self, backend: Backend) -> Optional[str]: + eval_backends = self.config.evaluation_config.backends + backend_cfg = self._get_backend_entry(eval_backends, backend) + if backend_cfg and isinstance(backend_cfg, Mapping): + if backend == Backend.ONNX: + path = backend_cfg.get("model_dir") + if path: + return path + elif backend == Backend.TENSORRT: + path = backend_cfg.get("engine_dir") + if path: + return path + + if backend == Backend.PYTORCH: + return self.config.checkpoint_path + if backend == Backend.ONNX: + return self.config.export_config.onnx_path + + return None + + @staticmethod + def _get_backend_entry(mapping: Optional[Mapping], backend: Backend) -> Any: + if not mapping: + return None + + value = mapping.get(backend.value) + if value is not None: + return value + + return mapping.get(backend) diff --git a/deployment/runtime/evaluation_orchestrator.py b/deployment/runtime/evaluation_orchestrator.py new file mode 100644 index 000000000..8ca4aa9b6 --- /dev/null +++ b/deployment/runtime/evaluation_orchestrator.py @@ -0,0 +1,154 @@ +""" +Evaluation orchestration for deployment workflows. + +This module handles cross-backend evaluation with consistent metrics. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Mapping + +from deployment.core.backend import Backend +from deployment.core.config.base_config import BaseDeploymentConfig +from deployment.core.evaluation.base_evaluator import BaseEvaluator +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.runtime.artifact_manager import ArtifactManager + + +class EvaluationOrchestrator: + """Orchestrates evaluation across backends with consistent metrics.""" + + def __init__( + self, + config: BaseDeploymentConfig, + evaluator: BaseEvaluator, + data_loader: BaseDataLoader, + logger: logging.Logger, + ): + self.config = config + self.evaluator = evaluator + self.data_loader = data_loader + self.logger = logger + + def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: + eval_config = self.config.evaluation_config + + if not eval_config.enabled: + self.logger.info("Evaluation disabled, skipping...") + return {} + + self.logger.info("=" * 80) + self.logger.info("Running Evaluation") + self.logger.info("=" * 80) + + models_to_evaluate = self._get_models_to_evaluate(artifact_manager) + if not models_to_evaluate: + self.logger.warning("No models found for evaluation") + return {} + + num_samples = eval_config.num_samples + if num_samples == -1: + num_samples = self.data_loader.get_num_samples() + + verbose_mode = eval_config.verbose + all_results: Dict[str, Any] = {} + + for spec in models_to_evaluate: + backend = spec.backend + backend_device = self._normalize_device_for_backend(backend, spec.device) + normalized_spec = ModelSpec(backend=backend, device=backend_device, artifact=spec.artifact) + + self.logger.info(f"\nEvaluating {backend.value} on {backend_device}...") + try: + results = self.evaluator.evaluate( + model=normalized_spec, + data_loader=self.data_loader, + num_samples=num_samples, + verbose=verbose_mode, + ) + all_results[backend.value] = results + self.logger.info(f"\n{backend.value.upper()} Results:") + self.evaluator.print_results(results) + except Exception as e: + self.logger.error(f"Evaluation failed for {backend.value}: {e}", exc_info=True) + all_results[backend.value] = {"error": str(e)} + finally: + from deployment.pipelines.gpu_resource_mixin import clear_cuda_memory + + clear_cuda_memory() + + if len(all_results) > 1: + self._print_cross_backend_comparison(all_results) + + return all_results + + def _get_models_to_evaluate(self, artifact_manager: ArtifactManager) -> List[ModelSpec]: + backends = self.config.get_evaluation_backends() + models_to_evaluate: List[ModelSpec] = [] + + for backend_key, backend_cfg in backends.items(): + backend_enum = Backend.from_value(backend_key) + if not backend_cfg.get("enabled", False): + continue + + device = str(backend_cfg.get("device", "cpu") or "cpu") + artifact, is_valid = artifact_manager.resolve_artifact(backend_enum) + + if is_valid and artifact: + spec = ModelSpec(backend=backend_enum, device=device, artifact=artifact) + models_to_evaluate.append(spec) + self.logger.info(f" - {backend_enum.value}: {artifact.path} (device: {device})") + elif artifact is not None: + self.logger.warning(f" - {backend_enum.value}: {artifact.path} (not found or invalid, skipping)") + + return models_to_evaluate + + def _normalize_device_for_backend(self, backend: Backend, device: str) -> str: + normalized_device = str(device or self._get_default_device(backend) or "cpu") + + if backend in (Backend.PYTORCH, Backend.ONNX): + if normalized_device not in ("cpu",) and not normalized_device.startswith("cuda"): + self.logger.warning( + f"Unsupported device '{normalized_device}' for backend '{backend.value}'. Falling back to CPU." + ) + normalized_device = "cpu" + elif backend is Backend.TENSORRT: + if not normalized_device or normalized_device == "cpu": + normalized_device = self.config.devices.cuda or "cuda:0" + if not normalized_device.startswith("cuda"): + self.logger.warning( + "TensorRT evaluation requires CUDA device. " + f"Overriding device from '{normalized_device}' to 'cuda:0'." + ) + normalized_device = "cuda:0" + + return normalized_device + + def _get_default_device(self, backend: Backend) -> str: + if backend is Backend.TENSORRT: + return self.config.devices.cuda or "cuda:0" + return self.config.devices.cpu or "cpu" + + def _print_cross_backend_comparison(self, all_results: Mapping[str, Any]) -> None: + self.logger.info("\n" + "=" * 80) + self.logger.info("Cross-Backend Comparison") + self.logger.info("=" * 80) + + for backend_label, results in all_results.items(): + self.logger.info(f"\n{backend_label.upper()}:") + if results and "error" not in results: + if "accuracy" in results: + self.logger.info(f" Accuracy: {results.get('accuracy', 0):.4f}") + if "mAP" in results: + self.logger.info(f" mAP: {results.get('mAP', 0):.4f}") + + if "latency_stats" in results: + stats = results["latency_stats"] + self.logger.info(f" Latency: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms") + elif "latency" in results: + latency = results["latency"] + self.logger.info(f" Latency: {latency['mean_ms']:.2f} ± {latency['std_ms']:.2f} ms") + else: + self.logger.info(" No results available") diff --git a/deployment/runtime/export_orchestrator.py b/deployment/runtime/export_orchestrator.py new file mode 100644 index 000000000..10de09798 --- /dev/null +++ b/deployment/runtime/export_orchestrator.py @@ -0,0 +1,364 @@ +""" +Export orchestration for deployment workflows. + +This module handles all model export logic (PyTorch loading, ONNX export, TensorRT export) +in a unified orchestrator, keeping the deployment runner thin. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Optional, Type + +import torch + +from deployment.core.artifacts import Artifact +from deployment.core.backend import Backend +from deployment.core.config.base_config import BaseDeploymentConfig +from deployment.core.contexts import ExportContext +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.exporters.common.factory import ExporterFactory +from deployment.exporters.common.model_wrappers import BaseModelWrapper +from deployment.exporters.common.onnx_exporter import ONNXExporter +from deployment.exporters.common.tensorrt_exporter import TensorRTExporter +from deployment.exporters.export_pipelines.base import OnnxExportPipeline, TensorRTExportPipeline +from deployment.runtime.artifact_manager import ArtifactManager + + +@dataclass +class ExportResult: + """Result of export orchestration.""" + + pytorch_model: Optional[Any] = None + onnx_path: Optional[str] = None + tensorrt_path: Optional[str] = None + + +class ExportOrchestrator: + """Orchestrates model export workflows (PyTorch loading, ONNX, TensorRT).""" + + ONNX_DIR_NAME = "onnx" + TENSORRT_DIR_NAME = "tensorrt" + DEFAULT_ENGINE_FILENAME = "model.engine" + + def __init__( + self, + config: BaseDeploymentConfig, + data_loader: BaseDataLoader, + artifact_manager: ArtifactManager, + logger: logging.Logger, + model_loader: Callable[..., Any], + evaluator: Any, + onnx_wrapper_cls: Optional[Type[BaseModelWrapper]] = None, + onnx_pipeline: Optional[OnnxExportPipeline] = None, + tensorrt_pipeline: Optional[TensorRTExportPipeline] = None, + ): + self.config = config + self.data_loader = data_loader + self.artifact_manager = artifact_manager + self.logger = logger + self._model_loader = model_loader + self._evaluator = evaluator + self._onnx_wrapper_cls = onnx_wrapper_cls + self._onnx_pipeline = onnx_pipeline + self._tensorrt_pipeline = tensorrt_pipeline + + self._onnx_exporter: Optional[ONNXExporter] = None + self._tensorrt_exporter: Optional[TensorRTExporter] = None + + def run(self, context: Optional[ExportContext] = None) -> ExportResult: + if context is None: + context = ExportContext() + + result = ExportResult() + + should_export_onnx = self.config.export_config.should_export_onnx() + should_export_trt = self.config.export_config.should_export_tensorrt() + checkpoint_path = self.config.checkpoint_path + external_onnx_path = self.config.export_config.onnx_path + + requires_pytorch = self._determine_pytorch_requirements() + + pytorch_model = None + if requires_pytorch: + pytorch_model, success = self._ensure_pytorch_model_loaded(pytorch_model, checkpoint_path, context, result) + if not success: + return result + + if should_export_onnx: + result.onnx_path = self._run_onnx_export(pytorch_model, context) + + if should_export_trt: + onnx_path = self._resolve_onnx_path_for_trt(result.onnx_path, external_onnx_path) + if not onnx_path: + return result + result.onnx_path = onnx_path + self._register_external_onnx_artifact(onnx_path) + result.tensorrt_path = self._run_tensorrt_export(onnx_path, context) + + self._resolve_external_artifacts(result) + return result + + def _determine_pytorch_requirements(self) -> bool: + if self.config.export_config.should_export_onnx(): + return True + + eval_config = self.config.evaluation_config + if eval_config.enabled: + backends_cfg = eval_config.backends + pytorch_cfg = backends_cfg.get(Backend.PYTORCH.value) or backends_cfg.get(Backend.PYTORCH, {}) + if pytorch_cfg and pytorch_cfg.get("enabled", False): + return True + + verification_cfg = self.config.verification_config + if verification_cfg.enabled: + export_mode = self.config.export_config.mode + scenarios = self.config.get_verification_scenarios(export_mode) + if scenarios and any( + policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios + ): + return True + + return False + + def _load_and_register_pytorch_model(self, checkpoint_path: str, context: ExportContext) -> Optional[Any]: + if not checkpoint_path: + self.logger.error( + "Checkpoint required but not provided. Please set export.checkpoint_path in config or pass via CLI." + ) + return None + + self.logger.info("\nLoading PyTorch model...") + try: + pytorch_model = self._model_loader(checkpoint_path, context) + self.artifact_manager.register_artifact(Backend.PYTORCH, Artifact(path=checkpoint_path)) + + if hasattr(self._evaluator, "set_pytorch_model"): + self._evaluator.set_pytorch_model(pytorch_model) + self.logger.info("Updated evaluator with pre-built PyTorch model via set_pytorch_model()") + + return pytorch_model + except Exception as e: + self.logger.error(f"Failed to load PyTorch model: {e}") + return None + + def _ensure_pytorch_model_loaded( + self, + pytorch_model: Optional[Any], + checkpoint_path: str, + context: ExportContext, + result: ExportResult, + ) -> tuple[Optional[Any], bool]: + if pytorch_model is not None: + return pytorch_model, True + + if not checkpoint_path: + self.logger.error("PyTorch model required but checkpoint_path not provided.") + return None, False + + pytorch_model = self._load_and_register_pytorch_model(checkpoint_path, context) + if pytorch_model is None: + self.logger.error("Failed to load PyTorch model; aborting export.") + return None, False + + result.pytorch_model = pytorch_model + return pytorch_model, True + + def _run_onnx_export(self, pytorch_model: Any, context: ExportContext) -> Optional[str]: + onnx_artifact = self._export_onnx(pytorch_model, context) + if onnx_artifact: + return onnx_artifact.path + self.logger.error("ONNX export requested but no artifact was produced.") + return None + + def _resolve_onnx_path_for_trt( + self, exported_onnx_path: Optional[str], external_onnx_path: Optional[str] + ) -> Optional[str]: + onnx_path = exported_onnx_path or external_onnx_path + if not onnx_path: + self.logger.error( + "TensorRT export requires an ONNX path. " + "Please set export.onnx_path in config or enable ONNX export." + ) + return None + return onnx_path + + def _register_external_onnx_artifact(self, onnx_path: str) -> None: + if not os.path.exists(onnx_path): + return + multi_file = os.path.isdir(onnx_path) + self.artifact_manager.register_artifact(Backend.ONNX, Artifact(path=onnx_path, multi_file=multi_file)) + + def _run_tensorrt_export(self, onnx_path: str, context: ExportContext) -> Optional[str]: + trt_artifact = self._export_tensorrt(onnx_path, context) + if trt_artifact: + return trt_artifact.path + self.logger.error("TensorRT export requested but no artifact was produced.") + return None + + def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[Artifact]: + if not self.config.export_config.should_export_onnx(): + return None + + if self._onnx_pipeline is None and self._onnx_wrapper_cls is None: + raise RuntimeError("ONNX export requested but no wrapper class or export pipeline provided.") + + onnx_settings = self.config.get_onnx_settings() + sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.sample_idx + + onnx_dir = os.path.join(self.config.export_config.work_dir, self.ONNX_DIR_NAME) + os.makedirs(onnx_dir, exist_ok=True) + output_path = os.path.join(onnx_dir, onnx_settings.save_file) + + if self._onnx_pipeline is not None: + self.logger.info("=" * 80) + self.logger.info(f"Exporting to ONNX via pipeline ({type(self._onnx_pipeline).__name__})") + self.logger.info("=" * 80) + artifact = self._onnx_pipeline.export( + model=pytorch_model, + data_loader=self.data_loader, + output_dir=onnx_dir, + config=self.config, + sample_idx=sample_idx, + ) + self.artifact_manager.register_artifact(Backend.ONNX, artifact) + self.logger.info(f"ONNX export successful: {artifact.path}") + return artifact + + exporter = self._get_onnx_exporter() + self.logger.info("=" * 80) + self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") + self.logger.info("=" * 80) + + sample = self.data_loader.load_sample(sample_idx) + single_input = self.data_loader.preprocess(sample) + + batch_size = onnx_settings.batch_size + if batch_size is None: + input_tensor = single_input + self.logger.info("Using dynamic batch size") + else: + if isinstance(single_input, (list, tuple)): + input_tensor = tuple( + inp.repeat(batch_size, *([1] * (len(inp.shape) - 1))) if len(inp.shape) > 0 else inp + for inp in single_input + ) + else: + input_tensor = single_input.repeat(batch_size, *([1] * (len(single_input.shape) - 1))) + self.logger.info(f"Using fixed batch size: {batch_size}") + + exporter.export(pytorch_model, input_tensor, output_path) + + multi_file = bool(self.config.onnx_config.get("multi_file", False)) + artifact_path = onnx_dir if multi_file else output_path + artifact = Artifact(path=artifact_path, multi_file=multi_file) + self.artifact_manager.register_artifact(Backend.ONNX, artifact) + self.logger.info(f"ONNX export successful: {artifact.path}") + return artifact + + def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[Artifact]: + if not self.config.export_config.should_export_tensorrt(): + return None + + if not onnx_path: + self.logger.warning("ONNX path not available, skipping TensorRT export") + return None + + exporter_label = None if self._tensorrt_pipeline else type(self._get_tensorrt_exporter()).__name__ + self.logger.info("=" * 80) + if self._tensorrt_pipeline: + self.logger.info(f"Exporting to TensorRT via pipeline ({type(self._tensorrt_pipeline).__name__})") + else: + self.logger.info(f"Exporting to TensorRT (Using {exporter_label})") + self.logger.info("=" * 80) + + tensorrt_dir = os.path.join(self.config.export_config.work_dir, self.TENSORRT_DIR_NAME) + os.makedirs(tensorrt_dir, exist_ok=True) + output_path = self._get_tensorrt_output_path(onnx_path, tensorrt_dir) + + cuda_device = self.config.devices.cuda + device_id = self.config.devices.get_cuda_device_index() + if cuda_device is None or device_id is None: + raise RuntimeError("TensorRT export requires a CUDA device. Set deploy_cfg.devices['cuda'].") + torch.cuda.set_device(device_id) + self.logger.info(f"Using CUDA device for TensorRT export: {cuda_device}") + + sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.sample_idx + sample_input = self.data_loader.get_shape_sample(sample_idx) + + if self._tensorrt_pipeline is not None: + artifact = self._tensorrt_pipeline.export( + onnx_path=onnx_path, + output_dir=tensorrt_dir, + config=self.config, + device=cuda_device, + data_loader=self.data_loader, + ) + self.artifact_manager.register_artifact(Backend.TENSORRT, artifact) + self.logger.info(f"TensorRT export successful: {artifact.path}") + return artifact + + exporter = self._get_tensorrt_exporter() + artifact = exporter.export( + model=None, + sample_input=sample_input, + output_path=output_path, + onnx_path=onnx_path, + ) + self.artifact_manager.register_artifact(Backend.TENSORRT, artifact) + self.logger.info(f"TensorRT export successful: {artifact.path}") + return artifact + + def _get_onnx_exporter(self) -> ONNXExporter: + if self._onnx_exporter is None: + if self._onnx_wrapper_cls is None: + raise RuntimeError("ONNX wrapper class not provided. Cannot create ONNX exporter.") + self._onnx_exporter = ExporterFactory.create_onnx_exporter( + config=self.config, + wrapper_cls=self._onnx_wrapper_cls, + logger=self.logger, + ) + return self._onnx_exporter + + def _get_tensorrt_exporter(self) -> TensorRTExporter: + if self._tensorrt_exporter is None: + self._tensorrt_exporter = ExporterFactory.create_tensorrt_exporter( + config=self.config, + logger=self.logger, + ) + return self._tensorrt_exporter + + def _get_tensorrt_output_path(self, onnx_path: str, tensorrt_dir: str) -> str: + if os.path.isdir(onnx_path): + return os.path.join(tensorrt_dir, self.DEFAULT_ENGINE_FILENAME) + onnx_filename = os.path.basename(onnx_path) + engine_filename = onnx_filename.replace(".onnx", ".engine") + return os.path.join(tensorrt_dir, engine_filename) + + def _resolve_external_artifacts(self, result: ExportResult) -> None: + if not result.onnx_path: + self._resolve_and_register_artifact(Backend.ONNX, result, "onnx_path") + + if not result.tensorrt_path: + self._resolve_and_register_artifact(Backend.TENSORRT, result, "tensorrt_path") + + def _resolve_and_register_artifact(self, backend: Backend, result: ExportResult, attr_name: str) -> None: + eval_models = self.config.evaluation_config.models + artifact_path = self._get_backend_entry(eval_models, backend) + + if artifact_path and os.path.exists(artifact_path): + setattr(result, attr_name, artifact_path) + multi_file = os.path.isdir(artifact_path) + self.artifact_manager.register_artifact(backend, Artifact(path=artifact_path, multi_file=multi_file)) + elif artifact_path: + self.logger.warning(f"{backend.value} file from config does not exist: {artifact_path}") + + @staticmethod + def _get_backend_entry(mapping: Optional[Mapping[Any, Any]], backend: Backend) -> Any: + if not mapping: + return None + if backend.value in mapping: + return mapping[backend.value] + return mapping.get(backend) diff --git a/deployment/runtime/runner.py b/deployment/runtime/runner.py new file mode 100644 index 000000000..1c0ae6692 --- /dev/null +++ b/deployment/runtime/runner.py @@ -0,0 +1,109 @@ +""" +Unified deployment runner for common deployment workflows. + +Project-agnostic runtime runner that orchestrates: +- Export (PyTorch -> ONNX -> TensorRT) +- Verification (scenario-based comparisons) +- Evaluation (metrics/latency across backends) +""" + +from __future__ import annotations + +import logging +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, Optional, Type + +from mmengine.config import Config + +from deployment.core import BaseDataLoader, BaseDeploymentConfig, BaseEvaluator +from deployment.core.contexts import ExportContext +from deployment.exporters.common.model_wrappers import BaseModelWrapper +from deployment.exporters.export_pipelines.base import OnnxExportPipeline, TensorRTExportPipeline +from deployment.runtime.artifact_manager import ArtifactManager +from deployment.runtime.evaluation_orchestrator import EvaluationOrchestrator +from deployment.runtime.export_orchestrator import ExportOrchestrator +from deployment.runtime.verification_orchestrator import VerificationOrchestrator + + +@dataclass +class DeploymentResult: + """Standardized structure returned by `BaseDeploymentRunner.run()`.""" + + pytorch_model: Optional[Any] = None + onnx_path: Optional[str] = None + tensorrt_path: Optional[str] = None + verification_results: Dict[str, Any] = field(default_factory=dict) + evaluation_results: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +class BaseDeploymentRunner: + """Base deployment runner for common deployment pipelines.""" + + def __init__( + self, + data_loader: BaseDataLoader, + evaluator: BaseEvaluator, + config: BaseDeploymentConfig, + model_cfg: Config, + logger: logging.Logger, + onnx_wrapper_cls: Optional[Type[BaseModelWrapper]] = None, + onnx_pipeline: Optional[OnnxExportPipeline] = None, + tensorrt_pipeline: Optional[TensorRTExportPipeline] = None, + ): + self.data_loader = data_loader + self.evaluator = evaluator + self.config = config + self.model_cfg = model_cfg + self.logger = logger + + self._onnx_wrapper_cls = onnx_wrapper_cls + self._onnx_pipeline = onnx_pipeline + self._tensorrt_pipeline = tensorrt_pipeline + + self.artifact_manager = ArtifactManager(config, logger) + + self._export_orchestrator: Optional[ExportOrchestrator] = None + self.verification_orchestrator = VerificationOrchestrator(config, evaluator, data_loader, logger) + self.evaluation_orchestrator = EvaluationOrchestrator(config, evaluator, data_loader, logger) + + @property + def export_orchestrator(self) -> ExportOrchestrator: + if self._export_orchestrator is None: + self._export_orchestrator = ExportOrchestrator( + config=self.config, + data_loader=self.data_loader, + artifact_manager=self.artifact_manager, + logger=self.logger, + model_loader=self.load_pytorch_model, + evaluator=self.evaluator, + onnx_wrapper_cls=self._onnx_wrapper_cls, + onnx_pipeline=self._onnx_pipeline, + tensorrt_pipeline=self._tensorrt_pipeline, + ) + return self._export_orchestrator + + def load_pytorch_model(self, checkpoint_path: str, context: ExportContext) -> Any: + raise NotImplementedError(f"{self.__class__.__name__}.load_pytorch_model() must be implemented by subclasses.") + + def run(self, context: Optional[ExportContext] = None) -> DeploymentResult: + if context is None: + context = ExportContext() + + results = DeploymentResult() + + export_result = self.export_orchestrator.run(context) + results.pytorch_model = export_result.pytorch_model + results.onnx_path = export_result.onnx_path + results.tensorrt_path = export_result.tensorrt_path + + results.verification_results = self.verification_orchestrator.run(artifact_manager=self.artifact_manager) + results.evaluation_results = self.evaluation_orchestrator.run(self.artifact_manager) + + self.logger.info("\n" + "=" * 80) + self.logger.info("Deployment Complete!") + self.logger.info("=" * 80) + + return results diff --git a/deployment/runtime/verification_orchestrator.py b/deployment/runtime/verification_orchestrator.py new file mode 100644 index 000000000..9f99253b5 --- /dev/null +++ b/deployment/runtime/verification_orchestrator.py @@ -0,0 +1,141 @@ +""" +Verification orchestration for deployment workflows. + +This module handles scenario-based verification across different backends. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Mapping + +from deployment.core.backend import Backend +from deployment.core.config.base_config import BaseDeploymentConfig +from deployment.core.evaluation.base_evaluator import BaseEvaluator +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.runtime.artifact_manager import ArtifactManager + + +class VerificationOrchestrator: + """Orchestrates verification across backends using scenario-based verification.""" + + def __init__( + self, + config: BaseDeploymentConfig, + evaluator: BaseEvaluator, + data_loader: BaseDataLoader, + logger: logging.Logger, + ): + self.config = config + self.evaluator = evaluator + self.data_loader = data_loader + self.logger = logger + + def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: + verification_cfg = self.config.verification_config + + if not verification_cfg.enabled: + self.logger.info("Verification disabled (verification.enabled=False), skipping...") + return {} + + export_mode = self.config.export_config.mode + scenarios = self.config.get_verification_scenarios(export_mode) + + if not scenarios: + self.logger.info(f"No verification scenarios for export mode '{export_mode.value}', skipping...") + return {} + + needs_pytorch = any( + policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios + ) + if needs_pytorch: + _, pytorch_valid = artifact_manager.resolve_artifact(Backend.PYTORCH) + if not pytorch_valid: + self.logger.warning( + "PyTorch checkpoint not available, but required by verification scenarios. Skipping verification." + ) + return {} + + num_verify_samples = verification_cfg.num_verify_samples + tolerance = verification_cfg.tolerance + devices_map = dict(verification_cfg.devices or {}) + devices_map.setdefault("cpu", self.config.devices.cpu or "cpu") + if self.config.devices.cuda: + devices_map.setdefault("cuda", self.config.devices.cuda) + + self.logger.info("=" * 80) + self.logger.info(f"Running Verification (mode: {export_mode.value})") + self.logger.info("=" * 80) + + all_results: Dict[str, Any] = {} + total_passed = 0 + total_failed = 0 + + for i, policy in enumerate(scenarios): + ref_device = self._resolve_device(policy.ref_device, devices_map) + test_device = self._resolve_device(policy.test_device, devices_map) + + self.logger.info( + f"\nScenario {i+1}/{len(scenarios)}: " + f"{policy.ref_backend.value}({ref_device}) vs {policy.test_backend.value}({test_device})" + ) + + ref_artifact, ref_valid = artifact_manager.resolve_artifact(policy.ref_backend) + test_artifact, test_valid = artifact_manager.resolve_artifact(policy.test_backend) + + if not ref_valid or not test_valid: + ref_path = ref_artifact.path if ref_artifact else None + test_path = test_artifact.path if test_artifact else None + self.logger.warning( + " Skipping: missing or invalid artifacts " + f"(ref={ref_path}, valid={ref_valid}, test={test_path}, valid={test_valid})" + ) + continue + + reference_spec = ModelSpec(backend=policy.ref_backend, device=ref_device, artifact=ref_artifact) + test_spec = ModelSpec(backend=policy.test_backend, device=test_device, artifact=test_artifact) + + verification_results = self.evaluator.verify( + reference=reference_spec, + test=test_spec, + data_loader=self.data_loader, + num_samples=num_verify_samples, + tolerance=tolerance, + verbose=False, + ) + + policy_key = f"{policy.ref_backend.value}_{ref_device}_vs_{policy.test_backend.value}_{test_device}" + all_results[policy_key] = verification_results + + if "summary" in verification_results: + summary = verification_results["summary"] + passed = summary.get("passed", 0) + failed = summary.get("failed", 0) + total_passed += passed + total_failed += failed + if failed == 0: + self.logger.info(f"Scenario {i+1} passed ({passed} comparisons)") + else: + self.logger.warning(f"Scenario {i+1} failed ({failed}/{passed+failed} comparisons)") + + self.logger.info("\n" + "=" * 80) + if total_failed == 0: + self.logger.info(f"All verifications passed! ({total_passed} total)") + else: + self.logger.warning(f"{total_failed}/{total_passed + total_failed} verifications failed") + self.logger.info("=" * 80) + + all_results["summary"] = { + "passed": total_passed, + "failed": total_failed, + "total": total_passed + total_failed, + } + + return all_results + + def _resolve_device(self, device_key: str, devices_map: Mapping[str, str]) -> str: + if device_key in devices_map: + return devices_map[device_key] + self.logger.warning(f"Device alias '{device_key}' not found in devices map, using as-is") + return device_key diff --git a/projects/CenterPoint/Dockerfile b/projects/CenterPoint/Dockerfile new file mode 100644 index 000000000..7b86c99eb --- /dev/null +++ b/projects/CenterPoint/Dockerfile @@ -0,0 +1,13 @@ +ARG AWML_BASE_IMAGE="autoware-ml:latest" +FROM ${AWML_BASE_IMAGE} +ARG TRT_VERSION=10.8.0.43 + +# Install pip dependencies +RUN python3 -m pip --no-cache-dir install \ + onnxruntime-gpu --extra-index-url https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/ \ + onnxsim \ + pycuda \ + tensorrt-cu12==${TRT_VERSION} + +WORKDIR /workspace +RUN pip install --no-cache-dir -e . diff --git a/projects/CenterPoint/README.md b/projects/CenterPoint/README.md index 6b265e890..cc26b910a 100644 --- a/projects/CenterPoint/README.md +++ b/projects/CenterPoint/README.md @@ -41,6 +41,11 @@ docker run -it --rm --gpus all --shm-size=64g --name awml -p 6006:6006 -v $PWD/:/workspace -v $PWD/data:/workspace/data autoware-ml ``` +For ONNX and TensorRT evaluation +```sh +docker run -it --rm --gpus all --shm-size=64g --name awml_deployment -p 6006:6006 -v $PWD/:/workspace -v $PWD/data:/workspace/data centerpoint-deployment:latest +``` + ### 2. Train #### 2.1 Environment set up @@ -110,12 +115,14 @@ where `frame-range` represents the range of frames to visualize. ### 5. Deploy -- Make an onnx file for a CenterPoint model. +- Run the unified deployment pipeline to export ONNX/TensorRT artifacts, verify them, and (optionally) evaluate. Update `deployment/projects/centerpoint/config/deploy_config.py` so that `checkpoint_path`, `runtime_io.info_file`, and `export.work_dir` point to your experiment (e.g., `checkpoint_path="work_dirs/centerpoint/t4dataset/second_secfpn_2xb8_121m_base/epoch_50.pth"`). ```sh -# Deploy for t4dataset -DIR="work_dirs/centerpoint/t4dataset/second_secfpn_2xb8_121m_base/" && -python projects/CenterPoint/scripts/deploy.py projects/CenterPoint/configs/t4dataset/second_secfpn_2xb8_121m_base.py $DIR/epoch_50.pth --replace_onnx_models --device gpu --rot_y_axis_reference +# Deploy for t4dataset (export + verification + evaluation) +python -m deployment.cli.main centerpoint \ + deployment/projects/centerpoint/config/deploy_config.py \ + projects/CenterPoint/configs/t4dataset/second_secfpn_2xb8_121m_base.py \ + --rot-y-axis-reference ``` where `rot_y_axis_reference` can be removed if we would like to use the original counterclockwise x-axis rotation system. diff --git a/projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_j6gen2_base.py b/projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_j6gen2_base.py index b3be5991d..e28796867 100644 --- a/projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_j6gen2_base.py +++ b/projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_j6gen2_base.py @@ -7,7 +7,7 @@ custom_imports["imports"] += _base_.custom_imports["imports"] custom_imports["imports"] += ["autoware_ml.detection3d.datasets.transforms"] custom_imports["imports"] += ["autoware_ml.hooks"] -custom_imports["imports"] += ["autoware_ml.backends.mlflowbackend"] +# custom_imports["imports"] += ["autoware_ml.backends.mlflowbackend"] # This is a base file for t4dataset, add the dataset config. # type, data_root and ann_file of data.train, data.val and data.test @@ -41,13 +41,13 @@ # user setting data_root = "data/t4dataset/" -info_directory_path = "info/user_name/" -train_gpu_size = 4 -train_batch_size = 16 -test_batch_size = 2 -num_workers = 32 +info_directory_path = "info/" +train_gpu_size = 1 +train_batch_size = 1 +test_batch_size = 1 +num_workers = 1 val_interval = 1 -max_epochs = 30 +max_epochs = 1 work_dir = "work_dirs/centerpoint/" + _base_.dataset_type + "/second_secfpn_4xb16_121m_j6gen2_base/" train_pipeline = [ @@ -384,19 +384,19 @@ if train_gpu_size > 1: sync_bn = "torch" -vis_backends = [ - dict(type="LocalVisBackend"), - dict(type="TensorboardVisBackend"), - # Update info accordingly - dict( - type="SafeMLflowVisBackend", - exp_name="(UserName) CenterPoint", - run_name="CenterPoint base", - tracking_uri="http://localhost:5000", - artifact_suffix=(), - ), -] -visualizer = dict(type="Det3DLocalVisualizer", vis_backends=vis_backends, name="visualizer") +# vis_backends = [ +# dict(type="LocalVisBackend"), +# dict(type="TensorboardVisBackend"), +# # Update info accordingly +# dict( +# type="SafeMLflowVisBackend", +# exp_name="(UserName) CenterPoint", +# run_name="CenterPoint base", +# tracking_uri="http://localhost:5000", +# artifact_suffix=(), +# ), +# ] +# visualizer = dict(type="Det3DLocalVisualizer", vis_backends=vis_backends, name="visualizer") logger_interval = 50 default_hooks = dict( diff --git a/projects/CenterPoint/models/__init__.py b/projects/CenterPoint/models/__init__.py index 13d1792cb..c713add79 100644 --- a/projects/CenterPoint/models/__init__.py +++ b/projects/CenterPoint/models/__init__.py @@ -1,13 +1,10 @@ from .backbones.second import SECOND from .dense_heads.centerpoint_head import CenterHead, CustomSeparateHead -from .dense_heads.centerpoint_head_onnx import CenterHeadONNX, SeparateHeadONNX from .detectors.centerpoint import CenterPoint -from .detectors.centerpoint_onnx import CenterPointONNX from .losses.amp_gaussian_focal_loss import AmpGaussianFocalLoss from .necks.second_fpn import SECONDFPN from .task_modules.coders.centerpoint_bbox_coders import CenterPointBBoxCoder from .voxel_encoders.pillar_encoder import BackwardPillarFeatureNet -from .voxel_encoders.pillar_encoder_onnx import BackwardPillarFeatureNetONNX, PillarFeatureNetONNX __all__ = [ "SECOND", @@ -16,11 +13,6 @@ "CenterHead", "CustomSeparateHead", "BackwardPillarFeatureNet", - "PillarFeatureNetONNX", - "BackwardPillarFeatureNetONNX", - "CenterPointONNX", - "CenterHeadONNX", - "SeparateHeadONNX", "CenterPointBBoxCoder", "AmpGaussianFocalLoss", ] diff --git a/projects/CenterPoint/models/detectors/centerpoint_onnx.py b/projects/CenterPoint/models/detectors/centerpoint_onnx.py deleted file mode 100644 index ff568a00d..000000000 --- a/projects/CenterPoint/models/detectors/centerpoint_onnx.py +++ /dev/null @@ -1,176 +0,0 @@ -import os -from typing import Callable, Dict, List, Tuple - -import torch -from mmdet3d.models.detectors.centerpoint import CenterPoint -from mmdet3d.registry import MODELS -from mmengine.logging import MMLogger, print_log -from torch import nn - - -class CenterPointHeadONNX(nn.Module): - """Head module for centerpoint with BACKBONE, NECK and BBOX_HEAD""" - - def __init__(self, backbone: nn.Module, neck: nn.Module, bbox_head: nn.Module): - super(CenterPointHeadONNX, self).__init__() - self.backbone: nn.Module = backbone - self.neck: nn.Module = neck - self.bbox_head: nn.Module = bbox_head - self._logger = MMLogger.get_current_instance() - self._logger.info("Running CenterPointHeadONNX!") - - def forward(self, x: torch.Tensor) -> Tuple[List[Dict[str, torch.Tensor]]]: - """ - Note: - torch.onnx.export() doesn't support triple-nested output - - Args: - x (torch.Tensor): (B, C, H, W) - Returns: - tuple[list[dict[str, any]]]: - (num_classes x [num_detect x {'reg', 'height', 'dim', 'rot', 'vel', 'heatmap'}]) - """ - x = self.backbone(x) - if self.neck is not None: - x = self.neck(x) - x = self.bbox_head(x) - - return x - - -@MODELS.register_module() -class CenterPointONNX(CenterPoint): - """onnx support impl of mmdet3d.models.detectors.CenterPoint""" - - def __init__(self, point_channels: int = 5, device: str = "cpu", **kwargs): - super().__init__(**kwargs) - self._point_channels = point_channels - self._device = device - self._torch_device = torch.device("cuda:0") if self._device == "gpu" else torch.device("cpu") - self._logger = MMLogger.get_current_instance() - self._logger.info("Running CenterPointONNX!") - - def _get_random_inputs(self): - """ - Generate random inputs and preprocess it to feed it to onnx. - """ - # Input channels - points = [ - torch.rand(1000, self._point_channels).to(self._torch_device), - # torch.rand(1000, self._point_channels).to(self._torch_device), - ] - # We only need lidar pointclouds for CenterPoint. - return {"points": points, "data_samples": None} - - def _extract_random_features(self): - assert self.data_preprocessor is not None and hasattr(self.data_preprocessor, "voxelize") - - # Get inputs - inputs = self._get_random_inputs() - voxel_dict = self.data_preprocessor.voxelize(points=inputs["points"], data_samples=inputs["data_samples"]) - assert self.pts_voxel_encoder is not None and hasattr(self.pts_voxel_encoder, "get_input_features") - input_features = self.pts_voxel_encoder.get_input_features( - voxel_dict["voxels"], voxel_dict["num_points"], voxel_dict["coors"] - ) - return input_features, voxel_dict - - def save_onnx( - self, - save_dir: str, - verbose=False, - onnx_opset_version=13, - ): - """Save onnx model - Args: - batch_dict (dict[str, any]) - save_dir (str): directory path to save onnx models - verbose (bool, optional) - onnx_opset_version (int, optional) - """ - print_log(f"Running onnx_opset_version: {onnx_opset_version}") - # Get features - input_features, voxel_dict = self._extract_random_features() - - # === pts_voxel_encoder === - pth_onnx_pve = os.path.join(save_dir, "pts_voxel_encoder.onnx") - torch.onnx.export( - self.pts_voxel_encoder, - (input_features,), - f=pth_onnx_pve, - input_names=("input_features",), - output_names=("pillar_features",), - dynamic_axes={ - "input_features": {0: "num_voxels", 1: "num_max_points"}, - "pillar_features": {0: "num_voxels"}, - }, - verbose=verbose, - opset_version=onnx_opset_version, - ) - print_log(f"Saved pts_voxel_encoder onnx model: {pth_onnx_pve}") - voxel_features = self.pts_voxel_encoder(input_features) - voxel_features = voxel_features.squeeze(1) - - # Note: pts_middle_encoder isn't exported - coors = voxel_dict["coors"] - batch_size = coors[-1, 0] + 1 - x = self.pts_middle_encoder(voxel_features, coors, batch_size) - # x (torch.tensor): (batch_size, num_pillar_features, W, H) - - # === pts_backbone === - assert self.pts_bbox_head is not None and hasattr(self.pts_bbox_head, "output_names") - pts_backbone_neck_head = CenterPointHeadONNX( - self.pts_backbone, - self.pts_neck, - self.pts_bbox_head, - ) - # pts_backbone_neck_head = torch.jit.script(pts_backbone_neck_head) - pth_onnx_backbone_neck_head = os.path.join(save_dir, "pts_backbone_neck_head.onnx") - torch.onnx.export( - pts_backbone_neck_head, - (x,), - f=pth_onnx_backbone_neck_head, - input_names=("spatial_features",), - output_names=tuple(self.pts_bbox_head.output_names), - dynamic_axes={ - name: {0: "batch_size", 2: "H", 3: "W"} - for name in ["spatial_features"] + self.pts_bbox_head.output_names - }, - verbose=verbose, - opset_version=onnx_opset_version, - ) - print_log(f"Saved pts_backbone_neck_head onnx model: {pth_onnx_backbone_neck_head}") - - def save_torchscript( - self, - save_dir: str, - verbose: bool = False, - ): - """Save torchscript model - Args: - batch_dict (dict[str, any]) - save_dir (str): directory path to save onnx models - verbose (bool, optional) - """ - # Get features - input_features, voxel_dict = self._extract_random_features() - - pth_pt_pve = os.path.join(save_dir, "pts_voxel_encoder.pt") - traced_pts_voxel_encoder = torch.jit.trace(self.pts_voxel_encoder, (input_features,)) - traced_pts_voxel_encoder.save(pth_pt_pve) - - voxel_features = traced_pts_voxel_encoder(input_features) - voxel_features = voxel_features.squeeze() - - # Note: pts_middle_encoder isn't exported - coors = voxel_dict["coors"] - batch_size = coors[-1, 0] + 1 - x = self.pts_middle_encoder(voxel_features, coors, batch_size) - - pts_backbone_neck_head = CenterPointHeadONNX( - self.pts_backbone, - self.pts_neck, - self.pts_bbox_head, - ) - pth_pt_head = os.path.join(save_dir, "pts_backbone_neck_head.pt") - traced_pts_backbone_neck_head = torch.jit.trace(pts_backbone_neck_head, (x)) - traced_pts_backbone_neck_head.save(pth_pt_head) diff --git a/projects/CenterPoint/runners/deployment_runner.py b/projects/CenterPoint/runners/deployment_runner.py deleted file mode 100644 index bbd703cbb..000000000 --- a/projects/CenterPoint/runners/deployment_runner.py +++ /dev/null @@ -1,103 +0,0 @@ -from pathlib import Path -from typing import Optional, Union - -from mmengine.registry import MODELS, init_default_scope -from torch import nn - -from autoware_ml.detection3d.runners.base_runner import BaseRunner - - -class DeploymentRunner(BaseRunner): - """Runner to run deploment of mmdet3D model to generate ONNX with random inputs.""" - - def __init__( - self, - model_cfg_path: str, - checkpoint_path: str, - work_dir: Path, - rot_y_axis_reference: bool = False, - device: str = "gpu", - replace_onnx_models: bool = False, - default_scope: str = "mmengine", - experiment_name: str = "", - log_level: Union[int, str] = "INFO", - log_file: Optional[str] = None, - onnx_opset_version: int = 13, - ) -> None: - """ - :param model_cfg_path: MMDet3D model config path. - :param checkpoint_path: Checkpoint path to load weights. - :param work_dir: Working directory to save outputs. - :param rot_y_axis_reference: Set True to convert rotation - from x-axis counterclockwiese to y-axis clockwise. - :param device: Working devices, only 'gpu' or 'cpu' supported. - :param replace_onnx_models: Set True to replace model with ONNX, - for example, CenterHead -> CenterHeadONNX. - :param default_scope: Default scope in mmdet3D. - :param experiment_name: Experiment name. - :param log_level: Logging and display log messages above this level. - :param log_file: Logger file. - :param oxx_opset_version: onnx opset version. - """ - super(DeploymentRunner, self).__init__( - model_cfg_path=model_cfg_path, - checkpoint_path=checkpoint_path, - work_dir=work_dir, - device=device, - default_scope=default_scope, - experiment_name=experiment_name, - log_level=log_level, - log_file=log_file, - ) - - # We need init deafault scope to mmdet3d to search registries in the mmdet3d scope - init_default_scope("mmdet3d") - - self._rot_y_axis_reference = rot_y_axis_reference - self._replace_onnx_models = replace_onnx_models - self._onnx_opset_version = onnx_opset_version - - def build_model(self) -> nn.Module: - """ - Build a model. Replace the model by ONNX model if replace_onnx_model is set. - :return torch.nn.Module. A torch module. - """ - self._logger.info("===== Building CenterPoint model ====") - model_cfg = self._cfg.get("model") - # Update Model type to ONNX - if self._replace_onnx_models: - self._logger.info("Replacing ONNX models!") - model_cfg.type = "CenterPointONNX" - model_cfg.point_channels = model_cfg.pts_voxel_encoder.in_channels - model_cfg.device = self._device - model_cfg.pts_voxel_encoder.type = ( - "PillarFeatureNetONNX" - if model_cfg.pts_voxel_encoder.type == "PillarFeatureNet" - else "BackwardPillarFeatureNetONNX" - ) - model_cfg.pts_bbox_head.type = "CenterHeadONNX" - model_cfg.pts_bbox_head.separate_head.type = "SeparateHeadONNX" - model_cfg.pts_bbox_head.rot_y_axis_reference = self._rot_y_axis_reference - - if model_cfg.pts_backbone.type == "ConvNeXt_PC": - # Always set with_cp (gradient checkpointing) to False for deployment - model_cfg.pts_backbone.with_cp = False - model = MODELS.build(model_cfg) - model.to(self._torch_device) - - self._logger.info(model) - self._logger.info("===== Built CenterPoint model ====") - return model - - def run(self) -> None: - """Start running the Runner.""" - # Building a model - model = self.build_model() - - # Loading checkpoint to the model - self.load_verify_checkpoint(model=model) - - assert hasattr(model, "save_onnx"), "The model must have the function: save_onnx()!" - - # Run and save onnx model! - model.save_onnx(save_dir=self._work_dir, onnx_opset_version=self._onnx_opset_version) diff --git a/projects/CenterPoint/scripts/deploy.py b/projects/CenterPoint/scripts/deploy.py deleted file mode 100644 index 3aea2ee29..000000000 --- a/projects/CenterPoint/scripts/deploy.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Script to export CenterPoint to onnx/torchscript -""" - -import argparse -import logging -import os -from pathlib import Path - -from projects.CenterPoint.runners.deployment_runner import DeploymentRunner - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Export CenterPoint model to backends.", - ) - parser.add_argument( - "model_cfg_path", - help="model config path", - ) - parser.add_argument( - "checkpoint", - help="model checkpoint path", - ) - parser.add_argument( - "--work-dir", - default="", - help="the dir to save logs and models", - ) - parser.add_argument( - "--log-level", - help="set log level", - default="INFO", - choices=list(logging._nameToLevel.keys()), - ) - parser.add_argument("--onnx_opset_version", type=int, default=13, help="onnx opset version") - parser.add_argument( - "--device", - choices=["cpu", "gpu"], - default="gpu", - help="Set running device!", - ) - parser.add_argument( - "--replace_onnx_models", - action="store_true", - help="Set False to disable replacement of model by ONNX model, for example, CenterHead -> CenterHeadONNX", - ) - parser.add_argument( - "--rot_y_axis_reference", - action="store_true", - help="Set True to output rotation in y-axis clockwise in CenterHeadONNX", - ) - args = parser.parse_args() - return args - - -def build_deploy_runner(args) -> DeploymentRunner: - """Build a DeployRunner.""" - model_cfg_path = args.model_cfg_path - checkpoint_path = args.checkpoint - experiment_name = Path(model_cfg_path).stem - work_dir = ( - Path(os.getcwd()) / "work_dirs" / "deployment" / experiment_name if not args.work_dir else Path(args.work_dir) - ) - - deployment_runner = DeploymentRunner( - experiment_name=experiment_name, - model_cfg_path=model_cfg_path, - checkpoint_path=checkpoint_path, - work_dir=work_dir, - replace_onnx_models=args.replace_onnx_models, - device=args.device, - rot_y_axis_reference=args.rot_y_axis_reference, - onnx_opset_version=args.onnx_opset_version, - ) - return deployment_runner - - -if __name__ == "__main__": - """Launch a DeployRunner.""" - args = parse_args() - - # Build DeploymentRunner - deployment_runner = build_deploy_runner(args=args) - - # Start running DeploymentRunner - deployment_runner.run()