From 390963ae24d2694494cc4f82046a91a5fbe524ae Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 9 Oct 2025 00:12:34 +0900 Subject: [PATCH 01/62] chore: init Signed-off-by: vividf --- deployment/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 deployment/README.md diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 000000000..e69de29bb From e2be793d102de7bffc2285ea51514f214aae8f9d Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 9 Oct 2025 00:18:41 +0900 Subject: [PATCH 02/62] chore: add deployment framework intro Signed-off-by: vividf --- autoware_ml/deployment/README.md | 99 ++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 autoware_ml/deployment/README.md diff --git a/autoware_ml/deployment/README.md b/autoware_ml/deployment/README.md new file mode 100644 index 000000000..dc63d4473 --- /dev/null +++ b/autoware_ml/deployment/README.md @@ -0,0 +1,99 @@ +# Autoware ML Deployment Framework + +A unified, task-agnostic deployment framework for exporting, verifying, and evaluating machine learning models across different backends (ONNX, TensorRT). + +## Architecture Overview + +``` +Deployment Framework +├── Core Abstractions +│ ├── BaseDataLoader # Task-specific data loading +│ ├── BaseEvaluator # Task-specific evaluation +│ ├── BaseBackend # Unified inference interface +│ └── BaseDeploymentConfig # Configuration management +│ +├── Backends +│ ├── PyTorchBackend # PyTorch inference +│ ├── ONNXBackend # ONNX Runtime inference +│ └── TensorRTBackend # TensorRT inference +│ +├── Exporters +│ ├── ONNXExporter # PyTorch → ONNX +│ └── TensorRTExporter # ONNX → TensorRT +│ +└── Project Implementations + ├── CalibrationStatusClassification/deploy/ + ├── YOLOX/deploy/ + └── CenterPoint/deploy/ +``` + +--- + +## 🚀 Quick Start + +### For Implemented Projects + +#### CalibrationStatusClassification + +```bash +python projects/CalibrationStatusClassification/deploy/main.py \ + projects/CalibrationStatusClassification/deploy/deploy_config.py \ + projects/CalibrationStatusClassification/configs/t4dataset/resnet18_5ch_1xb16-50e_j6gen2.py \ + checkpoint.pth \ + --work-dir work_dirs/deployment +``` + +See `projects/CalibrationStatusClassification/deploy/README.md` for details. + +--- + +## 📚 Documentation + +- **Design Document**: `/docs/design/deploy_pipeline_design.md` +- **Architecture**: See above +- **Per-Project Guides**: `projects/{PROJECT}/deploy/README.md` + +--- + +## 🔧 Development Guidelines + +### Adding a New Project + +1. **Create deploy directory**: `projects/{PROJECT}/deploy/` + +2. **Implement DataLoader**: + ```python + from autoware_ml.deployment.core import BaseDataLoader + + class YourDataLoader(BaseDataLoader): + def load_sample(self, index: int) -> Dict[str, Any]: + # Load raw data + pass + + def preprocess(self, sample: Dict[str, Any]) -> torch.Tensor: + # Preprocess for model input + pass + + def get_num_samples(self) -> int: + return len(self.data) + ``` + +3. **Implement Evaluator**: + ```python + from autoware_ml.deployment.core import BaseEvaluator + + class YourEvaluator(BaseEvaluator): + def evaluate(self, model_path, data_loader, ...): + # Run inference and compute metrics + pass + + def print_results(self, results): + # Pretty print results + pass + ``` + +4. **Create deployment config** (`deploy_config.py`) + +5. **Create main script** (`main.py`) + +6. **Test and document** From aa21e2c33fa832caf611b70aa7c11f92fe943976 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 14 Nov 2025 12:27:57 +0900 Subject: [PATCH 03/62] feat: prototype deployment framework Signed-off-by: vividf --- autoware_ml/deployment/README.md | 723 ++++++++++++-- autoware_ml/deployment/__init__.py | 23 + autoware_ml/deployment/core/__init__.py | 37 + autoware_ml/deployment/core/base_config.py | 345 +++++++ .../deployment/core/base_data_loader.py | 68 ++ autoware_ml/deployment/core/base_evaluator.py | 170 ++++ autoware_ml/deployment/core/base_pipeline.py | 283 ++++++ .../core/classification_pipeline.py | 160 ++++ .../deployment/core/detection_2d_pipeline.py | 173 ++++ .../deployment/core/detection_3d_pipeline.py | 173 ++++ .../deployment/core/preprocessing_builder.py | 312 +++++++ autoware_ml/deployment/exporters/__init__.py | 29 + .../deployment/exporters/base_exporter.py | 114 +++ .../deployment/exporters/model_wrappers.py | 108 +++ .../deployment/exporters/onnx_exporter.py | 174 ++++ .../deployment/exporters/tensorrt_exporter.py | 235 +++++ autoware_ml/deployment/pipelines/__init__.py | 47 + .../pipelines/calibration/__init__.py | 0 .../pipelines/calibration/calibration_onnx.py | 0 .../calibration/calibration_pipeline.py | 0 .../calibration/calibration_pytorch.py | 0 .../calibration/calibration_tensorrt.py | 0 .../pipelines/centerpoint/__init__.py | 0 .../pipelines/centerpoint/centerpoint_onnx.py | 0 .../centerpoint/centerpoint_pipeline.py | 0 .../centerpoint/centerpoint_pytorch.py | 0 .../centerpoint/centerpoint_tensorrt.py | 0 .../deployment/pipelines/yolox/__init__.py | 0 .../deployment/pipelines/yolox/yolox_onnx.py | 0 .../pipelines/yolox/yolox_pipeline.py | 0 .../pipelines/yolox/yolox_pytorch.py | 0 .../pipelines/yolox/yolox_tensorrt.py | 0 autoware_ml/deployment/runners/__init__.py | 7 + .../deployment/runners/deployment_runner.py | 882 ++++++++++++++++++ 34 files changed, 3998 insertions(+), 65 deletions(-) create mode 100644 autoware_ml/deployment/__init__.py create mode 100644 autoware_ml/deployment/core/__init__.py create mode 100644 autoware_ml/deployment/core/base_config.py create mode 100644 autoware_ml/deployment/core/base_data_loader.py create mode 100644 autoware_ml/deployment/core/base_evaluator.py create mode 100644 autoware_ml/deployment/core/base_pipeline.py create mode 100644 autoware_ml/deployment/core/classification_pipeline.py create mode 100644 autoware_ml/deployment/core/detection_2d_pipeline.py create mode 100644 autoware_ml/deployment/core/detection_3d_pipeline.py create mode 100644 autoware_ml/deployment/core/preprocessing_builder.py create mode 100644 autoware_ml/deployment/exporters/__init__.py create mode 100644 autoware_ml/deployment/exporters/base_exporter.py create mode 100644 autoware_ml/deployment/exporters/model_wrappers.py create mode 100644 autoware_ml/deployment/exporters/onnx_exporter.py create mode 100644 autoware_ml/deployment/exporters/tensorrt_exporter.py create mode 100644 autoware_ml/deployment/pipelines/__init__.py rename deployment/README.md => autoware_ml/deployment/pipelines/calibration/__init__.py (100%) create mode 100644 autoware_ml/deployment/pipelines/calibration/calibration_onnx.py create mode 100644 autoware_ml/deployment/pipelines/calibration/calibration_pipeline.py create mode 100644 autoware_ml/deployment/pipelines/calibration/calibration_pytorch.py create mode 100644 autoware_ml/deployment/pipelines/calibration/calibration_tensorrt.py create mode 100644 autoware_ml/deployment/pipelines/centerpoint/__init__.py create mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_onnx.py create mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_pipeline.py create mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_pytorch.py create mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_tensorrt.py create mode 100644 autoware_ml/deployment/pipelines/yolox/__init__.py create mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_onnx.py create mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_pipeline.py create mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_pytorch.py create mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_tensorrt.py create mode 100644 autoware_ml/deployment/runners/__init__.py create mode 100644 autoware_ml/deployment/runners/deployment_runner.py diff --git a/autoware_ml/deployment/README.md b/autoware_ml/deployment/README.md index dc63d4473..9c580f836 100644 --- a/autoware_ml/deployment/README.md +++ b/autoware_ml/deployment/README.md @@ -1,99 +1,692 @@ -# Autoware ML Deployment Framework +# AWML Deployment Framework -A unified, task-agnostic deployment framework for exporting, verifying, and evaluating machine learning models across different backends (ONNX, TensorRT). +A unified, task-agnostic deployment framework for exporting PyTorch models to ONNX and TensorRT, with comprehensive verification and evaluation capabilities. -## Architecture Overview +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Key Features](#key-features) +- [Usage](#usage) +- [Configuration](#configuration) +- [Project-Specific Implementations](#project-specific-implementations) +- [Pipeline Architecture](#pipeline-architecture) +- [Export Workflow](#export-workflow) +- [Verification & Evaluation](#verification--evaluation) + +--- + +## Overview + +The AWML Deployment Framework provides a standardized approach to model deployment across different projects (CenterPoint, YOLOX, CalibrationStatusClassification). It abstracts common deployment workflows while allowing project-specific customizations. + +### Design Principles + +1. **Unified Interface**: Single entry point (`DeploymentRunner`) for all deployment tasks +2. **Task-Agnostic Core**: Base classes that work across detection, classification, and segmentation +3. **Backend Flexibility**: Support for PyTorch, ONNX, and TensorRT backends +4. **Pipeline Architecture**: Shared preprocessing/postprocessing with backend-specific inference +5. **Configuration-Driven**: All settings controlled via config files + +--- + +## Architecture + +### High-Level Architecture ``` -Deployment Framework -├── Core Abstractions -│ ├── BaseDataLoader # Task-specific data loading -│ ├── BaseEvaluator # Task-specific evaluation -│ ├── BaseBackend # Unified inference interface -│ └── BaseDeploymentConfig # Configuration management -│ -├── Backends -│ ├── PyTorchBackend # PyTorch inference -│ ├── ONNXBackend # ONNX Runtime inference -│ └── TensorRTBackend # TensorRT inference -│ -├── Exporters -│ ├── ONNXExporter # PyTorch → ONNX -│ └── TensorRTExporter # ONNX → TensorRT -│ -└── Project Implementations - ├── CalibrationStatusClassification/deploy/ - ├── YOLOX/deploy/ - └── CenterPoint/deploy/ +┌─────────────────────────────────────────────────────────┐ +│ Project Entry Points │ +│ (projects/*/deploy/main.py) │ +│ - CenterPoint, YOLOX-ELAN, Calibration │ +└──────────────────┬──────────────────────────────────────┘ + │ +┌──────────────────▼──────────────────────────────────────┐ +│ DeploymentRunner (Unified Runner) │ +│ - Coordinates export → verification → evaluation │ +│ - Manages model loading, export, verification │ +└──────────────────┬──────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + │ │ +┌───────▼────────┐ ┌────────▼────────┐ +│ Exporters │ │ Evaluators │ +│ - ONNX │ │ - Task-specific│ +│ - TensorRT │ │ - Metrics │ +└────────────────┘ └─────────────────┘ + │ │ + └──────────┬──────────┘ + │ +┌──────────────────▼──────────────────────────────────────┐ +│ Pipeline Architecture │ +│ - BaseDeploymentPipeline │ +│ - Task-specific pipelines (Detection2D/3D, Classify) │ +│ - Backend-specific implementations │ +└──────────────────────────────────────────────────────────┘ ``` +### Core Components + +#### 1. **DeploymentRunner** +The unified runner that orchestrates the complete deployment workflow: + +- **Model Loading**: Loads PyTorch models from checkpoints +- **Export**: Exports to ONNX and/or TensorRT +- **Verification**: Scenario-based verification across backends +- **Evaluation**: Performance metrics and latency statistics + +#### 2. **Base Classes** + +- **`BaseDeploymentConfig`**: Configuration container for deployment settings +- **`BaseEvaluator`**: Abstract interface for task-specific evaluation +- **`BaseDataLoader`**: Abstract interface for data loading +- **`BaseDeploymentPipeline`**: Abstract pipeline for inference + +#### 3. **Exporters** + +- **`ONNXExporter`**: Standard ONNX export with model wrapping support +- **`TensorRTExporter`**: TensorRT engine building with precision policies +- **Project-specific exporters**: Custom exporters for complex models (e.g., CenterPoint) + +#### 4. **Pipelines** + +- **`BaseDeploymentPipeline`**: Abstract base with `preprocess() → run_model() → postprocess()` +- **Task-specific pipelines**: `Detection2DPipeline`, `Detection3DPipeline`, `ClassificationPipeline` +- **Backend implementations**: PyTorch, ONNX, TensorRT variants for each pipeline + --- -## 🚀 Quick Start +## Key Features + +### 1. Unified Deployment Workflow + +All projects follow the same workflow: +``` +Load Model → Export ONNX → Export TensorRT → Verify → Evaluate +``` + +### 2. Scenario-Based Verification + +Flexible verification system that compares outputs between backends: + +```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"}, + ] + } +) +``` + +### 3. Multi-Backend Evaluation + +Evaluate models across multiple backends with consistent metrics: + +```python +evaluation = dict( + enabled=True, + backends={ + "pytorch": {"enabled": True, "device": "cpu"}, + "onnx": {"enabled": True, "device": "cpu"}, + "tensorrt": {"enabled": True, "device": "cuda:0"}, + } +) +``` + +### 4. Pipeline Architecture + +Shared preprocessing/postprocessing with backend-specific inference: + +- **Preprocessing**: Image resize, normalization, voxelization (shared) +- **Model Inference**: Backend-specific (PyTorch/ONNX/TensorRT) +- **Postprocessing**: NMS, coordinate transform, decoding (shared) + +### 5. Flexible Export Modes + +- `mode="onnx"`: Export PyTorch → ONNX only +- `mode="trt"`: Build TensorRT from existing ONNX +- `mode="both"`: Full export pipeline +- `mode="none"`: Skip export (evaluation only) + +### 6. Precision Policies for TensorRT -### For Implemented Projects +Support for different TensorRT precision modes: -#### CalibrationStatusClassification +- `auto`: TensorRT decides automatically +- `fp16`: FP16 precision +- `fp32_tf32`: FP32 with TF32 acceleration +- `explicit_int8`: INT8 quantization + +--- + +## Usage + +### Basic Usage ```bash +# CenterPoint deployment +python projects/CenterPoint/deploy/main.py \ + configs/deploy_config.py \ + configs/model_config.py \ + checkpoint.pth + +# YOLOX deployment +python projects/YOLOX_opt_elan/deploy/main.py \ + configs/deploy_config.py \ + configs/model_config.py \ + checkpoint.pth + +# Calibration deployment python projects/CalibrationStatusClassification/deploy/main.py \ - projects/CalibrationStatusClassification/deploy/deploy_config.py \ - projects/CalibrationStatusClassification/configs/t4dataset/resnet18_5ch_1xb16-50e_j6gen2.py \ - checkpoint.pth \ - --work-dir work_dirs/deployment + configs/deploy_config.py \ + configs/model_config.py \ + checkpoint.pth ``` -See `projects/CalibrationStatusClassification/deploy/README.md` for details. +### Command-Line Arguments + +```bash +python deploy/main.py \ + \ # Deployment configuration file + \ # Model configuration file + [checkpoint] \ # Optional: checkpoint path (can be in config) + --work-dir \ # Optional: override work directory + --device \ # Optional: override device + --log-level # Optional: logging level (DEBUG, INFO, WARNING, ERROR) +``` + +### Export Modes + +#### Export ONNX Only +```python +export = dict( + mode="onnx", + checkpoint_path="model.pth", + work_dir="work_dirs/deployment", +) +``` + +#### Build 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 +export = dict( + mode="both", + checkpoint_path="model.pth", + work_dir="work_dirs/deployment", +) +``` + +#### Evaluation Only (No Export) +```python +export = dict( + mode="none", + work_dir="work_dirs/deployment", +) +``` --- -## 📚 Documentation +## Configuration + +### Configuration Structure + +```python +# Task type +task_type = "detection3d" # or "detection2d", "classification" + +# Export configuration +export = dict( + mode="both", # "onnx", "trt", "both", "none" + work_dir="work_dirs/deployment", + checkpoint_path="model.pth", + onnx_path=None, # Optional: for mode="trt" +) -- **Design Document**: `/docs/design/deploy_pipeline_design.md` -- **Architecture**: See above -- **Per-Project Guides**: `projects/{PROJECT}/deploy/README.md` +# Runtime I/O settings +runtime_io = dict( + info_file="data/info.pkl", # Dataset info file + sample_idx=0, # Sample index for export +) + +# Model I/O configuration +model_io = dict( + input_name="input", + input_shape=(3, 960, 960), # (C, H, W) + input_dtype="float32", + output_name="output", + batch_size=1, # or None for dynamic + dynamic_axes={...}, # When batch_size=None +) + +# ONNX configuration +onnx_config = dict( + opset_version=16, + do_constant_folding=True, + save_file="model.onnx", + multi_file=False, # True for multi-file ONNX (e.g., CenterPoint) +) + +# Backend configuration +backend_config = dict( + common_config=dict( + precision_policy="auto", # "auto", "fp16", "fp32_tf32", "explicit_int8" + max_workspace_size=1 << 30, # 1 GB + ), +) + +# Verification configuration +verification = dict( + enabled=True, + num_verify_samples=3, + tolerance=0.1, + devices={ + "cpu": "cpu", + "cuda": "cuda:0", + }, + scenarios={ + "both": [ + {"ref_backend": "pytorch", "ref_device": "cpu", + "test_backend": "onnx", "test_device": "cpu"}, + ] + } +) + +# Evaluation configuration +evaluation = dict( + enabled=True, + num_samples=100, # or -1 for all samples + verbose=False, + backends={ + "pytorch": {"enabled": True, "device": "cpu"}, + "onnx": {"enabled": True, "device": "cpu"}, + "tensorrt": {"enabled": True, "device": "cuda:0"}, + } +) +``` + +### Configuration Examples + +See project-specific configs: +- `projects/CenterPoint/deploy/configs/deploy_config.py` +- `projects/YOLOX_opt_elan/deploy/configs/deploy_config.py` +- `projects/CalibrationStatusClassification/deploy/configs/deploy_config.py` --- -## 🔧 Development Guidelines +## Project-Specific Implementations + +### CenterPoint (3D Detection) + +**Features:** +- Multi-file ONNX export (voxel encoder + backbone/head) +- ONNX-compatible model configuration +- Custom exporters for complex model structure -### Adding a New Project +**Key Files:** +- `projects/CenterPoint/deploy/main.py` +- `projects/CenterPoint/deploy/evaluator.py` +- `autoware_ml/deployment/pipelines/centerpoint/` -1. **Create deploy directory**: `projects/{PROJECT}/deploy/` +**Pipeline Structure:** +``` +preprocess() → run_voxel_encoder() → process_middle_encoder() → +run_backbone_head() → postprocess() +``` + +### YOLOX (2D Detection) + +**Features:** +- Standard single-file ONNX export +- Model wrapper for ONNX-compatible output format +- ReLU6 → ReLU replacement for ONNX compatibility + +**Key Files:** +- `projects/YOLOX_opt_elan/deploy/main.py` +- `projects/YOLOX_opt_elan/deploy/evaluator.py` +- `autoware_ml/deployment/pipelines/yolox/` -2. **Implement DataLoader**: - ```python - from autoware_ml.deployment.core import BaseDataLoader +**Pipeline Structure:** +``` +preprocess() → run_model() → postprocess() +``` - class YourDataLoader(BaseDataLoader): - def load_sample(self, index: int) -> Dict[str, Any]: - # Load raw data - pass +### CalibrationStatusClassification - def preprocess(self, sample: Dict[str, Any]) -> torch.Tensor: - # Preprocess for model input - pass +**Features:** +- Binary classification task +- Simple single-file ONNX export +- Calibrated/miscalibrated data loader variants - def get_num_samples(self) -> int: - return len(self.data) - ``` +**Key Files:** +- `projects/CalibrationStatusClassification/deploy/main.py` +- `projects/CalibrationStatusClassification/deploy/evaluator.py` +- `autoware_ml/deployment/pipelines/calibration/` -3. **Implement Evaluator**: - ```python - from autoware_ml.deployment.core import BaseEvaluator +**Pipeline Structure:** +``` +preprocess() → run_model() → postprocess() +``` - class YourEvaluator(BaseEvaluator): - def evaluate(self, model_path, data_loader, ...): - # Run inference and compute metrics - pass +--- - def print_results(self, results): - # Pretty print results - pass - ``` +## Pipeline Architecture -4. **Create deployment config** (`deploy_config.py`) +### Base Pipeline + +All pipelines inherit from `BaseDeploymentPipeline`: + +```python +class BaseDeploymentPipeline(ABC): + @abstractmethod + def preprocess(self, input_data, **kwargs) -> Any: + """Preprocess input data""" + pass + + @abstractmethod + def run_model(self, preprocessed_input) -> Any: + """Backend-specific model inference""" + pass + + @abstractmethod + def postprocess(self, model_output, metadata) -> Any: + """Postprocess model output""" + pass + + def infer(self, input_data, **kwargs): + """Complete inference pipeline""" + preprocessed = self.preprocess(input_data, **kwargs) + model_output = self.run_model(preprocessed) + predictions = self.postprocess(model_output, metadata) + return predictions +``` + +### Task-Specific Pipelines + +#### Detection2DPipeline +- Shared preprocessing: image resize, normalization, padding +- Shared postprocessing: bbox decoding, NMS, coordinate transform +- Backend-specific: model inference + +#### Detection3DPipeline +- Shared preprocessing: voxelization, feature extraction +- Shared postprocessing: 3D bbox decoding, NMS +- Backend-specific: voxel encoder, backbone/head inference + +#### ClassificationPipeline +- Shared preprocessing: image normalization +- Shared postprocessing: softmax, top-k selection +- Backend-specific: model inference + +### Backend Implementations + +Each pipeline has three backend implementations: + +1. **PyTorch Pipeline**: Direct PyTorch model inference +2. **ONNX Pipeline**: ONNX Runtime inference +3. **TensorRT Pipeline**: TensorRT engine inference + +Example for YOLOX: +- `YOLOXPyTorchPipeline`: Uses PyTorch model directly +- `YOLOXONNXPipeline`: Uses ONNX Runtime +- `YOLOXTensorRTPipeline`: Uses TensorRT engine + +--- + +## Export Workflow + +### ONNX Export + +1. **Model Preparation**: Load PyTorch model, apply model wrapper if needed +2. **Input Preparation**: Get sample input from data loader +3. **Export**: Call `torch.onnx.export()` with configured settings +4. **Simplification**: Optional ONNX model simplification +5. **Save**: Save to `work_dir/onnx/` + +### TensorRT Export + +1. **ONNX Validation**: Verify ONNX model exists +2. **Network Creation**: Parse ONNX, create TensorRT network +3. **Precision Configuration**: Apply precision policy flags +4. **Optimization Profile**: Configure input shape ranges +5. **Engine Building**: Build and save TensorRT engine +6. **Save**: Save to `work_dir/tensorrt/` + +### Multi-File Export (CenterPoint) + +CenterPoint uses a multi-file ONNX structure: +- `voxel_encoder.onnx`: Voxel feature extraction +- `backbone_head.onnx`: Backbone and detection head + +The exporter handles: +- Sequential export of each component +- Proper input/output linking +- Directory-based organization + +--- + +## Verification & Evaluation + +### Verification + +Policy-based verification compares outputs between backends: + +```python +# Verification scenarios example +verification = dict( + enabled=True, + scenarios={ + "both": [ + { + "ref_backend": "pytorch", + "ref_device": "cpu", + "test_backend": "onnx", + "test_device": "cpu" + } + ] + }, + tolerance=0.1, # Maximum allowed difference + num_verify_samples=3 +) +``` + +**Verification Process:** +1. Load reference model (PyTorch or ONNX) +2. Load test model (ONNX or TensorRT) +3. Run inference on same samples +4. Compare outputs with tolerance +5. Report pass/fail for each sample + +### Evaluation + +Task-specific evaluation with consistent metrics: + +**Detection Tasks:** +- mAP (mean Average Precision) +- Per-class AP +- Latency statistics + +**Classification Tasks:** +- Accuracy +- Precision, Recall +- Per-class metrics +- Confusion matrix +- Latency statistics + +**Evaluation Configuration:** +```python +evaluation = dict( + enabled=True, + num_samples=100, # or -1 for all + verbose=False, + backends={ + "pytorch": {"enabled": True, "device": "cpu"}, + "onnx": {"enabled": True, "device": "cpu"}, + "tensorrt": {"enabled": True, "device": "cuda:0"}, + } +) +``` + +--- + +## File Structure + +``` +autoware_ml/deployment/ +├── core/ # Core base classes +│ ├── base_config.py # Configuration management +│ ├── base_data_loader.py # Data loader interface +│ ├── base_evaluator.py # Evaluator interface +│ ├── base_pipeline.py # Pipeline base class +│ ├── detection_2d_pipeline.py # 2D detection pipeline +│ ├── detection_3d_pipeline.py # 3D detection pipeline +│ └── classification_pipeline.py # Classification pipeline +│ +├── exporters/ # Model exporters +│ ├── base_exporter.py # Exporter base class +│ ├── onnx_exporter.py # ONNX exporter +│ ├── tensorrt_exporter.py # TensorRT exporter +│ ├── centerpoint_exporter.py # CenterPoint-specific exporters +│ └── model_wrappers.py # Model wrappers for ONNX +│ +├── pipelines/ # Task-specific pipelines +│ ├── centerpoint/ # CenterPoint pipelines +│ │ ├── centerpoint_pipeline.py +│ │ ├── centerpoint_pytorch.py +│ │ ├── centerpoint_onnx.py +│ │ └── centerpoint_tensorrt.py +│ ├── yolox/ # YOLOX pipelines +│ │ ├── yolox_pipeline.py +│ │ ├── yolox_pytorch.py +│ │ ├── yolox_onnx.py +│ │ └── yolox_tensorrt.py +│ └── calibration/ # Calibration pipelines +│ ├── calibration_pipeline.py +│ ├── calibration_pytorch.py +│ ├── calibration_onnx.py +│ └── calibration_tensorrt.py +│ +└── runners/ # Deployment runners + └── deployment_runner.py # Unified deployment runner +projects/ +├── CenterPoint/deploy/ +│ ├── main.py # Entry point +│ ├── evaluator.py # CenterPoint evaluator +│ ├── data_loader.py # CenterPoint data loader +│ └── configs/ +│ └── deploy_config.py # Deployment configuration +│ +├── YOLOX_opt_elan/deploy/ +│ ├── main.py +│ ├── evaluator.py +│ ├── data_loader.py +│ └── configs/ +│ └── deploy_config.py +│ +└── CalibrationStatusClassification/deploy/ + ├── main.py + ├── evaluator.py + ├── data_loader.py + └── configs/ + └── deploy_config.py +``` + +--- + +## Best Practices + +### 1. Configuration Management + +- Keep deployment configs separate from model configs +- Use relative paths for data files +- Document all configuration options + +### 2. Model Export + +- Always verify ONNX export before TensorRT conversion +- Use appropriate precision policies for TensorRT +- Test with multiple samples during export + +### 3. Verification + +- Start with small tolerance (0.01) and increase if needed +- Verify on representative samples +- Check both accuracy and numerical differences + +### 4. Evaluation + +- Use consistent evaluation settings across backends +- Report latency statistics (mean, std, min, max) +- Compare metrics across backends + +### 5. Pipeline Development + +- Inherit from appropriate base pipeline +- Share preprocessing/postprocessing logic +- Keep backend-specific code minimal + +--- + +## Troubleshooting + +### Common Issues + +1. **ONNX Export Fails** + - Check model compatibility (unsupported ops) + - Verify input shapes match model expectations + - Try different opset versions + +2. **TensorRT Build Fails** + - Verify ONNX model is valid + - Check input shape configuration + - Reduce workspace size if memory issues + +3. **Verification Fails** + - Check tolerance settings + - Verify same preprocessing for all backends + - Check device compatibility + +4. **Evaluation Errors** + - Verify data loader paths + - Check model output format + - Ensure correct task type in config + +--- + +## Future Enhancements + +- [ ] Support for more task types (segmentation, etc.) +- [ ] Automatic precision tuning for TensorRT +- [ ] Distributed evaluation support +- [ ] Integration with MLOps pipelines +- [ ] Performance profiling tools + +--- + +## Contributing + +When adding a new project: + +1. Create project-specific evaluator and data loader +2. Implement task-specific pipeline (if needed) +3. Create deployment configuration +4. Add entry point script +5. Update documentation + +--- -5. **Create main script** (`main.py`) +## License -6. **Test and document** +See LICENSE file in project root. \ No newline at end of file diff --git a/autoware_ml/deployment/__init__.py b/autoware_ml/deployment/__init__.py new file mode 100644 index 000000000..97e14a757 --- /dev/null +++ b/autoware_ml/deployment/__init__.py @@ -0,0 +1,23 @@ +""" +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, TorchScript, etc.). +""" + +from .core.base_config import BaseDeploymentConfig +from .core.base_data_loader import BaseDataLoader +from .core.base_evaluator import BaseEvaluator +from .core.preprocessing_builder import build_preprocessing_pipeline +from .runners import DeploymentRunner + +__all__ = [ + "BaseDeploymentConfig", + "BaseDataLoader", + "BaseEvaluator", + "DeploymentRunner", + "build_preprocessing_pipeline", +] + +__version__ = "1.0.0" \ No newline at end of file diff --git a/autoware_ml/deployment/core/__init__.py b/autoware_ml/deployment/core/__init__.py new file mode 100644 index 000000000..47c5130cf --- /dev/null +++ b/autoware_ml/deployment/core/__init__.py @@ -0,0 +1,37 @@ +"""Core components for deployment framework.""" + +from .base_config import ( + BackendConfig, + BaseDeploymentConfig, + ExportConfig, + RuntimeConfig, + parse_base_args, + setup_logging, +) +from .base_data_loader import BaseDataLoader +from .base_evaluator import BaseEvaluator +from .base_pipeline import BaseDeploymentPipeline +from .detection_2d_pipeline import Detection2DPipeline +from .detection_3d_pipeline import Detection3DPipeline +from .classification_pipeline import ClassificationPipeline +from .preprocessing_builder import ( + build_preprocessing_pipeline, + register_preprocessing_builder, +) + +__all__ = [ + "BaseDeploymentConfig", + "ExportConfig", + "RuntimeConfig", + "BackendConfig", + "setup_logging", + "parse_base_args", + "BaseDataLoader", + "BaseEvaluator", + "BaseDeploymentPipeline", + "Detection2DPipeline", + "Detection3DPipeline", + "ClassificationPipeline", + "build_preprocessing_pipeline", + "register_preprocessing_builder", +] \ No newline at end of file diff --git a/autoware_ml/deployment/core/base_config.py b/autoware_ml/deployment/core/base_config.py new file mode 100644 index 000000000..fa0c2a235 --- /dev/null +++ b/autoware_ml/deployment/core/base_config.py @@ -0,0 +1,345 @@ +""" +Base configuration classes for deployment framework. +This module provides the foundation for task-agnostic deployment configuration. +Task-specific deployment configs should extend BaseDeploymentConfig. +""" + +import argparse +import logging +from typing import Any, Dict, List, Optional + +from mmengine.config import Config + +# Constants +DEFAULT_VERIFICATION_TOLERANCE = 1e-3 +DEFAULT_WORKSPACE_SIZE = 1 << 30 # 1 GB + +# Precision policy mapping for TensorRT +PRECISION_POLICIES = { + "auto": {}, # No special flags, TensorRT decides + "fp16": {"FP16": True}, + "fp32_tf32": {"TF32": True}, # TF32 for FP32 operations + "explicit_int8": {"INT8": True}, + "strongly_typed": {"STRONGLY_TYPED": True}, # Network creation flag +} + + +class ExportConfig: + """Configuration for model export settings.""" + + def __init__(self, config_dict: Dict[str, Any]): + self.mode = config_dict.get("mode", "both") + # Note: verify has been moved to verification.enabled in v2 config format + # Device is optional in v2 format (devices are specified per-backend in evaluation/verification) + # Default to cuda:0 for backward compatibility + self.device = config_dict.get("device", "cuda:0") + self.work_dir = config_dict.get("work_dir", "work_dirs") + self.checkpoint_path = config_dict.get("checkpoint_path") + self.onnx_path = config_dict.get("onnx_path") + + def should_export_onnx(self) -> bool: + """Check if ONNX export is requested.""" + return self.mode in ["onnx", "both"] + + def should_export_tensorrt(self) -> bool: + """Check if TensorRT export is requested.""" + return self.mode in ["trt", "both"] + + +class RuntimeConfig: + """Configuration for runtime I/O settings.""" + + def __init__(self, config_dict: Dict[str, Any]): + self._config = config_dict + + def get(self, key: str, default: Any = None) -> Any: + """Get a runtime configuration value.""" + return self._config.get(key, default) + + def __getitem__(self, key: str) -> Any: + """Dictionary-style access to runtime config.""" + return self._config[key] + + +class BackendConfig: + """Configuration for backend-specific settings.""" + + def __init__(self, config_dict: Dict[str, Any]): + self.common_config = config_dict.get("common_config", {}) + self.model_inputs = config_dict.get("model_inputs", []) + + def get_precision_policy(self) -> str: + """Get precision policy name.""" + return self.common_config.get("precision_policy", "auto") + + def get_precision_flags(self) -> Dict[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) + + +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. + """ + + 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() + + # Initialize config sections + self.export_config = ExportConfig(deploy_cfg.get("export", {})) + self.runtime_config = RuntimeConfig(deploy_cfg.get("runtime_io", {})) + self.backend_config = BackendConfig(deploy_cfg.get("backend_config", {})) + + 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 + valid_modes = ["onnx", "trt", "both", "none"] + mode = self.deploy_cfg.get("export", {}).get("mode", "both") + if mode not in valid_modes: + raise ValueError(f"Invalid export mode '{mode}'. Must be one of {valid_modes}") + + # 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", "auto") + if precision_policy not in PRECISION_POLICIES: + raise ValueError( + f"Invalid precision_policy '{precision_policy}'. " f"Must be one of {list(PRECISION_POLICIES.keys())}" + ) + + @property + def evaluation_config(self) -> Dict: + """Get evaluation configuration.""" + return self.deploy_cfg.get("evaluation", {}) + + @property + def onnx_config(self) -> Dict: + """Get ONNX configuration.""" + return self.deploy_cfg.get("onnx_config", {}) + + @property + def verification_config(self) -> Dict: + """Get verification configuration.""" + return self.deploy_cfg.get("verification", {}) + + def get_evaluation_backends(self) -> Dict[str, Dict[str, Any]]: + """ + Get evaluation backends configuration. + + Returns: + Dictionary mapping backend names to their configuration + """ + eval_config = self.evaluation_config + return eval_config.get("backends", {}) + + def get_verification_scenarios(self, export_mode: str) -> List[Dict[str, str]]: + """ + Get verification scenarios for the given export mode. + + Args: + export_mode: Export mode ('onnx', 'trt', 'both', 'none') + + Returns: + List of verification scenarios dictionaries + """ + verification_cfg = self.verification_config + scenarios = verification_cfg.get("scenarios", {}) + return scenarios.get(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) -> Dict[str, Any]: + """ + Get ONNX export settings. + Returns: + Dictionary 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 = { + "opset_version": onnx_config.get("opset_version", 16), + "do_constant_folding": onnx_config.get("do_constant_folding", True), + "input_names": input_names, + "output_names": 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), + "save_file": onnx_config.get("save_file", "model.onnx"), + "decode_in_inference": onnx_config.get("decode_in_inference", True), + "batch_size": batch_size, + } + + # Include model_wrapper config if present in onnx_config + if "model_wrapper" in onnx_config: + settings["model_wrapper"] = onnx_config["model_wrapper"] + + return settings + + def get_tensorrt_settings(self) -> Dict[str, Any]: + """ + Get TensorRT export settings with precision policy support. + Returns: + Dictionary containing TensorRT export parameters + """ + return { + "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, + } + + def update_batch_size(self, batch_size: int) -> None: + """ + Update batch size in backend config model_inputs. + Args: + batch_size: New batch size to set + """ + if batch_size is not None: + # Check if model_inputs already has TensorRT-specific configuration + existing_model_inputs = self.backend_config.model_inputs + + # If model_inputs is None or empty, generate from model_io + if existing_model_inputs is None or len(existing_model_inputs) == 0: + # Get model_io configuration + model_io = self.deploy_cfg.get("model_io", {}) + input_name = model_io.get("input_name", "input") + input_shape = model_io.get("input_shape", (3, 960, 960)) + input_dtype = model_io.get("input_dtype", "float32") + + # Create model_inputs list + model_inputs = [] + + # Add primary input + full_shape = (batch_size,) + input_shape + model_inputs.append(dict( + name=input_name, + shape=full_shape, + dtype=input_dtype, + )) + + # Add additional inputs if specified + additional_inputs = model_io.get("additional_inputs", []) + for additional_input in additional_inputs: + if isinstance(additional_input, dict): + add_name = additional_input.get("name", "input") + add_shape = additional_input.get("shape", (-1,)) + add_dtype = additional_input.get("dtype", "float32") + + # Handle dynamic shapes (e.g., (-1,) for variable length) + if isinstance(add_shape, tuple) and len(add_shape) > 0 and add_shape[0] == -1: + # Keep dynamic shape for variable length inputs + full_add_shape = add_shape + else: + # Add batch dimension for fixed shapes + full_add_shape = (batch_size,) + add_shape + + model_inputs.append(dict( + name=add_name, + shape=full_add_shape, + dtype=add_dtype, + )) + + # Update model_inputs in backend config + self.backend_config.model_inputs = model_inputs + else: + # If model_inputs already exists (e.g., TensorRT shape ranges), + # update batch size in existing shapes if they are simple shapes + for model_input in existing_model_inputs: + if isinstance(model_input, dict) and "shape" in model_input: + # Simple shape format: {"name": "input", "shape": (batch, ...), "dtype": "float32"} + if isinstance(model_input["shape"], tuple) and len(model_input["shape"]) > 0: + # Update batch dimension (first dimension) + shape = list(model_input["shape"]) + shape[0] = batch_size + model_input["shape"] = tuple(shape) + elif isinstance(model_input, dict) and "input_shapes" in model_input: + # TensorRT shape ranges format: {"input_shapes": {"input": {"min_shape": [...], ...}}} + # For TensorRT shape ranges, we don't modify batch size as it's handled by dynamic_axes + pass + + +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") + parser.add_argument( + "checkpoint", nargs="?", default=None, help="Model checkpoint path (optional when mode='none')" + ) + + # Optional overrides + parser.add_argument("--work-dir", help="Override output directory from config") + parser.add_argument("--device", help="Override device from config") + parser.add_argument("--log-level", default="INFO", choices=list(logging._nameToLevel.keys()), help="Logging level") + + return parser \ No newline at end of file diff --git a/autoware_ml/deployment/core/base_data_loader.py b/autoware_ml/deployment/core/base_data_loader.py new file mode 100644 index 000000000..68c3136fb --- /dev/null +++ b/autoware_ml/deployment/core/base_data_loader.py @@ -0,0 +1,68 @@ +""" +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 abc import ABC, abstractmethod +from typing import Any, Dict + +import torch + + +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: Dict[str, Any]): + """ + Initialize data loader. + Args: + config: Configuration dictionary containing task-specific settings + """ + self.config = config + + @abstractmethod + def load_sample(self, index: int) -> Dict[str, Any]: + """ + 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 + """ + pass + + @abstractmethod + def preprocess(self, sample: Dict[str, Any]) -> 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 + """ + pass + + @abstractmethod + def get_num_samples(self) -> int: + """ + Get total number of samples in the dataset. + Returns: + Total number of samples available + """ + pass \ No newline at end of file diff --git a/autoware_ml/deployment/core/base_evaluator.py b/autoware_ml/deployment/core/base_evaluator.py new file mode 100644 index 000000000..c9cdfaec3 --- /dev/null +++ b/autoware_ml/deployment/core/base_evaluator.py @@ -0,0 +1,170 @@ +""" +Abstract base class for model evaluation in deployment. +Each task (classification, detection, segmentation, etc.) must implement +a concrete Evaluator that extends this base class to compute task-specific metrics. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict + +import numpy as np + +from .base_data_loader import BaseDataLoader + + +class BaseEvaluator(ABC): + """ + Abstract base class for task-specific evaluators. + This class defines the interface that all task-specific evaluators + must implement. It handles running inference on a dataset and computing + evaluation metrics appropriate for the task. + """ + + def __init__(self, config: Dict[str, Any]): + """ + Initialize evaluator. + Args: + config: Configuration dictionary containing evaluation settings + """ + self.config = config + + @abstractmethod + def evaluate( + self, + model_path: str, + data_loader: BaseDataLoader, + num_samples: int, + backend: str = "pytorch", + device: str = "cpu", + verbose: bool = False, + ) -> Dict[str, Any]: + """ + Run full evaluation on a model. + Args: + model_path: Path to model checkpoint/weights + data_loader: DataLoader for loading samples + num_samples: Number of samples to evaluate + backend: Backend to use ('pytorch', 'onnx', 'tensorrt') + device: Device to run inference on + verbose: Whether to print detailed progress + Returns: + Dictionary containing evaluation metrics. The exact metrics + depend on the task, but should include: + - Primary metric(s) for the task + - Per-class metrics (if applicable) + - Inference latency statistics + - Any other relevant metrics + Example: + For classification: + { + "accuracy": 0.95, + "precision": 0.94, + "recall": 0.96, + "per_class_accuracy": {...}, + "confusion_matrix": [...], + "avg_latency_ms": 5.2, + } + For detection: + { + "mAP": 0.72, + "mAP_50": 0.85, + "mAP_75": 0.68, + "per_class_ap": {...}, + "avg_latency_ms": 15.3, + } + """ + pass + + @abstractmethod + def print_results(self, results: Dict[str, Any]) -> None: + """ + Pretty print evaluation results. + Args: + results: Results dictionary returned by evaluate() + """ + pass + + @abstractmethod + def verify( + self, + ref_backend: str, + ref_device: str, + ref_path: str, + test_backend: str, + test_device: str, + test_path: str, + data_loader: BaseDataLoader, + num_samples: int = 1, + tolerance: float = 0.1, + verbose: bool = False, + ) -> Dict[str, Any]: + """ + Verify exported models using scenario-based verification. + + This method compares outputs from a reference backend against a test backend + as specified by the verification scenarios. This is a more flexible approach + than the legacy verify() method which compares all available backends. + + Args: + ref_backend: Reference backend name ('pytorch' or 'onnx') + ref_device: Device for reference backend (e.g., 'cpu', 'cuda:0') + ref_path: Path to reference model (checkpoint for pytorch, model path for onnx) + test_backend: Test backend name ('onnx' or 'tensorrt') + test_device: Device for test backend (e.g., 'cpu', 'cuda:0') + test_path: Path to test model (model path for onnx, engine path for tensorrt) + data_loader: Data loader for test samples + num_samples: Number of samples to verify + tolerance: Maximum allowed difference for verification to pass + verbose: Whether to print detailed output + + Returns: + Dictionary containing verification results: + { + 'sample_0': bool (passed/failed), + 'sample_1': bool (passed/failed), + ... + 'summary': {'passed': int, 'failed': int, 'total': int} + } + """ + pass + + def compute_latency_stats(self, latencies: list) -> Dict[str, float]: + """ + Compute latency statistics from a list of latency measurements. + Args: + latencies: List of latency values in milliseconds + Returns: + Dictionary with latency statistics + """ + if not latencies: + return { + "mean_ms": 0.0, + "std_ms": 0.0, + "min_ms": 0.0, + "max_ms": 0.0, + "median_ms": 0.0, + } + + latencies_array = np.array(latencies) + + return { + "mean_ms": float(np.mean(latencies_array)), + "std_ms": float(np.std(latencies_array)), + "min_ms": float(np.min(latencies_array)), + "max_ms": float(np.max(latencies_array)), + "median_ms": float(np.median(latencies_array)), + } + + def format_latency_stats(self, stats: Dict[str, float]) -> str: + """ + Format latency statistics as a readable string. + Args: + stats: Latency statistics dictionary + Returns: + Formatted string + """ + return ( + f"Latency: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms " + f"(min: {stats['min_ms']:.2f}, max: {stats['max_ms']:.2f}, " + f"median: {stats['median_ms']:.2f})" + ) \ No newline at end of file diff --git a/autoware_ml/deployment/core/base_pipeline.py b/autoware_ml/deployment/core/base_pipeline.py new file mode 100644 index 000000000..2443c3ef6 --- /dev/null +++ b/autoware_ml/deployment/core/base_pipeline.py @@ -0,0 +1,283 @@ +""" +Base Deployment Pipeline for Unified Model Deployment. +This module provides the abstract base class for all deployment pipelines, +defining a unified interface across different backends (PyTorch, ONNX, TensorRT) +and task types (detection, classification, segmentation). +Architecture: + Input → preprocess() → run_model() → postprocess() → Output +Key Design Principles: + 1. Shared Logic: preprocess/postprocess are shared across backends + 2. Backend-Specific: run_model() is implemented per backend + 3. Unified Interface: infer() provides consistent API + 4. Flexible Output: Can return raw or processed outputs +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Tuple, Union, List, Optional +import logging +import time + +import torch + + +logger = logging.getLogger(__name__) + + +class BaseDeploymentPipeline(ABC): + """ + Abstract base class for all deployment pipelines. + + This class defines the unified interface for model deployment across + different backends and task types. + + Attributes: + model: Model object (PyTorch model, ONNX session, TensorRT engine, etc.) + device: Device for inference + task_type: Type of task ("detection_2d", "detection_3d", "classification", etc.) + backend_type: Type of backend ("pytorch", "onnx", "tensorrt", etc.) + """ + + def __init__( + self, + model: Any, + device: str = "cpu", + task_type: str = "unknown", + backend_type: str = "unknown" + ): + """ + Initialize deployment pipeline. + + Args: + model: Model object (backend-specific) + device: Device for inference ('cpu', 'cuda', 'cuda:0', etc.) + task_type: Type of task + backend_type: Type of backend + """ + self.model = model + self.device = torch.device(device) if isinstance(device, str) else device + self.task_type = task_type + self.backend_type = backend_type + + logger.info(f"Initialized {self.__class__.__name__} on device: {self.device}") + + # ========== Abstract Methods (Must Implement) ========== + + @abstractmethod + def preprocess(self, input_data: Any, **kwargs) -> Any: + """ + Preprocess input data. + + This method should handle all preprocessing steps required before + feeding data to the model (normalization, resizing, etc.). + + Args: + input_data: Raw input (image, point cloud, etc.) + **kwargs: Additional preprocessing parameters + + Returns: + Preprocessed data ready for model + """ + pass + + @abstractmethod + def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple]: + """ + Run model inference (backend-specific). + + This is the only method that differs across backends. + Each backend (PyTorch, ONNX, TensorRT) implements its own version. + + Args: + preprocessed_input: Preprocessed input data + + Returns: + Model output (raw tensors or backend-specific format) + """ + pass + + @abstractmethod + def postprocess( + self, + model_output: Any, + metadata: Dict = None + ) -> Any: + """ + Postprocess model output to final predictions. + + This method should handle all postprocessing steps like NMS, + coordinate transformation, score filtering, etc. + + Args: + model_output: Raw model output from run_model() + metadata: Additional metadata (image size, point cloud range, etc.) + + Returns: + Final predictions in standard format + """ + pass + + # ========== Concrete Methods (Shared Logic) ========== + + def infer( + self, + input_data: Any, + metadata: Optional[Dict] = None, + return_raw_outputs: bool = False, + **kwargs + ) -> Tuple[Any, float, Dict[str, float]]: + """ + Complete inference pipeline. + + This method orchestrates the entire inference flow: + 1. Preprocessing + 2. Model inference + 3. Postprocessing (optional) + + This unified interface allows: + - Evaluation: infer(..., return_raw_outputs=False) → get final predictions + - Verification: infer(..., return_raw_outputs=True) → get raw outputs for comparison + + Args: + input_data: Raw input data + metadata: Additional metadata for preprocessing/postprocessing + return_raw_outputs: If True, skip postprocessing (for verification) + **kwargs: Additional arguments passed to preprocess() + + Returns: + Tuple of (outputs, latency_ms, latency_breakdown) + - outputs: If return_raw_outputs=True: raw_model_output + If return_raw_outputs=False: final_predictions + - latency_ms: Total inference latency in milliseconds + - latency_breakdown: Dictionary with stage-wise latencies (may be empty) + Keys: 'preprocessing_ms', 'model_ms', 'postprocessing_ms' + """ + if metadata is None: + metadata = {} + + latency_breakdown: Dict[str, float] = {} + + try: + start_time = time.time() + + # 1. Preprocess + preprocessed = self.preprocess(input_data, **kwargs) + + # Unpack preprocess outputs: allow (data, metadata) tuple + 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.time() + latency_breakdown['preprocessing_ms'] = (preprocess_time - start_time) * 1000 + + # Merge caller metadata (if any) with preprocess metadata (preprocess takes precedence by default) + merged_metadata = {} + merged_metadata.update(metadata or {}) + merged_metadata.update(preprocess_metadata) + + # 2. Run model (backend-specific) + model_start = time.time() + model_output = self.run_model(model_input) + model_time = time.time() + latency_breakdown['model_ms'] = (model_time - model_start) * 1000 + + # Merge stage-wise latencies if available (for multi-stage pipelines like CenterPoint) + if hasattr(self, '_stage_latencies') and isinstance(self._stage_latencies, dict): + latency_breakdown.update(self._stage_latencies) + # Clear for next inference + self._stage_latencies = {} + + total_latency = (time.time() - start_time) * 1000 + + # 3. Postprocess (optional) + if return_raw_outputs: + return model_output, total_latency, latency_breakdown + else: + postprocess_start = time.time() + predictions = self.postprocess(model_output, merged_metadata) + postprocess_time = time.time() + latency_breakdown['postprocessing_ms'] = (postprocess_time - postprocess_start) * 1000 + + total_latency = (time.time() - start_time) * 1000 + return predictions, total_latency, latency_breakdown + + except Exception as e: + logger.error(f"Inference failed: {e}") + import traceback + traceback.print_exc() + raise + + def warmup(self, input_data: Any, num_iterations: int = 10): + """ + Warmup the model with dummy inputs. + + Useful for stabilizing latency measurements, especially for GPU models. + + Args: + input_data: Sample input for warmup + num_iterations: Number of warmup iterations + """ + logger.info(f"Warming up {self.__class__.__name__} with {num_iterations} iterations...") + + for i in range(num_iterations): + try: + self.infer(input_data) + except Exception as e: + logger.warning(f"Warmup iteration {i} failed: {e}") + + logger.info("Warmup completed") + + def benchmark( + self, + input_data: Any, + num_iterations: int = 100 + ) -> Dict[str, float]: + """ + Benchmark inference performance. + + Args: + input_data: Sample input for benchmarking + num_iterations: Number of benchmark iterations + + Returns: + Dictionary with latency statistics (mean, std, min, max) + """ + logger.info(f"Benchmarking {self.__class__.__name__} with {num_iterations} iterations...") + + # Warmup first + self.warmup(input_data, num_iterations=10) + + # Benchmark + latencies = [] + for _ in range(num_iterations): + _, latency, _ = self.infer(input_data) + latencies.append(latency) + + import numpy as np + results = { + 'mean_ms': np.mean(latencies), + 'std_ms': np.std(latencies), + 'min_ms': np.min(latencies), + 'max_ms': np.max(latencies), + 'median_ms': np.median(latencies) + } + + logger.info(f"Benchmark results: {results['mean_ms']:.2f} ± {results['std_ms']:.2f} ms") + + return results + + 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): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + # Cleanup resources if needed + pass \ No newline at end of file diff --git a/autoware_ml/deployment/core/classification_pipeline.py b/autoware_ml/deployment/core/classification_pipeline.py new file mode 100644 index 000000000..941f20cab --- /dev/null +++ b/autoware_ml/deployment/core/classification_pipeline.py @@ -0,0 +1,160 @@ +""" +Classification Pipeline Base Class. +This module provides the base class for classification pipelines, +implementing common preprocessing and postprocessing for image/point cloud classification. +""" + +from abc import abstractmethod +from typing import List, Dict, Tuple, Any +import logging + +import numpy as np +import torch + +from .base_pipeline import BaseDeploymentPipeline + + +logger = logging.getLogger(__name__) + + +class ClassificationPipeline(BaseDeploymentPipeline): + """ + Base class for classification pipelines. + + Provides common functionality for classification tasks including: + - Image/data preprocessing (via data loader) + - Postprocessing (softmax, top-k selection) + - Standard classification output format + + Expected output format: + Dict containing: + { + 'class_id': int, # Predicted class ID + 'class_name': str, # Class name + 'confidence': float, # Confidence score + 'probabilities': np.ndarray, # All class probabilities + 'top_k': List[Dict] # Top-K predictions (optional) + } + """ + + def __init__( + self, + model: Any, + device: str = "cpu", + num_classes: int = 1000, + class_names: List[str] = None, + input_size: Tuple[int, int] = (224, 224), + backend_type: str = "unknown" + ): + """ + Initialize classification pipeline. + + Args: + model: Model object + device: Device for inference + num_classes: Number of classes + class_names: List of class names + input_size: Model input size (height, width) - for reference only + backend_type: Backend type + """ + super().__init__(model, device, task_type="classification", backend_type=backend_type) + + self.num_classes = num_classes + self.class_names = class_names or [f"class_{i}" for i in range(num_classes)] + self.input_size = input_size + + @abstractmethod + def preprocess( + self, + input_data: Any, + **kwargs + ) -> torch.Tensor: + """ + Preprocess input data for classification. + + This method should be implemented by specific classification pipelines. + Preprocessing should be done by data loader before calling this method. + + Args: + input_data: Preprocessed tensor from data loader or raw input + **kwargs: Additional preprocessing parameters + + Returns: + Preprocessed tensor [1, C, H, W] + """ + pass + + @abstractmethod + def run_model(self, preprocessed_input: torch.Tensor) -> torch.Tensor: + """ + Run classification model (backend-specific). + + Args: + preprocessed_input: Preprocessed tensor [1, C, H, W] + + Returns: + Model output (logits) [1, num_classes] + """ + pass + + def postprocess( + self, + model_output: torch.Tensor, + metadata: Dict = None, + top_k: int = 5 + ) -> Dict: + """ + Standard classification postprocessing. + + Steps: + 1. Apply softmax to get probabilities + 2. Get predicted class + 3. Optionally get top-K predictions + + Args: + model_output: Model output (logits) [1, num_classes] + metadata: Additional metadata (unused for classification) + top_k: Number of top predictions to return + + Returns: + Dictionary with classification results + """ + # Convert to numpy if needed + if isinstance(model_output, torch.Tensor): + logits = model_output.cpu().numpy() + else: + logits = model_output + + # Remove batch dimension if present + if logits.ndim == 2: + logits = logits[0] + + # Apply softmax + exp_logits = np.exp(logits - np.max(logits)) # Numerical stability + probabilities = exp_logits / np.sum(exp_logits) + + # Get predicted class + class_id = int(np.argmax(probabilities)) + confidence = float(probabilities[class_id]) + class_name = self.class_names[class_id] if class_id < len(self.class_names) else f"class_{class_id}" + + # Get top-K predictions + top_k_indices = np.argsort(probabilities)[::-1][:top_k] + top_k_predictions = [] + for idx in top_k_indices: + top_k_predictions.append({ + 'class_id': int(idx), + 'class_name': self.class_names[idx] if idx < len(self.class_names) else f"class_{idx}", + 'confidence': float(probabilities[idx]) + }) + + result = { + 'class_id': class_id, + 'class_name': class_name, + 'confidence': confidence, + 'probabilities': probabilities, + 'top_k': top_k_predictions + } + + + return result \ No newline at end of file diff --git a/autoware_ml/deployment/core/detection_2d_pipeline.py b/autoware_ml/deployment/core/detection_2d_pipeline.py new file mode 100644 index 000000000..934837716 --- /dev/null +++ b/autoware_ml/deployment/core/detection_2d_pipeline.py @@ -0,0 +1,173 @@ +""" +2D Object Detection Pipeline Base Class. +This module provides the base class for 2D object detection pipelines, +implementing common preprocessing and postprocessing for models like YOLOX, YOLO, etc. +""" + +from abc import abstractmethod +from typing import List, Dict, Tuple, Any +import logging + +import numpy as np +import torch + +from .base_pipeline import BaseDeploymentPipeline + + +logger = logging.getLogger(__name__) + + +class Detection2DPipeline(BaseDeploymentPipeline): + """ + Base class for 2D object detection pipelines. + + Provides common functionality for 2D detection tasks including: + - Image preprocessing (resize, normalize, padding) + - Postprocessing (NMS, coordinate transformation) + - Standard detection output format + + Expected output format: + List[Dict] where each dict contains: + { + 'bbox': [x1, y1, x2, y2], # Bounding box coordinates + 'score': float, # Confidence score + 'class_id': int, # Class ID + 'class_name': str # Class name (optional) + } + """ + + def __init__( + self, + model: Any, + device: str = "cpu", + num_classes: int = 80, + class_names: List[str] = None, + input_size: Tuple[int, int] = (640, 640), + backend_type: str = "unknown" + ): + """ + Initialize 2D detection pipeline. + + Args: + model: Model object + device: Device for inference + num_classes: Number of classes + class_names: List of class names + input_size: Model input size (height, width) + backend_type: Backend type + """ + super().__init__(model, device, task_type="detection_2d", backend_type=backend_type) + + self.num_classes = num_classes + self.class_names = class_names or [f"class_{i}" for i in range(num_classes)] + self.input_size = input_size + + @abstractmethod + def preprocess( + self, + input_data: Any, + **kwargs + ) -> Tuple[torch.Tensor, Dict]: + """ + Preprocess input data for 2D detection. + + This method should be implemented by specific detection pipelines. + For YOLOX, preprocessing is done by MMDetection pipeline before calling this method. + + Args: + input_data: Preprocessed tensor from MMDetection pipeline or raw input + **kwargs: Additional preprocessing parameters + + Returns: + Tuple of (preprocessed_tensor, preprocessing_metadata) + - preprocessed_tensor: [1, C, H, W] + - preprocessing_metadata: Dict with preprocessing information + """ + pass + + @abstractmethod + def run_model(self, preprocessed_input: torch.Tensor) -> Any: + """ + Run detection model (backend-specific). + + Args: + preprocessed_input: Preprocessed tensor [1, C, H, W] + + Returns: + Model output (backend-specific format) + """ + pass + + def postprocess( + self, + model_output: Any, + metadata: Dict = None + ) -> List[Dict]: + """ + Standard 2D detection postprocessing. + + Steps: + 1. Parse model outputs (boxes, scores, classes) + 2. Apply NMS + 3. Transform coordinates back to original image space + 4. Filter by confidence threshold + + Args: + model_output: Raw model output + metadata: Preprocessing metadata + + Returns: + List of detections in standard format + """ + # This should be overridden by specific detectors (YOLOX, YOLO, etc.) + # as output formats differ + raise NotImplementedError( + "postprocess() must be implemented by specific detector pipeline" + ) + + + def _nms( + self, + boxes: np.ndarray, + scores: np.ndarray, + iou_threshold: float = 0.45 + ) -> np.ndarray: + """ + Non-Maximum Suppression. + + Args: + boxes: Bounding boxes [N, 4] + scores: Confidence scores [N] + iou_threshold: IoU threshold for NMS + + Returns: + Indices of boxes to keep + """ + x1 = boxes[:, 0] + y1 = boxes[:, 1] + x2 = boxes[:, 2] + y2 = boxes[:, 3] + + areas = (x2 - x1) * (y2 - y1) + order = scores.argsort()[::-1] + + keep = [] + while order.size > 0: + i = order[0] + keep.append(i) + + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + + w = np.maximum(0.0, xx2 - xx1) + h = np.maximum(0.0, yy2 - yy1) + inter = w * h + + iou = inter / (areas[i] + areas[order[1:]] - inter) + + inds = np.where(iou <= iou_threshold)[0] + order = order[inds + 1] + + return np.array(keep) \ No newline at end of file diff --git a/autoware_ml/deployment/core/detection_3d_pipeline.py b/autoware_ml/deployment/core/detection_3d_pipeline.py new file mode 100644 index 000000000..044842fcb --- /dev/null +++ b/autoware_ml/deployment/core/detection_3d_pipeline.py @@ -0,0 +1,173 @@ +""" +3D Object Detection Pipeline Base Class. + +This module provides the base class for 3D object detection pipelines, +implementing common functionality for point cloud-based detection models like CenterPoint. +""" + + +from abc import abstractmethod +from typing import List, Dict, Tuple, Any +import logging + + +import torch +import numpy as np + + +from .base_pipeline import BaseDeploymentPipeline + + + + +logger = logging.getLogger(__name__) + + + + +class Detection3DPipeline(BaseDeploymentPipeline): + """ + Base class for 3D object detection pipelines. + + Provides common functionality for 3D detection tasks including: + - Point cloud preprocessing (voxelization, normalization) + - Postprocessing (NMS, coordinate transformation) + - Standard 3D detection output format + + Expected output format: + List[Dict] where each dict contains: + { + 'bbox_3d': [x, y, z, w, l, h, yaw], # 3D bounding box + 'score': float, # Confidence score + 'label': int, # Class label + 'class_name': str # Class name (optional) + } + """ + + + def __init__( + self, + model: Any, + device: str = "cpu", + num_classes: int = 10, + class_names: List[str] = None, + point_cloud_range: List[float] = None, + voxel_size: List[float] = None, + backend_type: str = "unknown" + ): + """ + Initialize 3D detection pipeline. + + Args: + model: Model object + device: Device for inference + num_classes: Number of classes + class_names: List of class names + point_cloud_range: Point cloud range [x_min, y_min, z_min, x_max, y_max, z_max] + voxel_size: Voxel size [vx, vy, vz] + backend_type: Backend type + """ + super().__init__(model, device, task_type="detection_3d", backend_type=backend_type) + + + self.num_classes = num_classes + self.class_names = class_names or [f"class_{i}" for i in range(num_classes)] + self.point_cloud_range = point_cloud_range + self.voxel_size = voxel_size + + + def preprocess( + self, + points: torch.Tensor, + **kwargs + ) -> Dict[str, torch.Tensor]: + """ + Standard 3D detection preprocessing. + + Note: For 3D detection, preprocessing is often model-specific + (voxelization, pillar generation, etc.), so this method should + be overridden by specific implementations. + + Args: + points: Input point cloud [N, point_features] + **kwargs: Additional preprocessing parameters + + Returns: + Dictionary containing preprocessed data + """ + raise NotImplementedError( + "preprocess() must be implemented by specific 3D detector pipeline.\n" + "3D detection preprocessing varies significantly between models." + ) + + + def run_model(self, preprocessed_input: Any) -> Any: + """ + Run 3D detection model (backend-specific). + + **Note**: This method is intentionally not abstract for 3D detection pipelines. + + Most 3D detection models use a **multi-stage inference pipeline** rather than + a single model call: + + ``` + Points → Voxel Encoder → Middle Encoder → Backbone/Head → Postprocess + ``` + + For 3D detection pipelines + + *Implement `run_model()` (Recommended)* + - Implement all stages in `run_model()`: + - `run_voxel_encoder()` - backend-specific voxel encoding + - `process_middle_encoder()` - sparse convolution (usually PyTorch-only) + - `run_backbone_head()` - backend-specific backbone/head inference + - Return final head outputs + - Use base class `infer()` for unified pipeline orchestration + + + Args: + preprocessed_input: Preprocessed data (usually Dict from preprocess()) + + Returns: + Model output (backend-specific format, usually List[torch.Tensor] for head outputs) + + Raises: + NotImplementedError: Default implementation raises error. + Subclasses should implement `run_model()` with all stages. + + Example: + See `CenterPointDeploymentPipeline.run_model()` for a complete multi-stage + implementation example. + """ + raise NotImplementedError( + "run_model() must be implemented by 3D detection pipelines. " + "3D detection typically uses a multi-stage inference pipeline " + "(voxel encoder → middle encoder → backbone/head). " + "Please implement run_model() with all stages. " + "See CenterPointDeploymentPipeline.run_model() for an example implementation." + ) + + + def postprocess( + self, + model_output: Any, + metadata: Dict = None + ) -> List[Dict]: + """ + Standard 3D detection postprocessing. + + Note: For 3D detection, postprocessing is often model-specific + (CenterPoint uses predict_by_feat, PointPillars uses different logic), + so this method should be overridden by specific implementations. + + Args: + model_output: Raw model output + metadata: Preprocessing metadata + + Returns: + List of 3D detections in standard format + """ + raise NotImplementedError( + "postprocess() must be implemented by specific 3D detector pipeline.\n" + "3D detection postprocessing varies significantly between models." + ) \ No newline at end of file diff --git a/autoware_ml/deployment/core/preprocessing_builder.py b/autoware_ml/deployment/core/preprocessing_builder.py new file mode 100644 index 000000000..b970a0d7d --- /dev/null +++ b/autoware_ml/deployment/core/preprocessing_builder.py @@ -0,0 +1,312 @@ +""" +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. +NOTE: This module is compatible with the new pipeline architecture (BaseDeploymentPipeline). +They serve complementary purposes: +- preprocessing_builder.py: Builds MMDet/MMDet3D preprocessing pipelines for data loaders +- New pipeline architecture: Handles inference pipeline (preprocess → run_model → postprocess) +Data flow: DataLoader (uses preprocessing_builder) → Preprocessed Data → Pipeline (new architecture) → Predictions +See PIPELINE_BUILDER_INTEGRATION_ANALYSIS.md for detailed analysis. +""" + +import logging +from typing import Any, Callable, Dict, List, Optional + +from mmengine.config import Config + +logger = logging.getLogger(__name__) + +# Valid task types +VALID_TASK_TYPES = ["detection2d", "detection3d", "classification", "segmentation"] + + +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, + 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 + + # Import MMEngine components + try: + from mmengine.dataset import Compose + from mmengine.registry import init_default_scope + except ImportError as e: + raise ImportError( + f"Failed to import mmengine components for scope '{scope}'. " + f"Please ensure mmengine is installed. Error: {e}" + ) from e + + # Set default scope and build Compose + try: + init_default_scope(scope) + logger.info(f"Building pipeline with mmengine.dataset.Compose (default_scope='{scope}')") + return Compose(pipeline_cfg) + except Exception as e: + raise ImportError( + f"Failed to build Compose pipeline for scope '{scope}'. " + f"Error: {e}" + ) from e + + +class PreprocessingPipelineRegistry: + """ + Registry for preprocessing pipeline builders by task type. + + Provides a clean way to register and retrieve pipeline builders. + """ + + def __init__(self): + self._builders: Dict[str, Callable[[List], Any]] = {} + self._register_default_builders() + + def _register_default_builders(self): + """Register default pipeline builders.""" + self.register("detection2d", self._build_detection2d) + self.register("detection3d", self._build_detection3d) + self.register("classification", self._build_classification) + self.register("segmentation", self._build_segmentation) + + def register(self, task_type: str, builder: Callable[[List], Any]): + """ + Register a pipeline builder for a task type. + Args: + task_type: Task type identifier + builder: Builder function that takes pipeline_cfg and returns Compose object + """ + if task_type not in VALID_TASK_TYPES: + logger.warning(f"Registering non-standard task_type: {task_type}") + self._builders[task_type] = builder + logger.debug(f"Registered pipeline builder for task_type: {task_type}") + + def build(self, task_type: str, pipeline_cfg: List) -> Any: + """ + Build pipeline for given task type. + Args: + task_type: Task type identifier + pipeline_cfg: Pipeline configuration + Returns: + Compose object + Raises: + ValueError: If task_type is not registered + """ + if task_type not in self._builders: + raise ValueError( + f"Unknown task_type '{task_type}'. " + f"Available types: {list(self._builders.keys())}" + ) + return self._builders[task_type](pipeline_cfg) + + def _build_detection2d(self, pipeline_cfg: List) -> Any: + """Build 2D detection preprocessing pipeline.""" + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmdet", + import_modules=["mmdet.datasets.transforms"], + ) + + def _build_detection3d(self, pipeline_cfg: List) -> Any: + """Build 3D detection preprocessing pipeline.""" + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmdet3d", + import_modules=["mmdet3d.datasets.transforms"], + ) + + def _build_classification(self, pipeline_cfg: List) -> Any: + """ + Build classification preprocessing pipeline using mmpretrain. + + Raises: + ImportError: If mmpretrain is not installed + """ + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmpretrain", + import_modules=["mmpretrain.datasets.transforms"], + ) + + def _build_segmentation(self, pipeline_cfg: List) -> Any: + """Build segmentation preprocessing pipeline.""" + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmseg", + import_modules=["mmseg.datasets.transforms"], + ) + + +# Global registry instance +_registry = PreprocessingPipelineRegistry() + + +def build_preprocessing_pipeline( + model_cfg: Config, + task_type: Optional[str] = None, + backend: str = "pytorch", +) -> 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. + Can have pipeline defined in one of these locations: + - model_cfg.test_dataloader.dataset.pipeline + - model_cfg.test_pipeline + - model_cfg.val_dataloader.dataset.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"``. + backend: Target backend ('pytorch', 'onnx', 'tensorrt'). + Currently not used, reserved for future backend-specific optimizations. + 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) + task_type = _resolve_task_type(model_cfg, task_type) + + logger.info(f"Building preprocessing pipeline with task_type: {task_type}") + return _registry.build(task_type, pipeline_cfg) + + +def _resolve_task_type(model_cfg: Config, task_type: Optional[str] = None) -> str: + """ + Resolve task type from various sources. + Args: + model_cfg: Model configuration + task_type: Explicit task type (highest priority) + Returns: + Resolved task type string + Raises: + ValueError: If task_type cannot be resolved + """ + # Priority: function argument > model_cfg.task_type > model_cfg.deploy.task_type + if task_type is not None: + _validate_task_type(task_type) + return task_type + + if "task_type" in model_cfg: + task_type = model_cfg.task_type + _validate_task_type(task_type) + return task_type + + deploy_section = model_cfg.get("deploy", {}) + if isinstance(deploy_section, dict) and "task_type" in deploy_section: + task_type = deploy_section["task_type"] + _validate_task_type(task_type) + return task_type + + raise ValueError( + "task_type must be specified either via the build_preprocessing_pipeline argument " + "or by setting 'task_type' in the deploy config (deploy_config.py) or " + "model config (model_cfg.task_type or model_cfg.deploy.task_type). " + "Recommended: add 'task_type = \"detection3d\"' (or appropriate type) to deploy_config.py. " + "Automatic inference has been removed." + ) + + +def _validate_task_type(task_type: str) -> None: + """ + Validate task type. + Args: + task_type: Task type to validate + Raises: + ValueError: If task_type is invalid + """ + 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." + ) + + +def _extract_pipeline_config(model_cfg: Config) -> List: + """ + Extract pipeline configuration from model config. + Args: + model_cfg: Model configuration + Returns: + List of transform configurations + Raises: + ValueError: If no valid pipeline found + """ + # Try different possible locations for pipeline config + pipeline_locations = [ + # Primary location: test_dataloader + ("test_dataloader", "dataset", "pipeline"), + # Alternative: direct test_pipeline + ("test_pipeline",), + # Fallback: val_dataloader + ("val_dataloader", "dataset", "pipeline"), + ] + + for location in pipeline_locations: + try: + cfg = model_cfg + for key in location: + cfg = cfg[key] + if cfg: + logger.info(f"Found test pipeline at: {'.'.join(location)}") + return cfg + except (KeyError, TypeError): + continue + + raise ValueError( + "No test pipeline found in config. " + "Expected pipeline at one of: test_dataloader.dataset.pipeline, " + "test_pipeline, or val_dataloader.dataset.pipeline" + ) + + +# Public API: Allow custom pipeline builder registration +def register_preprocessing_builder(task_type: str, builder: Callable[[List], Any]): + """ + Register a custom preprocessing pipeline builder. + Args: + task_type: Task type identifier + builder: Builder function that takes pipeline_cfg and returns Compose object + Examples: + >>> def custom_builder(pipeline_cfg): + ... # Custom logic + ... return Compose(pipeline_cfg) + >>> register_preprocessing_builder("custom_task", custom_builder) + """ + _registry.register(task_type, builder) \ No newline at end of file diff --git a/autoware_ml/deployment/exporters/__init__.py b/autoware_ml/deployment/exporters/__init__.py new file mode 100644 index 000000000..254cb2ff8 --- /dev/null +++ b/autoware_ml/deployment/exporters/__init__.py @@ -0,0 +1,29 @@ +"""Model exporters for different backends.""" + +from .base_exporter import BaseExporter +from .onnx_exporter import ONNXExporter +from .tensorrt_exporter import TensorRTExporter +from .centerpoint_exporter import CenterPointONNXExporter +from .centerpoint_tensorrt_exporter import CenterPointTensorRTExporter +from .model_wrappers import ( + BaseModelWrapper, + YOLOXONNXWrapper, + IdentityWrapper, + register_model_wrapper, + get_model_wrapper, + list_model_wrappers, +) + +__all__ = [ + "BaseExporter", + "ONNXExporter", + "TensorRTExporter", + "CenterPointONNXExporter", + "CenterPointTensorRTExporter", + "BaseModelWrapper", + "YOLOXONNXWrapper", + "IdentityWrapper", + "register_model_wrapper", + "get_model_wrapper", + "list_model_wrappers", +] \ No newline at end of file diff --git a/autoware_ml/deployment/exporters/base_exporter.py b/autoware_ml/deployment/exporters/base_exporter.py new file mode 100644 index 000000000..c272e190b --- /dev/null +++ b/autoware_ml/deployment/exporters/base_exporter.py @@ -0,0 +1,114 @@ +""" +Abstract base class for model exporters. +Provides a unified interface for exporting models to different formats. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Callable +import logging + +import torch + + +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: Dict[str, Any], logger: logging.Logger = None): + """ + Initialize exporter. + Args: + config: Configuration dictionary for export settings + logger: Optional logger instance + """ + self.config = config + self.logger = logger or logging.getLogger(__name__) + self._model_wrapper_fn: Optional[Callable] = None + + # Extract wrapper configuration if present + wrapper_config = config.get('model_wrapper') + if wrapper_config: + self._setup_model_wrapper(wrapper_config) + + def _setup_model_wrapper(self, wrapper_config): + """ + Setup model wrapper from configuration. + + Args: + wrapper_config: Either a string (wrapper name) or dict with 'type' and kwargs + """ + from .model_wrappers import get_model_wrapper + + if isinstance(wrapper_config, str): + # Simple string: wrapper name only + wrapper_class = get_model_wrapper(wrapper_config) + self._model_wrapper_fn = lambda model: wrapper_class(model) + elif isinstance(wrapper_config, dict): + # Dict with type and additional arguments + wrapper_type = wrapper_config.get('type') + if not wrapper_type: + raise ValueError("Model wrapper config must have 'type' field") + + wrapper_class = get_model_wrapper(wrapper_type) + wrapper_kwargs = {k: v for k, v in wrapper_config.items() if k != 'type'} + self._model_wrapper_fn = lambda model: wrapper_class(model, **wrapper_kwargs) + else: + raise TypeError(f"Model wrapper config must be str or dict, got {type(wrapper_config)}") + + 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_fn: + self.logger.info("Applying model wrapper for export") + return self._model_wrapper_fn(model) + return model + + @abstractmethod + def export( + self, + model: torch.nn.Module, + sample_input: torch.Tensor, + output_path: str, + **kwargs + ) -> bool: + """ + Export model to target format. + Args: + model: PyTorch model to export + sample_input: Sample input tensor for tracing/shape inference + output_path: Path to save exported model + **kwargs: Additional format-specific arguments + Returns: + True if export succeeded, False otherwise + Raises: + RuntimeError: If export fails + """ + pass + + def validate_export(self, output_path: str) -> bool: + """ + Validate that the exported model file is valid. + Override this in subclasses to add format-specific validation. + Args: + output_path: Path to exported model file + Returns: + True if valid, False otherwise + """ + import os + + return os.path.exists(output_path) and os.path.getsize(output_path) > 0 diff --git a/autoware_ml/deployment/exporters/model_wrappers.py b/autoware_ml/deployment/exporters/model_wrappers.py new file mode 100644 index 000000000..9ef630bed --- /dev/null +++ b/autoware_ml/deployment/exporters/model_wrappers.py @@ -0,0 +1,108 @@ +""" +Model wrappers for ONNX export. +This module provides wrapper classes that prepare models for ONNX export +with specific output formats and processing requirements. +""" + +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. + """ + + 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. + """ + pass + + def get_config(self) -> Dict[str, Any]: + """Get wrapper configuration.""" + return self._wrapper_config + +# TODO(vividf): class YOLOXONNXWrapper + +class IdentityWrapper(BaseModelWrapper): + """ + Identity wrapper that doesn't modify the model. + + Useful for models that don't need special ONNX export handling. + """ + + 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) + + +# Model wrapper registry +_MODEL_WRAPPERS = { + # 'yolox': YOLOXONNXWrapper, + 'identity': IdentityWrapper, +} + + +def register_model_wrapper(name: str, wrapper_class: type): + """ + Register a custom model wrapper. + + Args: + name: Wrapper name + wrapper_class: Wrapper class (must inherit from BaseModelWrapper) + """ + if not issubclass(wrapper_class, BaseModelWrapper): + raise TypeError(f"Wrapper class must inherit from BaseModelWrapper, got {wrapper_class}") + _MODEL_WRAPPERS[name] = wrapper_class + + +def get_model_wrapper(name: str): + """ + Get model wrapper class by name. + + Args: + name: Wrapper name + + Returns: + Wrapper class + + Raises: + KeyError: If wrapper name not found + """ + if name not in _MODEL_WRAPPERS: + raise KeyError( + f"Model wrapper '{name}' not found. " + f"Available wrappers: {list(_MODEL_WRAPPERS.keys())}" + ) + return _MODEL_WRAPPERS[name] + + +def list_model_wrappers(): + """List all registered model wrappers.""" + return list(_MODEL_WRAPPERS.keys()) + diff --git a/autoware_ml/deployment/exporters/onnx_exporter.py b/autoware_ml/deployment/exporters/onnx_exporter.py new file mode 100644 index 000000000..0b99f02af --- /dev/null +++ b/autoware_ml/deployment/exporters/onnx_exporter.py @@ -0,0 +1,174 @@ +"""ONNX model exporter.""" + +import logging +import os +from typing import Any, Dict, Optional + +import onnx +import onnxsim +import torch + +from .base_exporter import BaseExporter + + +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: Dict[str, Any], logger: logging.Logger = None): + """ + Initialize ONNX exporter. + Args: + config: ONNX export configuration + logger: Optional logger instance + """ + super().__init__(config, logger) + + def export( + self, + model: torch.nn.Module, + sample_input: torch.Tensor, + output_path: str, + config_override: Optional[Dict[str, Any]] = None, + ) -> bool: + """ + 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 config overrides for this specific export + Returns: + True if export succeeded + """ + # Apply model wrapper if configured + model = self.prepare_model(model) + model.eval() + + # Merge config with overrides + export_config = self.config.copy() + if config_override: + export_config.update(config_override) + + self.logger.info("Exporting model to ONNX format...") + self.logger.info(f" Input shape: {sample_input.shape}") + self.logger.info(f" Output path: {output_path}") + self.logger.info(f" Opset version: {export_config.get('opset_version', 16)}") + + # 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_config.get("export_params", True), + keep_initializers_as_inputs=export_config.get("keep_initializers_as_inputs", False), + opset_version=export_config.get("opset_version", 16), + do_constant_folding=export_config.get("do_constant_folding", True), + input_names=export_config.get("input_names", ["input"]), + output_names=export_config.get("output_names", ["output"]), + dynamic_axes=export_config.get("dynamic_axes"), + verbose=export_config.get("verbose", False), + ) + + self.logger.info(f"ONNX export completed: {output_path}") + + # Optional model simplification + if export_config.get("simplify", True): + self._simplify_model(output_path) + + return True + + except Exception as e: + self.logger.error(f"ONNX export failed: {e}") + import traceback + self.logger.error(traceback.format_exc()) + return False + + def export_multi( + self, + models: Dict[str, torch.nn.Module], + sample_inputs: Dict[str, torch.Tensor], + output_dir: str, + configs: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> bool: + """ + Export multiple models to separate ONNX files. + + Useful for complex models that need to be split into multiple files + (e.g., CenterPoint: voxel encoder + backbone/neck/head). + + Args: + models: Dict of {filename: model} + sample_inputs: Dict of {filename: input_tensor} + output_dir: Directory to save ONNX files + configs: Optional dict of {filename: config_override} + + Returns: + True if all exports succeeded + """ + self.logger.info(f"Exporting {len(models)} models to {output_dir}") + os.makedirs(output_dir, exist_ok=True) + + success_count = 0 + configs = configs or {} + + for name, model in models.items(): + if name not in sample_inputs: + self.logger.error(f"No sample input provided for model: {name}") + continue + + output_path = os.path.join(output_dir, name) + if not output_path.endswith('.onnx'): + output_path += '.onnx' + + config_override = configs.get(name) + success = self.export( + model=model, + sample_input=sample_inputs[name], + output_path=output_path, + config_override=config_override, + ) + + if success: + success_count += 1 + self.logger.info(f"✅ Exported {name}") + else: + self.logger.error(f"❌ Failed to export {name}") + + total = len(models) + if success_count == total: + self.logger.info(f"✅ All {total} models exported successfully") + return True + elif success_count > 0: + self.logger.warning(f"⚠️ Partial success: {success_count}/{total} models exported") + return False + else: + self.logger.error(f"❌ All exports failed") + return False + + 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(f"ONNX model simplified successfully") + else: + self.logger.warning("ONNX model simplification failed") + except Exception as e: + self.logger.warning(f"ONNX simplification error: {e}") \ No newline at end of file diff --git a/autoware_ml/deployment/exporters/tensorrt_exporter.py b/autoware_ml/deployment/exporters/tensorrt_exporter.py new file mode 100644 index 000000000..3a620bc19 --- /dev/null +++ b/autoware_ml/deployment/exporters/tensorrt_exporter.py @@ -0,0 +1,235 @@ +"""TensorRT model exporter.""" + + +import logging +from typing import Any, Dict + + +import tensorrt as trt +import torch + + +from .base_exporter import BaseExporter + + + + +class TensorRTExporter(BaseExporter): + """ + TensorRT model exporter. + + Converts ONNX models to TensorRT engine format with precision policy support. + """ + + + def __init__(self, config: Dict[str, Any], logger: logging.Logger = None): + """ + Initialize TensorRT exporter. + + Args: + config: TensorRT export configuration + logger: Optional logger instance + """ + super().__init__(config) + self.logger = logger or logging.getLogger(__name__) + + + def export( + self, + model: torch.nn.Module, # Not used for TensorRT, kept for interface compatibility + sample_input: torch.Tensor, + output_path: str, + onnx_path: str = None, + ) -> bool: + """ + 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: + True if export succeeded + """ + if onnx_path is None: + self.logger.error("onnx_path is required for TensorRT export") + return False + + + precision_policy = self.config.get("precision_policy", "auto") + policy_flags = self.config.get("policy_flags", {}) + + + 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}") + + + # Initialize TensorRT + trt_logger = trt.Logger(trt.Logger.WARNING) + trt.init_libnvinfer_plugins(trt_logger, "") + + + builder = trt.Builder(trt_logger) + builder_config = builder.create_builder_config() + + + max_workspace_size = self.config.get("max_workspace_size", 1 << 30) + 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) + if policy_flags.get("STRONGLY_TYPED"): + 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 # Already handled + 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") + + + # Parse ONNX model first to get network structure + parser = trt.OnnxParser(network, trt_logger) + + + try: + with open(onnx_path, "rb") as f: + if not parser.parse(f.read()): + self._log_parser_errors(parser) + return False + self.logger.info("Successfully parsed ONNX file") + + + # Setup optimization profile after parsing ONNX to get actual input names + profile = builder.create_optimization_profile() + self._configure_input_shapes(profile, sample_input, network) + builder_config.add_optimization_profile(profile) + + + # Build engine + 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") + return False + + + # Save engine + with open(output_path, "wb") as f: + f.write(serialized_engine) + + + 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") + + + return True + + + except Exception as e: + self.logger.error(f"TensorRT export failed: {e}") + return False + + + def _configure_input_shapes( + self, + profile: trt.IOptimizationProfile, + sample_input: torch.Tensor, + network: trt.INetworkDefinition = None, + ) -> None: + """ + Configure input shapes for TensorRT optimization profile. + + Args: + profile: TensorRT optimization profile + sample_input: Sample input tensor + network: TensorRT network definition (optional, used to get actual input names) + """ + model_inputs = self.config.get("model_inputs", []) + + + if model_inputs: + input_shapes = model_inputs[0].get("input_shapes", {}) + for input_name, shapes in input_shapes.items(): + min_shape = shapes.get("min_shape", list(sample_input.shape)) + opt_shape = shapes.get("opt_shape", list(sample_input.shape)) + max_shape = shapes.get("max_shape", list(sample_input.shape)) + + + self.logger.info(f"Setting input shapes - min: {min_shape}, " f"opt: {opt_shape}, max: {max_shape}") + profile.set_shape(input_name, min_shape, opt_shape, max_shape) + else: + # Handle different input types based on shape + input_shape = list(sample_input.shape) + + + # Get actual input name from network if available + input_name = "input" # Default fallback + if network is not None and network.num_inputs > 0: + # Use the first input's name from the ONNX model + input_name = network.get_input(0).name + self.logger.info(f"Using input name from ONNX model: {input_name}") + + + # Determine input type based on shape + if len(input_shape) == 3 and input_shape[1] == 32: # voxel encoder: (num_voxels, 32, 11) + # CenterPoint voxel encoder input: input_features + min_shape = [1000, 32, 11] # Minimum voxels + opt_shape = [10000, 32, 11] # Optimal voxels + max_shape = [50000, 32, 11] # Maximum voxels + if network is None: + input_name = "input_features" + elif len(input_shape) == 4 and input_shape[1] == 32: # CenterPoint backbone input: (batch, 32, height, width) + # Backbone input: spatial_features - use dynamic dimensions for H, W + # NOTE: Actual evaluation data can produce up to 760x760, so use 800x800 for max_shape + min_shape = [1, 32, 100, 100] + opt_shape = [1, 32, 200, 200] + max_shape = [1, 32, 800, 800] # Increased from 400x400 to support actual data + if network is None: + input_name = "spatial_features" + elif len(input_shape) == 4 and input_shape[1] in [3, 5]: # Standard image input: (batch, channels, height, width) + # For YOLOX, CalibrationStatusClassification, etc. + # Use sample shape as optimal, allow some variation for batch dimension + batch_size = input_shape[0] + channels = input_shape[1] + height = input_shape[2] + width = input_shape[3] + + + # Allow dynamic batch size if batch_size > 1, otherwise use fixed + if batch_size > 1: + min_shape = [1, channels, height, width] + opt_shape = [batch_size, channels, height, width] + max_shape = [batch_size, channels, height, width] + else: + min_shape = opt_shape = max_shape = input_shape + else: + # Default fallback: use sample shape as-is + min_shape = opt_shape = max_shape = input_shape + + + 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)}") \ No newline at end of file diff --git a/autoware_ml/deployment/pipelines/__init__.py b/autoware_ml/deployment/pipelines/__init__.py new file mode 100644 index 000000000..22327ded4 --- /dev/null +++ b/autoware_ml/deployment/pipelines/__init__.py @@ -0,0 +1,47 @@ +""" +Deployment Pipelines for Complex Models. +This module provides pipeline abstractions for models that require +multi-stage processing with mixed PyTorch and optimized backend inference. +""" + +# # CenterPoint pipelines (3D detection) +# from .centerpoint import ( +# CenterPointDeploymentPipeline, +# CenterPointPyTorchPipeline, +# CenterPointONNXPipeline, +# CenterPointTensorRTPipeline, +# ) + +# # YOLOX pipelines (2D detection) +# from .yolox import ( +# YOLOXDeploymentPipeline, +# YOLOXPyTorchPipeline, +# YOLOXONNXPipeline, +# YOLOXTensorRTPipeline, +# ) + +# # Calibration pipelines (classification) +# from .calibration import ( +# CalibrationDeploymentPipeline, +# CalibrationPyTorchPipeline, +# CalibrationONNXPipeline, +# CalibrationTensorRTPipeline, +# ) + +# __all__ = [ +# # CenterPoint +# 'CenterPointDeploymentPipeline', +# 'CenterPointPyTorchPipeline', +# 'CenterPointONNXPipeline', +# 'CenterPointTensorRTPipeline', +# # YOLOX +# 'YOLOXDeploymentPipeline', +# 'YOLOXPyTorchPipeline', +# 'YOLOXONNXPipeline', +# 'YOLOXTensorRTPipeline', +# # Calibration +# 'CalibrationDeploymentPipeline', +# 'CalibrationPyTorchPipeline', +# 'CalibrationONNXPipeline', +# 'CalibrationTensorRTPipeline', +# ] \ No newline at end of file diff --git a/deployment/README.md b/autoware_ml/deployment/pipelines/calibration/__init__.py similarity index 100% rename from deployment/README.md rename to autoware_ml/deployment/pipelines/calibration/__init__.py diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_onnx.py b/autoware_ml/deployment/pipelines/calibration/calibration_onnx.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_pipeline.py b/autoware_ml/deployment/pipelines/calibration/calibration_pipeline.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_pytorch.py b/autoware_ml/deployment/pipelines/calibration/calibration_pytorch.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_tensorrt.py b/autoware_ml/deployment/pipelines/calibration/calibration_tensorrt.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/centerpoint/__init__.py b/autoware_ml/deployment/pipelines/centerpoint/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_onnx.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_onnx.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pipeline.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pipeline.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pytorch.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pytorch.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_tensorrt.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_tensorrt.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/yolox/__init__.py b/autoware_ml/deployment/pipelines/yolox/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_onnx.py b/autoware_ml/deployment/pipelines/yolox/yolox_onnx.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_pipeline.py b/autoware_ml/deployment/pipelines/yolox/yolox_pipeline.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_pytorch.py b/autoware_ml/deployment/pipelines/yolox/yolox_pytorch.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_tensorrt.py b/autoware_ml/deployment/pipelines/yolox/yolox_tensorrt.py new file mode 100644 index 000000000..e69de29bb diff --git a/autoware_ml/deployment/runners/__init__.py b/autoware_ml/deployment/runners/__init__.py new file mode 100644 index 000000000..d7fe4989b --- /dev/null +++ b/autoware_ml/deployment/runners/__init__.py @@ -0,0 +1,7 @@ +"""Deployment runners for unified deployment workflow.""" + +from .deployment_runner import DeploymentRunner + +__all__ = [ + "DeploymentRunner", +] diff --git a/autoware_ml/deployment/runners/deployment_runner.py b/autoware_ml/deployment/runners/deployment_runner.py new file mode 100644 index 000000000..bdd5b0ebe --- /dev/null +++ b/autoware_ml/deployment/runners/deployment_runner.py @@ -0,0 +1,882 @@ + +""" +Unified deployment runner for common deployment workflows. +This module provides a unified runner that handles the common deployment workflow +across different projects, while allowing project-specific customization. +""" + +import os +import logging +from typing import Any, Dict, Optional, Callable, List, Tuple + +import torch +from mmengine.config import Config + +from autoware_ml.deployment.core import BaseDeploymentConfig, BaseDataLoader, BaseEvaluator +from autoware_ml.deployment.exporters.onnx_exporter import ONNXExporter +from autoware_ml.deployment.exporters.tensorrt_exporter import TensorRTExporter + + +class DeploymentRunner: + """ + Unified deployment runner for common deployment workflows. + + This runner handles the standard deployment workflow: + 1. Load PyTorch model (if needed) + 2. Export to ONNX (if requested) + 3. Export to TensorRT (if requested) + 4. Verify outputs (if enabled) + 5. Evaluate models (if enabled) + + Projects can customize behavior by: + - Overriding methods (load_pytorch_model, export_onnx, export_tensorrt) + - Providing custom callbacks + - Extending this class + """ + + def __init__( + self, + data_loader: BaseDataLoader, + evaluator: BaseEvaluator, + config: BaseDeploymentConfig, + model_cfg: Config, + logger: logging.Logger, + load_model_fn: Optional[Callable] = None, + export_onnx_fn: Optional[Callable] = None, + export_tensorrt_fn: Optional[Callable] = None, + onnx_exporter: Optional[Any] = None, + tensorrt_exporter: Optional[Any] = None, + ): + """ + Initialize unified deployment runner. + + Args: + data_loader: Data loader for samples + evaluator: Evaluator for model evaluation + config: Deployment configuration + model_cfg: Model configuration + logger: Logger instance + load_model_fn: Optional custom function to load PyTorch model + export_onnx_fn: Optional custom function to export ONNX + export_tensorrt_fn: Optional custom function to export TensorRT + onnx_exporter: Optional ONNX exporter instance (e.g., CenterPointONNXExporter) + tensorrt_exporter: Optional TensorRT exporter instance (e.g., CenterPointTensorRTExporter) + """ + self.data_loader = data_loader + self.evaluator = evaluator + self.config = config + self.model_cfg = model_cfg + self.logger = logger + self._load_model_fn = load_model_fn + self._export_onnx_fn = export_onnx_fn + self._export_tensorrt_fn = export_tensorrt_fn + self._onnx_exporter = onnx_exporter + self._tensorrt_exporter = tensorrt_exporter + + def load_pytorch_model( + self, + checkpoint_path: str, + **kwargs + ) -> Any: + """ + Load PyTorch model from checkpoint. + + Uses custom function if provided, otherwise uses default implementation. + + Args: + checkpoint_path: Path to checkpoint file + **kwargs: Additional project-specific arguments + + Returns: + Loaded PyTorch model + """ + if self._load_model_fn: + return self._load_model_fn(checkpoint_path, **kwargs) + + # Default implementation - should be overridden by projects + self.logger.warning("Using default load_pytorch_model - projects should override this") + raise NotImplementedError("load_pytorch_model must be implemented or provided via load_model_fn") + + def export_onnx( + self, + pytorch_model: Any, + **kwargs + ) -> Optional[str]: + """ + Export model to ONNX format. + + Uses custom function if provided, otherwise uses standard ONNXExporter. + + Args: + pytorch_model: PyTorch model to export + **kwargs: Additional project-specific arguments + + Returns: + Path to exported ONNX file/directory, or None if export failed + """ + if self._export_onnx_fn: + return self._export_onnx_fn(pytorch_model, self.data_loader, self.config, self.logger, **kwargs) + + # Standard ONNX export using ONNXExporter + if not self.config.export_config.should_export_onnx(): + return None + + self.logger.info("=" * 80) + self.logger.info("Exporting to ONNX (Using Unified ONNXExporter)") + self.logger.info("=" * 80) + + # Get ONNX settings + onnx_settings = self.config.get_onnx_settings() + + # Use provided exporter instance if available + if self._onnx_exporter is not None: + exporter = self._onnx_exporter + self.logger.info("=" * 80) + self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") + self.logger.info("=" * 80) + + # Check if it's a CenterPoint exporter (needs special handling) + # CenterPoint exporter has data_loader parameter in export method + import inspect + sig = inspect.signature(exporter.export) + if 'data_loader' in sig.parameters: + # CenterPoint exporter signature + # Save to work_dir/onnx/ directory + output_dir = os.path.join(self.config.export_config.work_dir, "onnx") + os.makedirs(output_dir, exist_ok=True) + if not hasattr(pytorch_model, "_extract_features"): + self.logger.error("❌ ONNX export requires an ONNX-compatible model (CenterPointONNX).") + return None + + success = exporter.export( + model=pytorch_model, + data_loader=self.data_loader, + output_dir=output_dir, + sample_idx=0 + ) + + if success: + self.logger.info(f"✅ ONNX export successful: {output_dir}") + return output_dir + else: + self.logger.error(f"❌ ONNX export failed") + return None + + # Standard ONNX export + # Save to work_dir/onnx/ directory + onnx_dir = os.path.join(self.config.export_config.work_dir, "onnx") + os.makedirs(onnx_dir, exist_ok=True) + output_path = os.path.join(onnx_dir, onnx_settings["save_file"]) + + # Get sample input + sample_idx = self.config.runtime_config.get("sample_idx", 0) + sample = self.data_loader.load_sample(sample_idx) + single_input = self.data_loader.preprocess(sample) + + # Ensure tensor is float32 + if single_input.dtype != torch.float32: + single_input = single_input.float() + + # Get batch size from configuration + batch_size = onnx_settings.get("batch_size", 1) + if batch_size is None: + input_tensor = single_input + self.logger.info("Using dynamic batch size") + else: + # Handle different input shapes + if isinstance(single_input, (list, tuple)): + # Multiple inputs + 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: + # Single input + input_tensor = single_input.repeat(batch_size, *([1] * (len(single_input.shape) - 1))) + self.logger.info(f"Using fixed batch size: {batch_size}") + + # Use provided exporter or create default + if self._onnx_exporter is not None: + exporter = self._onnx_exporter + else: + exporter = ONNXExporter(onnx_settings, self.logger) + + success = exporter.export(pytorch_model, input_tensor, output_path) + + if success: + self.logger.info(f"✅ ONNX export successful: {output_path}") + return output_path + else: + self.logger.error(f"❌ ONNX export failed") + return None + + def export_tensorrt( + self, + onnx_path: str, + **kwargs + ) -> Optional[str]: + """ + Export ONNX model to TensorRT engine. + + Uses custom function if provided, otherwise uses standard TensorRTExporter. + + Args: + onnx_path: Path to ONNX model file/directory + **kwargs: Additional project-specific arguments + + Returns: + Path to exported TensorRT engine file/directory, or None if export failed + """ + if self._export_tensorrt_fn: + return self._export_tensorrt_fn(onnx_path, self.config, self.data_loader, self.logger, **kwargs) + + # Standard TensorRT export using TensorRTExporter + 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 + + trt_settings = self.config.get_tensorrt_settings() + + # Use provided exporter instance if available + if self._tensorrt_exporter is not None: + exporter = self._tensorrt_exporter + self.logger.info("=" * 80) + self.logger.info(f"Exporting to TensorRT (Using {type(exporter).__name__})") + self.logger.info("=" * 80) + + # Check if it's a CenterPoint exporter (needs special handling) + # CenterPoint exporter has onnx_dir parameter in export method + import inspect + sig = inspect.signature(exporter.export) + if 'onnx_dir' in sig.parameters: + # CenterPoint exporter signature + if not os.path.isdir(onnx_path): + self.logger.error("CenterPoint requires ONNX directory, not a single file") + return None + + # Save to work_dir/tensorrt/ directory + output_dir = os.path.join(self.config.export_config.work_dir, "tensorrt") + os.makedirs(output_dir, exist_ok=True) + + success = exporter.export( + onnx_dir=onnx_path, + output_dir=output_dir, + device=self.config.export_config.device + ) + + if success: + self.logger.info(f"✅ TensorRT export successful: {output_dir}") + return output_dir + else: + self.logger.error(f"❌ TensorRT export failed") + return None + + # Standard TensorRT export + self.logger.info("=" * 80) + self.logger.info("Exporting to TensorRT (Using Unified TensorRTExporter)") + self.logger.info("=" * 80) + + # Save to work_dir/tensorrt/ directory + tensorrt_dir = os.path.join(self.config.export_config.work_dir, "tensorrt") + os.makedirs(tensorrt_dir, exist_ok=True) + + # Determine output path based on ONNX file name + if os.path.isdir(onnx_path): + # For multi-file ONNX (shouldn't happen in standard export, but handle it) + # Use the directory name or a default name + output_path = os.path.join(tensorrt_dir, "model.engine") + else: + # Single file: extract filename and change extension + onnx_filename = os.path.basename(onnx_path) + engine_filename = onnx_filename.replace(".onnx", ".engine") + output_path = os.path.join(tensorrt_dir, engine_filename) + + # Get sample input for shape configuration + sample_idx = self.config.runtime_config.get("sample_idx", 0) + sample = self.data_loader.load_sample(sample_idx) + sample_input = self.data_loader.preprocess(sample) + + # Ensure tensor is float32 + if isinstance(sample_input, (list, tuple)): + sample_input = sample_input[0] # Use first input for shape + if sample_input.dtype != torch.float32: + sample_input = sample_input.float() + + # Merge backend_config.model_inputs into trt_settings for TensorRTExporter + if hasattr(self.config, 'backend_config') and hasattr(self.config.backend_config, 'model_inputs'): + trt_settings = trt_settings.copy() + trt_settings['model_inputs'] = self.config.backend_config.model_inputs + + # Use provided exporter or create default + if self._tensorrt_exporter is not None: + exporter = self._tensorrt_exporter + else: + exporter = TensorRTExporter(trt_settings, self.logger) + + success = exporter.export( + model=None, # Not used for TensorRT + sample_input=sample_input, + output_path=output_path, + onnx_path=onnx_path + ) + + if success: + self.logger.info(f"✅ TensorRT export successful: {output_path}") + return output_path + else: + self.logger.error(f"❌ TensorRT export failed") + return None + + # TODO(vivdf): check this, the current design is not clean. + def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: + """ + Get list of models to evaluate from config. + Returns: + List of tuples (backend_name, model_path, device) + """ + backends = self.config.get_evaluation_backends() + models_to_evaluate: List[Tuple[str, str, str]] = [] + + for backend_name, backend_cfg in backends.items(): + if not backend_cfg.get("enabled", False): + continue + + device = backend_cfg.get("device", "cpu") + model_path = None + is_valid = False + + if backend_name == "pytorch": + model_path = backend_cfg.get("checkpoint") + if model_path: + is_valid = os.path.exists(model_path) and os.path.isfile(model_path) + elif backend_name == "onnx": + model_path = backend_cfg.get("model_dir") + # If model_dir is None, try to infer from export config + if model_path is None: + work_dir = self.config.export_config.work_dir + onnx_dir = os.path.join(work_dir, "onnx") + save_file = self.config.onnx_config.get("save_file", "model.onnx") + multi_file = self.config.onnx_config.get("multi_file", False) # Default to single file + + if os.path.exists(onnx_dir) and os.path.isdir(onnx_dir): + # Check for ONNX files in work_dir/onnx/ directory + onnx_files = [f for f in os.listdir(onnx_dir) if f.endswith('.onnx')] + if onnx_files: + if multi_file: + # Multi-file ONNX: return directory path + model_path = onnx_dir + is_valid = True + else: + # Single file ONNX: use the save_file if it exists, otherwise use the first ONNX file found + expected_file = os.path.join(onnx_dir, save_file) + if os.path.exists(expected_file): + model_path = expected_file + else: + model_path = os.path.join(onnx_dir, onnx_files[0]) + is_valid = True + else: + if multi_file: + # Multi-file ONNX but no files found: still return directory + model_path = onnx_dir + is_valid = True + else: + # Try single file path + model_path = os.path.join(onnx_dir, save_file) + is_valid = os.path.exists(model_path) and model_path.endswith('.onnx') + else: + if multi_file: + # Multi-file ONNX: return directory even if it doesn't exist yet + model_path = onnx_dir + is_valid = True + else: + # Fallback: try in work_dir directly (for backward compatibility) + model_path = os.path.join(work_dir, save_file) + is_valid = os.path.exists(model_path) and model_path.endswith('.onnx') + else: + # model_dir is explicitly set in config + multi_file = self.config.onnx_config.get("multi_file", False) + if os.path.exists(model_path): + if os.path.isfile(model_path): + # Single file ONNX + is_valid = model_path.endswith('.onnx') and not multi_file + elif os.path.isdir(model_path): + # Directory: valid if multi_file is True, or if it contains ONNX files + if multi_file: + is_valid = True + else: + # Single file mode: find the ONNX file in directory + onnx_files = [f for f in os.listdir(model_path) if f.endswith('.onnx')] + if onnx_files: + # Use the save_file if it exists, otherwise use the first ONNX file found + save_file = self.config.onnx_config.get("save_file", "model.onnx") + expected_file = os.path.join(model_path, save_file) + if os.path.exists(expected_file): + model_path = expected_file + else: + model_path = os.path.join(model_path, onnx_files[0]) + is_valid = True + else: + is_valid = False + else: + is_valid = False + else: + is_valid = False + elif backend_name == "tensorrt": + model_path = backend_cfg.get("engine_dir") + # If engine_dir is None, try to infer from export config + if model_path is None: + work_dir = self.config.export_config.work_dir + engine_dir = os.path.join(work_dir, "tensorrt") + multi_file = self.config.onnx_config.get("multi_file", False) # Use same config as ONNX + + if os.path.exists(engine_dir) and os.path.isdir(engine_dir): + engine_files = [f for f in os.listdir(engine_dir) if f.endswith('.engine')] + if engine_files: + if multi_file: + # Multi-file TensorRT: return directory path + model_path = engine_dir + is_valid = True + else: + # Single file TensorRT: use the engine file matching ONNX filename + onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") + expected_engine = onnx_save_file.replace(".onnx", ".engine") + expected_path = os.path.join(engine_dir, expected_engine) + if os.path.exists(expected_path): + model_path = expected_path + else: + # Fallback: use the first engine file found + model_path = os.path.join(engine_dir, engine_files[0]) + is_valid = True + else: + if multi_file: + # Multi-file TensorRT but no files found: still return directory + model_path = engine_dir + is_valid = True + else: + is_valid = False + else: + if multi_file: + # Multi-file TensorRT: return directory even if it doesn't exist yet + model_path = engine_dir + is_valid = True + else: + # Fallback: try in work_dir directly (for backward compatibility) + if os.path.exists(work_dir) and os.path.isdir(work_dir): + engine_files = [f for f in os.listdir(work_dir) if f.endswith('.engine')] + if engine_files: + onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") + expected_engine = onnx_save_file.replace(".onnx", ".engine") + expected_path = os.path.join(work_dir, expected_engine) + if os.path.exists(expected_path): + model_path = expected_path + else: + model_path = os.path.join(work_dir, engine_files[0]) + is_valid = True + else: + is_valid = False + else: + is_valid = False + else: + # engine_dir is explicitly set in config + multi_file = self.config.onnx_config.get("multi_file", False) + if os.path.exists(model_path): + if os.path.isfile(model_path): + # Single file TensorRT + is_valid = (model_path.endswith('.engine') or model_path.endswith('.trt')) and not multi_file + elif os.path.isdir(model_path): + # Directory: valid if multi_file is True, or if it contains engine files + if multi_file: + is_valid = True + else: + # Single file mode: find the engine file in directory + engine_files = [f for f in os.listdir(model_path) if f.endswith('.engine')] + if engine_files: + # Try to match ONNX filename, otherwise use the first engine file found + onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") + expected_engine = onnx_save_file.replace(".onnx", ".engine") + expected_path = os.path.join(model_path, expected_engine) + if os.path.exists(expected_path): + model_path = expected_path + else: + model_path = os.path.join(model_path, engine_files[0]) + is_valid = True + else: + is_valid = False + + if is_valid and model_path: + models_to_evaluate.append((backend_name, model_path, device)) + self.logger.info(f" - {backend_name}: {model_path} (device: {device})") + elif model_path: + self.logger.warning(f" - {backend_name}: {model_path} (not found or invalid, skipping)") + + return models_to_evaluate + + def run_verification( + self, + pytorch_checkpoint: Optional[str], + onnx_path: Optional[str], + tensorrt_path: Optional[str], + **kwargs + ) -> Dict[str, Any]: + """ + Run verification on exported models using policy-based verification. + Args: + pytorch_checkpoint: Path to PyTorch checkpoint (reference) + onnx_path: Path to ONNX model file/directory + tensorrt_path: Path to TensorRT engine file/directory + **kwargs: Additional project-specific arguments + Returns: + Verification results dictionary + """ + verification_cfg = self.config.verification_config + + # Check master switches + if not verification_cfg.get("enabled", True): + 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}', skipping...") + return {} + + if not pytorch_checkpoint: + self.logger.warning("PyTorch checkpoint path not available, skipping verification") + return {} + + verification_cfg = self.config.verification_config + num_verify_samples = verification_cfg.get("num_verify_samples", 3) + tolerance = verification_cfg.get("tolerance", 0.1) + devices_map = verification_cfg.get("devices", {}) or {} + + self.logger.info("=" * 80) + self.logger.info(f"Running Verification (mode: {export_mode})") + self.logger.info("=" * 80) + + all_results = {} + total_passed = 0 + total_failed = 0 + + for i, policy in enumerate(scenarios): + ref_backend = policy["ref_backend"] + # Resolve device using alias system: + # - Scenarios use aliases (e.g., "cpu", "cuda") for flexibility + # - Actual device strings are defined in verification["devices"] + # - This allows easy device switching: change devices["cpu"] to affect all CPU verifications + ref_device_key = policy["ref_device"] + if ref_device_key in devices_map: + ref_device = devices_map[ref_device_key] + else: + # Fallback: use the key directly if not found in devices_map (backward compatibility) + ref_device = ref_device_key + self.logger.warning(f"Device alias '{ref_device_key}' not found in devices map, using as-is") + + test_backend = policy["test_backend"] + test_device_key = policy["test_device"] + if test_device_key in devices_map: + test_device = devices_map[test_device_key] + else: + # Fallback: use the key directly if not found in devices_map (backward compatibility) + test_device = test_device_key + self.logger.warning(f"Device alias '{test_device_key}' not found in devices map, using as-is") + + self.logger.info(f"\Scenarios {i+1}/{len(scenarios)}: {ref_backend}({ref_device}) vs {test_backend}({test_device})") + + # Resolve model paths based on backend + ref_path = None + test_path = None + + if ref_backend == "pytorch": + ref_path = pytorch_checkpoint + elif ref_backend == "onnx": + ref_path = onnx_path + + if test_backend == "onnx": + test_path = onnx_path + elif test_backend == "tensorrt": + test_path = tensorrt_path + + if not ref_path or not test_path: + self.logger.warning(f" Skipping: missing paths (ref={ref_path}, test={test_path})") + continue + + # Use policy-based verification interface + verification_results = self.evaluator.verify( + ref_backend=ref_backend, + ref_device=ref_device, + ref_path=ref_path, + test_backend=test_backend, + test_device=test_device, + test_path=test_path, + data_loader=self.data_loader, + num_samples=num_verify_samples, + tolerance=tolerance, + verbose=False, + ) + + # Extract results for this specific comparison + policy_key = f"{ref_backend}_{ref_device}_vs_{test_backend}_{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" ✅ Policy {i+1} passed ({passed} comparisons)") + else: + self.logger.warning(f" ⚠️ Policy {i+1} failed ({failed}/{passed+failed} comparisons)") + + # Overall summary + 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 run_evaluation(self, **kwargs) -> Dict[str, Any]: + """ + Run evaluation on specified models. + Args: + **kwargs: Additional project-specific arguments + Returns: + Dictionary containing evaluation results for all backends + """ + eval_config = self.config.evaluation_config + + if not eval_config.get("enabled", False): + 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() + + if not models_to_evaluate: + self.logger.warning("No models found for evaluation") + return {} + + num_samples = eval_config.get("num_samples", 10) + if num_samples == -1: + num_samples = self.data_loader.get_num_samples() + + verbose_mode = eval_config.get("verbose", False) + + all_results: Dict[str, Any] = {} + + for backend, model_path, backend_device in models_to_evaluate: + + if backend in ("pytorch", "onnx"): + if backend_device not in (None, "cpu") and not str(backend_device).startswith("cuda"): + self.logger.warning( + f"Unsupported device '{backend_device}' for backend '{backend}'. Falling back to CPU." + ) + backend_device = "cpu" + elif backend == "tensorrt": + if backend_device is None: + backend_device = "cuda:0" + if backend_device != "cuda:0": + self.logger.warning( + f"TensorRT evaluation only supports 'cuda:0'. Overriding device from '{backend_device}' to 'cuda:0'." + ) + backend_device = "cuda:0" + + if backend_device is None: + backend_device = "cpu" + + results = self.evaluator.evaluate( + model_path=model_path, + data_loader=self.data_loader, + num_samples=num_samples, + backend=backend, + device=backend_device, + verbose=verbose_mode, + ) + + all_results[backend] = results + + self.logger.info(f"\n{backend.upper()} Results:") + self.evaluator.print_results(results) + + if len(all_results) > 1: + self.logger.info("\n" + "=" * 80) + self.logger.info("Cross-Backend Comparison") + self.logger.info("=" * 80) + + for backend, results in all_results.items(): + self.logger.info(f"\n{backend.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") + + return all_results + + def run( + self, + checkpoint_path: Optional[str] = None, + **kwargs + ) -> Dict[str, Any]: + """ + Execute the complete deployment workflow. + + Args: + checkpoint_path: Path to PyTorch checkpoint (optional) + **kwargs: Additional project-specific arguments + + Returns: + Dictionary containing deployment results + """ + results = { + "pytorch_model": None, + "onnx_path": None, + "tensorrt_path": None, + "verification_results": {}, + "evaluation_results": {}, + } + + export_mode = self.config.export_config.mode + should_export_onnx = self.config.export_config.should_export_onnx() + should_export_trt = self.config.export_config.should_export_tensorrt() + + # Resolve checkpoint / ONNX sources from config if not provided via CLI + if checkpoint_path is None: + checkpoint_path = self.config.export_config.checkpoint_path + + external_onnx_path = self.config.export_config.onnx_path + + # Check if we need model loading and export + eval_config = self.config.evaluation_config + verification_cfg = self.config.verification_config + needs_onnx_eval = False + if eval_config.get("enabled", False): + models_to_eval = eval_config.get("models", {}) + if models_to_eval.get("onnx") or models_to_eval.get("tensorrt"): + needs_onnx_eval = True + + requires_pytorch_model = False + if should_export_onnx: + requires_pytorch_model = True + elif eval_config.get("enabled", False): + models_to_eval = eval_config.get("models", {}) + if models_to_eval.get("pytorch"): + requires_pytorch_model = True + elif needs_onnx_eval and eval_config.get("models", {}).get("pytorch"): + requires_pytorch_model = True + elif verification_cfg.get("enabled", False) and should_export_onnx: + requires_pytorch_model = True + + # Load model if needed for export or ONNX/TensorRT evaluation + pytorch_model = None + + if requires_pytorch_model: + if not checkpoint_path: + self.logger.error("Checkpoint required but not provided. Please set export.checkpoint_path in config or pass via CLI.") + return results + + self.logger.info("\nLoading PyTorch model...") + try: + pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) + results["pytorch_model"] = pytorch_model + except Exception as e: + self.logger.error(f"Failed to load PyTorch model: {e}") + return results + + # Export ONNX if requested + if should_export_onnx: + if pytorch_model is None: + if not checkpoint_path: + self.logger.error("ONNX export requires checkpoint_path but none was provided.") + return results + self.logger.info("\nLoading PyTorch model for ONNX export...") + try: + pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) + results["pytorch_model"] = pytorch_model + except Exception as e: + self.logger.error(f"Failed to load PyTorch model: {e}") + return results + + try: + onnx_path = self.export_onnx(pytorch_model, **kwargs) + results["onnx_path"] = onnx_path + except Exception as e: + self.logger.error(f"Failed to export ONNX: {e}") + + # Export TensorRT if requested + if should_export_trt: + onnx_source = results["onnx_path"] or external_onnx_path + if not onnx_source: + self.logger.error("TensorRT export requires an ONNX path. Please set export.onnx_path in config or enable ONNX export.") + return results + else: + results["onnx_path"] = onnx_source # Ensure verification/evaluation can use this path + try: + tensorrt_path = self.export_tensorrt(onnx_source, **kwargs) + results["tensorrt_path"] = tensorrt_path + except Exception as e: + self.logger.error(f"Failed to export TensorRT: {e}") + + # Get model paths from evaluation config if not exported + if not results["onnx_path"] or not results["tensorrt_path"]: + eval_models = self.config.evaluation_config.get("models", {}) + if not results["onnx_path"]: + onnx_path = eval_models.get("onnx") + if onnx_path and os.path.exists(onnx_path): + results["onnx_path"] = onnx_path + elif onnx_path: + self.logger.warning(f"ONNX file from config does not exist: {onnx_path}") + if not results["tensorrt_path"]: + tensorrt_path = eval_models.get("tensorrt") + if tensorrt_path and os.path.exists(tensorrt_path): + results["tensorrt_path"] = tensorrt_path + elif tensorrt_path: + self.logger.warning(f"TensorRT engine from config does not exist: {tensorrt_path}") + + # Verification + verification_results = self.run_verification( + pytorch_checkpoint=checkpoint_path, + onnx_path=results["onnx_path"], + tensorrt_path=results["tensorrt_path"], + **kwargs + ) + results["verification_results"] = verification_results + + # Evaluation + evaluation_results = self.run_evaluation(**kwargs) + results["evaluation_results"] = evaluation_results + + self.logger.info("\n" + "=" * 80) + self.logger.info("Deployment Complete!") + self.logger.info("=" * 80) + + return results + + From d5d3b43e9f77ac8ad97dab38da04f2bc87a06e03 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 14 Nov 2025 13:00:33 +0900 Subject: [PATCH 04/62] chore: move pipeline related code to pipeline directory Signed-off-by: vividf --- autoware_ml/deployment/core/__init__.py | 8 -------- .../deployment/pipelines/base/__init__.py | 19 +++++++++++++++++++ .../{core => pipelines/base}/base_pipeline.py | 0 .../base}/classification_pipeline.py | 0 .../base}/detection_2d_pipeline.py | 0 .../base}/detection_3d_pipeline.py | 0 6 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 autoware_ml/deployment/pipelines/base/__init__.py rename autoware_ml/deployment/{core => pipelines/base}/base_pipeline.py (100%) rename autoware_ml/deployment/{core => pipelines/base}/classification_pipeline.py (100%) rename autoware_ml/deployment/{core => pipelines/base}/detection_2d_pipeline.py (100%) rename autoware_ml/deployment/{core => pipelines/base}/detection_3d_pipeline.py (100%) diff --git a/autoware_ml/deployment/core/__init__.py b/autoware_ml/deployment/core/__init__.py index 47c5130cf..75ad83e7e 100644 --- a/autoware_ml/deployment/core/__init__.py +++ b/autoware_ml/deployment/core/__init__.py @@ -10,10 +10,6 @@ ) from .base_data_loader import BaseDataLoader from .base_evaluator import BaseEvaluator -from .base_pipeline import BaseDeploymentPipeline -from .detection_2d_pipeline import Detection2DPipeline -from .detection_3d_pipeline import Detection3DPipeline -from .classification_pipeline import ClassificationPipeline from .preprocessing_builder import ( build_preprocessing_pipeline, register_preprocessing_builder, @@ -28,10 +24,6 @@ "parse_base_args", "BaseDataLoader", "BaseEvaluator", - "BaseDeploymentPipeline", - "Detection2DPipeline", - "Detection3DPipeline", - "ClassificationPipeline", "build_preprocessing_pipeline", "register_preprocessing_builder", ] \ No newline at end of file diff --git a/autoware_ml/deployment/pipelines/base/__init__.py b/autoware_ml/deployment/pipelines/base/__init__.py new file mode 100644 index 000000000..10e7de151 --- /dev/null +++ b/autoware_ml/deployment/pipelines/base/__init__.py @@ -0,0 +1,19 @@ +""" +Base Pipeline Classes for Deployment Framework. + +This module provides the base abstract classes for all deployment pipelines, +including base pipeline, classification, 2D detection, and 3D detection pipelines. +""" + +from .base_pipeline import BaseDeploymentPipeline +from .classification_pipeline import ClassificationPipeline +from .detection_2d_pipeline import Detection2DPipeline +from .detection_3d_pipeline import Detection3DPipeline + +__all__ = [ + "BaseDeploymentPipeline", + "ClassificationPipeline", + "Detection2DPipeline", + "Detection3DPipeline", +] + diff --git a/autoware_ml/deployment/core/base_pipeline.py b/autoware_ml/deployment/pipelines/base/base_pipeline.py similarity index 100% rename from autoware_ml/deployment/core/base_pipeline.py rename to autoware_ml/deployment/pipelines/base/base_pipeline.py diff --git a/autoware_ml/deployment/core/classification_pipeline.py b/autoware_ml/deployment/pipelines/base/classification_pipeline.py similarity index 100% rename from autoware_ml/deployment/core/classification_pipeline.py rename to autoware_ml/deployment/pipelines/base/classification_pipeline.py diff --git a/autoware_ml/deployment/core/detection_2d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py similarity index 100% rename from autoware_ml/deployment/core/detection_2d_pipeline.py rename to autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py diff --git a/autoware_ml/deployment/core/detection_3d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py similarity index 100% rename from autoware_ml/deployment/core/detection_3d_pipeline.py rename to autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py From 3f2cd14b66f5489494728b7aca3bbae4ee8f46ff Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 14 Nov 2025 13:28:35 +0900 Subject: [PATCH 05/62] chore: update readme Signed-off-by: vividf --- autoware_ml/deployment/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/autoware_ml/deployment/README.md b/autoware_ml/deployment/README.md index 9c580f836..2f4107f9b 100644 --- a/autoware_ml/deployment/README.md +++ b/autoware_ml/deployment/README.md @@ -549,19 +549,20 @@ autoware_ml/deployment/ │ ├── base_config.py # Configuration management │ ├── base_data_loader.py # Data loader interface │ ├── base_evaluator.py # Evaluator interface -│ ├── base_pipeline.py # Pipeline base class -│ ├── detection_2d_pipeline.py # 2D detection pipeline -│ ├── detection_3d_pipeline.py # 3D detection pipeline -│ └── classification_pipeline.py # Classification pipeline +│ └── preprocessing_builder.py # Preprocessing builder │ ├── exporters/ # Model exporters │ ├── base_exporter.py # Exporter base class │ ├── onnx_exporter.py # ONNX exporter │ ├── tensorrt_exporter.py # TensorRT exporter -│ ├── centerpoint_exporter.py # CenterPoint-specific exporters │ └── model_wrappers.py # Model wrappers for ONNX │ ├── pipelines/ # Task-specific pipelines +│ ├── base/ # Base pipeline classes +│ │ ├── base_pipeline.py # Pipeline base class +│ │ ├── detection_2d_pipeline.py # 2D detection pipeline +│ │ ├── detection_3d_pipeline.py # 3D detection pipeline +│ │ └── classification_pipeline.py # Classification pipeline │ ├── centerpoint/ # CenterPoint pipelines │ │ ├── centerpoint_pipeline.py │ │ ├── centerpoint_pytorch.py From e725d8627513868c21a8470b340297eb1060f4fe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 14 Nov 2025 04:32:22 +0000 Subject: [PATCH 06/62] ci(pre-commit): autofix --- autoware_ml/deployment/README.md | 6 +- autoware_ml/deployment/__init__.py | 2 +- autoware_ml/deployment/core/__init__.py | 2 +- autoware_ml/deployment/core/base_config.py | 34 ++-- .../deployment/core/base_data_loader.py | 2 +- autoware_ml/deployment/core/base_evaluator.py | 8 +- .../deployment/core/preprocessing_builder.py | 18 +-- autoware_ml/deployment/exporters/__init__.py | 10 +- .../deployment/exporters/base_exporter.py | 26 ++-- .../deployment/exporters/model_wrappers.py | 26 ++-- .../deployment/exporters/onnx_exporter.py | 15 +- .../deployment/exporters/tensorrt_exporter.py | 52 ++----- autoware_ml/deployment/pipelines/__init__.py | 2 +- .../deployment/pipelines/base/__init__.py | 1 - .../pipelines/base/base_pipeline.py | 103 ++++++------ .../pipelines/base/classification_pipeline.py | 69 ++++----- .../pipelines/base/detection_2d_pipeline.py | 61 +++----- .../pipelines/base/detection_3d_pipeline.py | 73 ++++----- .../deployment/runners/deployment_runner.py | 146 ++++++++---------- 19 files changed, 269 insertions(+), 387 deletions(-) diff --git a/autoware_ml/deployment/README.md b/autoware_ml/deployment/README.md index 2f4107f9b..390b55d98 100644 --- a/autoware_ml/deployment/README.md +++ b/autoware_ml/deployment/README.md @@ -114,7 +114,7 @@ verification = dict( enabled=True, scenarios={ "both": [ - {"ref_backend": "pytorch", "ref_device": "cpu", + {"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"}, @@ -342,7 +342,7 @@ See project-specific configs: **Pipeline Structure:** ``` -preprocess() → run_voxel_encoder() → process_middle_encoder() → +preprocess() → run_voxel_encoder() → process_middle_encoder() → run_backbone_head() → postprocess() ``` @@ -690,4 +690,4 @@ When adding a new project: ## License -See LICENSE file in project root. \ No newline at end of file +See LICENSE file in project root. diff --git a/autoware_ml/deployment/__init__.py b/autoware_ml/deployment/__init__.py index 97e14a757..c667f538c 100644 --- a/autoware_ml/deployment/__init__.py +++ b/autoware_ml/deployment/__init__.py @@ -20,4 +20,4 @@ "build_preprocessing_pipeline", ] -__version__ = "1.0.0" \ No newline at end of file +__version__ = "1.0.0" diff --git a/autoware_ml/deployment/core/__init__.py b/autoware_ml/deployment/core/__init__.py index 75ad83e7e..fada1ad39 100644 --- a/autoware_ml/deployment/core/__init__.py +++ b/autoware_ml/deployment/core/__init__.py @@ -26,4 +26,4 @@ "BaseEvaluator", "build_preprocessing_pipeline", "register_preprocessing_builder", -] \ No newline at end of file +] diff --git a/autoware_ml/deployment/core/base_config.py b/autoware_ml/deployment/core/base_config.py index fa0c2a235..20ba64e21 100644 --- a/autoware_ml/deployment/core/base_config.py +++ b/autoware_ml/deployment/core/base_config.py @@ -144,7 +144,7 @@ def verification_config(self) -> Dict: def get_evaluation_backends(self) -> Dict[str, Dict[str, Any]]: """ Get evaluation backends configuration. - + Returns: Dictionary mapping backend names to their configuration """ @@ -154,10 +154,10 @@ def get_evaluation_backends(self) -> Dict[str, Dict[str, Any]]: def get_verification_scenarios(self, export_mode: str) -> List[Dict[str, str]]: """ Get verification scenarios for the given export mode. - + Args: export_mode: Export mode ('onnx', 'trt', 'both', 'none') - + Returns: List of verification scenarios dictionaries """ @@ -258,11 +258,13 @@ def update_batch_size(self, batch_size: int) -> None: # Add primary input full_shape = (batch_size,) + input_shape - model_inputs.append(dict( - name=input_name, - shape=full_shape, - dtype=input_dtype, - )) + model_inputs.append( + dict( + name=input_name, + shape=full_shape, + dtype=input_dtype, + ) + ) # Add additional inputs if specified additional_inputs = model_io.get("additional_inputs", []) @@ -280,16 +282,18 @@ def update_batch_size(self, batch_size: int) -> None: # Add batch dimension for fixed shapes full_add_shape = (batch_size,) + add_shape - model_inputs.append(dict( - name=add_name, - shape=full_add_shape, - dtype=add_dtype, - )) + model_inputs.append( + dict( + name=add_name, + shape=full_add_shape, + dtype=add_dtype, + ) + ) # Update model_inputs in backend config self.backend_config.model_inputs = model_inputs else: - # If model_inputs already exists (e.g., TensorRT shape ranges), + # If model_inputs already exists (e.g., TensorRT shape ranges), # update batch size in existing shapes if they are simple shapes for model_input in existing_model_inputs: if isinstance(model_input, dict) and "shape" in model_input: @@ -342,4 +346,4 @@ def parse_base_args(parser: Optional[argparse.ArgumentParser] = None) -> argpars parser.add_argument("--device", help="Override device from config") parser.add_argument("--log-level", default="INFO", choices=list(logging._nameToLevel.keys()), help="Logging level") - return parser \ No newline at end of file + return parser diff --git a/autoware_ml/deployment/core/base_data_loader.py b/autoware_ml/deployment/core/base_data_loader.py index 68c3136fb..a119bf501 100644 --- a/autoware_ml/deployment/core/base_data_loader.py +++ b/autoware_ml/deployment/core/base_data_loader.py @@ -65,4 +65,4 @@ def get_num_samples(self) -> int: Returns: Total number of samples available """ - pass \ No newline at end of file + pass diff --git a/autoware_ml/deployment/core/base_evaluator.py b/autoware_ml/deployment/core/base_evaluator.py index c9cdfaec3..231a8c261 100644 --- a/autoware_ml/deployment/core/base_evaluator.py +++ b/autoware_ml/deployment/core/base_evaluator.py @@ -100,11 +100,11 @@ def verify( ) -> Dict[str, Any]: """ Verify exported models using scenario-based verification. - + This method compares outputs from a reference backend against a test backend as specified by the verification scenarios. This is a more flexible approach than the legacy verify() method which compares all available backends. - + Args: ref_backend: Reference backend name ('pytorch' or 'onnx') ref_device: Device for reference backend (e.g., 'cpu', 'cuda:0') @@ -116,7 +116,7 @@ def verify( num_samples: Number of samples to verify tolerance: Maximum allowed difference for verification to pass verbose: Whether to print detailed output - + Returns: Dictionary containing verification results: { @@ -167,4 +167,4 @@ def format_latency_stats(self, stats: Dict[str, float]) -> str: f"Latency: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms " f"(min: {stats['min_ms']:.2f}, max: {stats['max_ms']:.2f}, " f"median: {stats['median_ms']:.2f})" - ) \ No newline at end of file + ) diff --git a/autoware_ml/deployment/core/preprocessing_builder.py b/autoware_ml/deployment/core/preprocessing_builder.py index b970a0d7d..3cd12f080 100644 --- a/autoware_ml/deployment/core/preprocessing_builder.py +++ b/autoware_ml/deployment/core/preprocessing_builder.py @@ -24,7 +24,7 @@ class ComposeBuilder: """ Unified builder for creating Compose objects with different MM frameworks. - + Uses MMEngine-based Compose with init_default_scope for all frameworks. """ @@ -71,16 +71,13 @@ def build( logger.info(f"Building pipeline with mmengine.dataset.Compose (default_scope='{scope}')") return Compose(pipeline_cfg) except Exception as e: - raise ImportError( - f"Failed to build Compose pipeline for scope '{scope}'. " - f"Error: {e}" - ) from e + raise ImportError(f"Failed to build Compose pipeline for scope '{scope}'. " f"Error: {e}") from e class PreprocessingPipelineRegistry: """ Registry for preprocessing pipeline builders by task type. - + Provides a clean way to register and retrieve pipeline builders. """ @@ -119,10 +116,7 @@ def build(self, task_type: str, pipeline_cfg: List) -> Any: ValueError: If task_type is not registered """ if task_type not in self._builders: - raise ValueError( - f"Unknown task_type '{task_type}'. " - f"Available types: {list(self._builders.keys())}" - ) + raise ValueError(f"Unknown task_type '{task_type}'. " f"Available types: {list(self._builders.keys())}") return self._builders[task_type](pipeline_cfg) def _build_detection2d(self, pipeline_cfg: List) -> Any: @@ -144,7 +138,7 @@ def _build_detection3d(self, pipeline_cfg: List) -> Any: def _build_classification(self, pipeline_cfg: List) -> Any: """ Build classification preprocessing pipeline using mmpretrain. - + Raises: ImportError: If mmpretrain is not installed """ @@ -309,4 +303,4 @@ def register_preprocessing_builder(task_type: str, builder: Callable[[List], Any ... return Compose(pipeline_cfg) >>> register_preprocessing_builder("custom_task", custom_builder) """ - _registry.register(task_type, builder) \ No newline at end of file + _registry.register(task_type, builder) diff --git a/autoware_ml/deployment/exporters/__init__.py b/autoware_ml/deployment/exporters/__init__.py index 254cb2ff8..8813a9396 100644 --- a/autoware_ml/deployment/exporters/__init__.py +++ b/autoware_ml/deployment/exporters/__init__.py @@ -1,18 +1,18 @@ """Model exporters for different backends.""" from .base_exporter import BaseExporter -from .onnx_exporter import ONNXExporter -from .tensorrt_exporter import TensorRTExporter from .centerpoint_exporter import CenterPointONNXExporter from .centerpoint_tensorrt_exporter import CenterPointTensorRTExporter from .model_wrappers import ( BaseModelWrapper, - YOLOXONNXWrapper, IdentityWrapper, - register_model_wrapper, + YOLOXONNXWrapper, get_model_wrapper, list_model_wrappers, + register_model_wrapper, ) +from .onnx_exporter import ONNXExporter +from .tensorrt_exporter import TensorRTExporter __all__ = [ "BaseExporter", @@ -26,4 +26,4 @@ "register_model_wrapper", "get_model_wrapper", "list_model_wrappers", -] \ No newline at end of file +] diff --git a/autoware_ml/deployment/exporters/base_exporter.py b/autoware_ml/deployment/exporters/base_exporter.py index c272e190b..9e7a7e24d 100644 --- a/autoware_ml/deployment/exporters/base_exporter.py +++ b/autoware_ml/deployment/exporters/base_exporter.py @@ -3,9 +3,9 @@ Provides a unified interface for exporting models to different formats. """ -from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, Callable import logging +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, Optional import torch @@ -15,7 +15,7 @@ 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 @@ -34,14 +34,14 @@ def __init__(self, config: Dict[str, Any], logger: logging.Logger = None): self._model_wrapper_fn: Optional[Callable] = None # Extract wrapper configuration if present - wrapper_config = config.get('model_wrapper') + wrapper_config = config.get("model_wrapper") if wrapper_config: self._setup_model_wrapper(wrapper_config) def _setup_model_wrapper(self, wrapper_config): """ Setup model wrapper from configuration. - + Args: wrapper_config: Either a string (wrapper name) or dict with 'type' and kwargs """ @@ -53,12 +53,12 @@ def _setup_model_wrapper(self, wrapper_config): self._model_wrapper_fn = lambda model: wrapper_class(model) elif isinstance(wrapper_config, dict): # Dict with type and additional arguments - wrapper_type = wrapper_config.get('type') + wrapper_type = wrapper_config.get("type") if not wrapper_type: raise ValueError("Model wrapper config must have 'type' field") wrapper_class = get_model_wrapper(wrapper_type) - wrapper_kwargs = {k: v for k, v in wrapper_config.items() if k != 'type'} + wrapper_kwargs = {k: v for k, v in wrapper_config.items() if k != "type"} self._model_wrapper_fn = lambda model: wrapper_class(model, **wrapper_kwargs) else: raise TypeError(f"Model wrapper config must be str or dict, got {type(wrapper_config)}") @@ -66,10 +66,10 @@ def _setup_model_wrapper(self, wrapper_config): 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) """ @@ -79,13 +79,7 @@ def prepare_model(self, model: torch.nn.Module) -> torch.nn.Module: return model @abstractmethod - def export( - self, - model: torch.nn.Module, - sample_input: torch.Tensor, - output_path: str, - **kwargs - ) -> bool: + def export(self, model: torch.nn.Module, sample_input: torch.Tensor, output_path: str, **kwargs) -> bool: """ Export model to target format. Args: diff --git a/autoware_ml/deployment/exporters/model_wrappers.py b/autoware_ml/deployment/exporters/model_wrappers.py index 9ef630bed..9d3d41d46 100644 --- a/autoware_ml/deployment/exporters/model_wrappers.py +++ b/autoware_ml/deployment/exporters/model_wrappers.py @@ -14,7 +14,7 @@ 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. """ @@ -22,7 +22,7 @@ class BaseModelWrapper(nn.Module, ABC): def __init__(self, model: nn.Module, **kwargs): """ Initialize wrapper. - + Args: model: PyTorch model to wrap **kwargs: Wrapper-specific arguments @@ -35,7 +35,7 @@ def __init__(self, model: nn.Module, **kwargs): def forward(self, *args, **kwargs): """ Forward pass for ONNX export. - + Must be implemented by subclasses to define ONNX-specific output format. """ pass @@ -44,12 +44,14 @@ def get_config(self) -> Dict[str, Any]: """Get wrapper configuration.""" return self._wrapper_config + # TODO(vividf): class YOLOXONNXWrapper + class IdentityWrapper(BaseModelWrapper): """ Identity wrapper that doesn't modify the model. - + Useful for models that don't need special ONNX export handling. """ @@ -64,14 +66,14 @@ def forward(self, *args, **kwargs): # Model wrapper registry _MODEL_WRAPPERS = { # 'yolox': YOLOXONNXWrapper, - 'identity': IdentityWrapper, + "identity": IdentityWrapper, } def register_model_wrapper(name: str, wrapper_class: type): """ Register a custom model wrapper. - + Args: name: Wrapper name wrapper_class: Wrapper class (must inherit from BaseModelWrapper) @@ -84,25 +86,21 @@ def register_model_wrapper(name: str, wrapper_class: type): def get_model_wrapper(name: str): """ Get model wrapper class by name. - + Args: name: Wrapper name - + Returns: Wrapper class - + Raises: KeyError: If wrapper name not found """ if name not in _MODEL_WRAPPERS: - raise KeyError( - f"Model wrapper '{name}' not found. " - f"Available wrappers: {list(_MODEL_WRAPPERS.keys())}" - ) + raise KeyError(f"Model wrapper '{name}' not found. " f"Available wrappers: {list(_MODEL_WRAPPERS.keys())}") return _MODEL_WRAPPERS[name] def list_model_wrappers(): """List all registered model wrappers.""" return list(_MODEL_WRAPPERS.keys()) - diff --git a/autoware_ml/deployment/exporters/onnx_exporter.py b/autoware_ml/deployment/exporters/onnx_exporter.py index 0b99f02af..0ec4023c9 100644 --- a/autoware_ml/deployment/exporters/onnx_exporter.py +++ b/autoware_ml/deployment/exporters/onnx_exporter.py @@ -62,7 +62,7 @@ def export( self.logger.info(f" Opset version: {export_config.get('opset_version', 16)}") # Ensure output directory exists - os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else '.', exist_ok=True) + os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True) try: with torch.no_grad(): @@ -91,6 +91,7 @@ def export( except Exception as e: self.logger.error(f"ONNX export failed: {e}") import traceback + self.logger.error(traceback.format_exc()) return False @@ -103,16 +104,16 @@ def export_multi( ) -> bool: """ Export multiple models to separate ONNX files. - + Useful for complex models that need to be split into multiple files (e.g., CenterPoint: voxel encoder + backbone/neck/head). - + Args: models: Dict of {filename: model} sample_inputs: Dict of {filename: input_tensor} output_dir: Directory to save ONNX files configs: Optional dict of {filename: config_override} - + Returns: True if all exports succeeded """ @@ -128,8 +129,8 @@ def export_multi( continue output_path = os.path.join(output_dir, name) - if not output_path.endswith('.onnx'): - output_path += '.onnx' + if not output_path.endswith(".onnx"): + output_path += ".onnx" config_override = configs.get(name) success = self.export( @@ -171,4 +172,4 @@ def _simplify_model(self, onnx_path: str) -> None: else: self.logger.warning("ONNX model simplification failed") except Exception as e: - self.logger.warning(f"ONNX simplification error: {e}") \ No newline at end of file + self.logger.warning(f"ONNX simplification error: {e}") diff --git a/autoware_ml/deployment/exporters/tensorrt_exporter.py b/autoware_ml/deployment/exporters/tensorrt_exporter.py index 3a620bc19..feee3fc58 100644 --- a/autoware_ml/deployment/exporters/tensorrt_exporter.py +++ b/autoware_ml/deployment/exporters/tensorrt_exporter.py @@ -1,19 +1,14 @@ """TensorRT model exporter.""" - import logging from typing import Any, Dict - import tensorrt as trt import torch - from .base_exporter import BaseExporter - - class TensorRTExporter(BaseExporter): """ TensorRT model exporter. @@ -21,7 +16,6 @@ class TensorRTExporter(BaseExporter): Converts ONNX models to TensorRT engine format with precision policy support. """ - def __init__(self, config: Dict[str, Any], logger: logging.Logger = None): """ Initialize TensorRT exporter. @@ -33,7 +27,6 @@ def __init__(self, config: Dict[str, Any], logger: logging.Logger = None): super().__init__(config) self.logger = logger or logging.getLogger(__name__) - def export( self, model: torch.nn.Module, # Not used for TensorRT, kept for interface compatibility @@ -57,42 +50,33 @@ def export( self.logger.error("onnx_path is required for TensorRT export") return False - precision_policy = self.config.get("precision_policy", "auto") policy_flags = self.config.get("policy_flags", {}) - 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}") - # Initialize TensorRT trt_logger = trt.Logger(trt.Logger.WARNING) trt.init_libnvinfer_plugins(trt_logger, "") - builder = trt.Builder(trt_logger) builder_config = builder.create_builder_config() - max_workspace_size = self.config.get("max_workspace_size", 1 << 30) 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) if policy_flags.get("STRONGLY_TYPED"): 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": @@ -101,11 +85,9 @@ def export( builder_config.set_flag(getattr(trt.BuilderFlag, flag_name)) self.logger.info(f"BuilderFlag.{flag_name} enabled") - # Parse ONNX model first to get network structure parser = trt.OnnxParser(network, trt_logger) - try: with open(onnx_path, "rb") as f: if not parser.parse(f.read()): @@ -113,40 +95,32 @@ def export( return False self.logger.info("Successfully parsed ONNX file") - # Setup optimization profile after parsing ONNX to get actual input names profile = builder.create_optimization_profile() self._configure_input_shapes(profile, sample_input, network) builder_config.add_optimization_profile(profile) - # Build engine 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") return False - # Save engine with open(output_path, "wb") as f: f.write(serialized_engine) - 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") - return True - except Exception as e: self.logger.error(f"TensorRT export failed: {e}") return False - def _configure_input_shapes( self, profile: trt.IOptimizationProfile, @@ -163,7 +137,6 @@ def _configure_input_shapes( """ model_inputs = self.config.get("model_inputs", []) - if model_inputs: input_shapes = model_inputs[0].get("input_shapes", {}) for input_name, shapes in input_shapes.items(): @@ -171,14 +144,12 @@ def _configure_input_shapes( opt_shape = shapes.get("opt_shape", list(sample_input.shape)) max_shape = shapes.get("max_shape", list(sample_input.shape)) - self.logger.info(f"Setting input shapes - min: {min_shape}, " f"opt: {opt_shape}, max: {max_shape}") profile.set_shape(input_name, min_shape, opt_shape, max_shape) else: # Handle different input types based on shape input_shape = list(sample_input.shape) - # Get actual input name from network if available input_name = "input" # Default fallback if network is not None and network.num_inputs > 0: @@ -186,24 +157,28 @@ def _configure_input_shapes( input_name = network.get_input(0).name self.logger.info(f"Using input name from ONNX model: {input_name}") - # Determine input type based on shape if len(input_shape) == 3 and input_shape[1] == 32: # voxel encoder: (num_voxels, 32, 11) # CenterPoint voxel encoder input: input_features - min_shape = [1000, 32, 11] # Minimum voxels - opt_shape = [10000, 32, 11] # Optimal voxels - max_shape = [50000, 32, 11] # Maximum voxels + min_shape = [1000, 32, 11] # Minimum voxels + opt_shape = [10000, 32, 11] # Optimal voxels + max_shape = [50000, 32, 11] # Maximum voxels if network is None: input_name = "input_features" - elif len(input_shape) == 4 and input_shape[1] == 32: # CenterPoint backbone input: (batch, 32, height, width) + elif ( + len(input_shape) == 4 and input_shape[1] == 32 + ): # CenterPoint backbone input: (batch, 32, height, width) # Backbone input: spatial_features - use dynamic dimensions for H, W # NOTE: Actual evaluation data can produce up to 760x760, so use 800x800 for max_shape min_shape = [1, 32, 100, 100] - opt_shape = [1, 32, 200, 200] + opt_shape = [1, 32, 200, 200] max_shape = [1, 32, 800, 800] # Increased from 400x400 to support actual data if network is None: input_name = "spatial_features" - elif len(input_shape) == 4 and input_shape[1] in [3, 5]: # Standard image input: (batch, channels, height, width) + elif len(input_shape) == 4 and input_shape[1] in [ + 3, + 5, + ]: # Standard image input: (batch, channels, height, width) # For YOLOX, CalibrationStatusClassification, etc. # Use sample shape as optimal, allow some variation for batch dimension batch_size = input_shape[0] @@ -211,7 +186,6 @@ def _configure_input_shapes( height = input_shape[2] width = input_shape[3] - # Allow dynamic batch size if batch_size > 1, otherwise use fixed if batch_size > 1: min_shape = [1, channels, height, width] @@ -223,13 +197,11 @@ def _configure_input_shapes( # Default fallback: use sample shape as-is min_shape = opt_shape = max_shape = input_shape - 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)}") \ No newline at end of file + self.logger.error(f"Parser error: {parser.get_error(error)}") diff --git a/autoware_ml/deployment/pipelines/__init__.py b/autoware_ml/deployment/pipelines/__init__.py index 22327ded4..42e90fa7b 100644 --- a/autoware_ml/deployment/pipelines/__init__.py +++ b/autoware_ml/deployment/pipelines/__init__.py @@ -44,4 +44,4 @@ # 'CalibrationPyTorchPipeline', # 'CalibrationONNXPipeline', # 'CalibrationTensorRTPipeline', -# ] \ No newline at end of file +# ] diff --git a/autoware_ml/deployment/pipelines/base/__init__.py b/autoware_ml/deployment/pipelines/base/__init__.py index 10e7de151..256fd1a72 100644 --- a/autoware_ml/deployment/pipelines/base/__init__.py +++ b/autoware_ml/deployment/pipelines/base/__init__.py @@ -16,4 +16,3 @@ "Detection2DPipeline", "Detection3DPipeline", ] - diff --git a/autoware_ml/deployment/pipelines/base/base_pipeline.py b/autoware_ml/deployment/pipelines/base/base_pipeline.py index 2443c3ef6..00bcebf30 100644 --- a/autoware_ml/deployment/pipelines/base/base_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/base_pipeline.py @@ -12,24 +12,23 @@ 4. Flexible Output: Can return raw or processed outputs """ -from abc import ABC, abstractmethod -from typing import Any, Dict, Tuple, Union, List, Optional import logging import time +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple, Union import torch - logger = logging.getLogger(__name__) class BaseDeploymentPipeline(ABC): """ Abstract base class for all deployment pipelines. - + This class defines the unified interface for model deployment across different backends and task types. - + Attributes: model: Model object (PyTorch model, ONNX session, TensorRT engine, etc.) device: Device for inference @@ -37,16 +36,10 @@ class BaseDeploymentPipeline(ABC): backend_type: Type of backend ("pytorch", "onnx", "tensorrt", etc.) """ - def __init__( - self, - model: Any, - device: str = "cpu", - task_type: str = "unknown", - backend_type: str = "unknown" - ): + def __init__(self, model: Any, device: str = "cpu", task_type: str = "unknown", backend_type: str = "unknown"): """ Initialize deployment pipeline. - + Args: model: Model object (backend-specific) device: Device for inference ('cpu', 'cuda', 'cuda:0', etc.) @@ -66,14 +59,14 @@ def __init__( def preprocess(self, input_data: Any, **kwargs) -> Any: """ Preprocess input data. - + This method should handle all preprocessing steps required before feeding data to the model (normalization, resizing, etc.). - + Args: input_data: Raw input (image, point cloud, etc.) **kwargs: Additional preprocessing parameters - + Returns: Preprocessed data ready for model """ @@ -83,34 +76,30 @@ def preprocess(self, input_data: Any, **kwargs) -> Any: def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple]: """ Run model inference (backend-specific). - + This is the only method that differs across backends. Each backend (PyTorch, ONNX, TensorRT) implements its own version. - + Args: preprocessed_input: Preprocessed input data - + Returns: Model output (raw tensors or backend-specific format) """ pass @abstractmethod - def postprocess( - self, - model_output: Any, - metadata: Dict = None - ) -> Any: + def postprocess(self, model_output: Any, metadata: Dict = None) -> Any: """ Postprocess model output to final predictions. - + This method should handle all postprocessing steps like NMS, coordinate transformation, score filtering, etc. - + Args: model_output: Raw model output from run_model() metadata: Additional metadata (image size, point cloud range, etc.) - + Returns: Final predictions in standard format """ @@ -119,30 +108,26 @@ def postprocess( # ========== Concrete Methods (Shared Logic) ========== def infer( - self, - input_data: Any, - metadata: Optional[Dict] = None, - return_raw_outputs: bool = False, - **kwargs + self, input_data: Any, metadata: Optional[Dict] = None, return_raw_outputs: bool = False, **kwargs ) -> Tuple[Any, float, Dict[str, float]]: """ Complete inference pipeline. - + This method orchestrates the entire inference flow: 1. Preprocessing 2. Model inference 3. Postprocessing (optional) - + This unified interface allows: - Evaluation: infer(..., return_raw_outputs=False) → get final predictions - Verification: infer(..., return_raw_outputs=True) → get raw outputs for comparison - + Args: input_data: Raw input data metadata: Additional metadata for preprocessing/postprocessing return_raw_outputs: If True, skip postprocessing (for verification) **kwargs: Additional arguments passed to preprocess() - + Returns: Tuple of (outputs, latency_ms, latency_breakdown) - outputs: If return_raw_outputs=True: raw_model_output @@ -169,7 +154,7 @@ def infer( model_input, preprocess_metadata = preprocessed preprocess_time = time.time() - latency_breakdown['preprocessing_ms'] = (preprocess_time - start_time) * 1000 + latency_breakdown["preprocessing_ms"] = (preprocess_time - start_time) * 1000 # Merge caller metadata (if any) with preprocess metadata (preprocess takes precedence by default) merged_metadata = {} @@ -180,10 +165,10 @@ def infer( model_start = time.time() model_output = self.run_model(model_input) model_time = time.time() - latency_breakdown['model_ms'] = (model_time - model_start) * 1000 + latency_breakdown["model_ms"] = (model_time - model_start) * 1000 # Merge stage-wise latencies if available (for multi-stage pipelines like CenterPoint) - if hasattr(self, '_stage_latencies') and isinstance(self._stage_latencies, dict): + if hasattr(self, "_stage_latencies") and isinstance(self._stage_latencies, dict): latency_breakdown.update(self._stage_latencies) # Clear for next inference self._stage_latencies = {} @@ -197,7 +182,7 @@ def infer( postprocess_start = time.time() predictions = self.postprocess(model_output, merged_metadata) postprocess_time = time.time() - latency_breakdown['postprocessing_ms'] = (postprocess_time - postprocess_start) * 1000 + latency_breakdown["postprocessing_ms"] = (postprocess_time - postprocess_start) * 1000 total_latency = (time.time() - start_time) * 1000 return predictions, total_latency, latency_breakdown @@ -205,15 +190,16 @@ def infer( except Exception as e: logger.error(f"Inference failed: {e}") import traceback + traceback.print_exc() raise def warmup(self, input_data: Any, num_iterations: int = 10): """ Warmup the model with dummy inputs. - + Useful for stabilizing latency measurements, especially for GPU models. - + Args: input_data: Sample input for warmup num_iterations: Number of warmup iterations @@ -228,18 +214,14 @@ def warmup(self, input_data: Any, num_iterations: int = 10): logger.info("Warmup completed") - def benchmark( - self, - input_data: Any, - num_iterations: int = 100 - ) -> Dict[str, float]: + def benchmark(self, input_data: Any, num_iterations: int = 100) -> Dict[str, float]: """ Benchmark inference performance. - + Args: input_data: Sample input for benchmarking num_iterations: Number of benchmark iterations - + Returns: Dictionary with latency statistics (mean, std, min, max) """ @@ -255,12 +237,13 @@ def benchmark( latencies.append(latency) import numpy as np + results = { - 'mean_ms': np.mean(latencies), - 'std_ms': np.std(latencies), - 'min_ms': np.min(latencies), - 'max_ms': np.max(latencies), - 'median_ms': np.median(latencies) + "mean_ms": np.mean(latencies), + "std_ms": np.std(latencies), + "min_ms": np.min(latencies), + "max_ms": np.max(latencies), + "median_ms": np.median(latencies), } logger.info(f"Benchmark results: {results['mean_ms']:.2f} ± {results['std_ms']:.2f} ms") @@ -268,10 +251,12 @@ def benchmark( return results def __repr__(self): - return (f"{self.__class__.__name__}(" - f"device={self.device}, " - f"task={self.task_type}, " - f"backend={self.backend_type})") + return ( + f"{self.__class__.__name__}(" + f"device={self.device}, " + f"task={self.task_type}, " + f"backend={self.backend_type})" + ) def __enter__(self): """Context manager entry.""" @@ -280,4 +265,4 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" # Cleanup resources if needed - pass \ No newline at end of file + pass diff --git a/autoware_ml/deployment/pipelines/base/classification_pipeline.py b/autoware_ml/deployment/pipelines/base/classification_pipeline.py index 941f20cab..78e10d900 100644 --- a/autoware_ml/deployment/pipelines/base/classification_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/classification_pipeline.py @@ -4,28 +4,27 @@ implementing common preprocessing and postprocessing for image/point cloud classification. """ -from abc import abstractmethod -from typing import List, Dict, Tuple, Any import logging +from abc import abstractmethod +from typing import Any, Dict, List, Tuple import numpy as np import torch from .base_pipeline import BaseDeploymentPipeline - logger = logging.getLogger(__name__) class ClassificationPipeline(BaseDeploymentPipeline): """ Base class for classification pipelines. - + Provides common functionality for classification tasks including: - Image/data preprocessing (via data loader) - Postprocessing (softmax, top-k selection) - Standard classification output format - + Expected output format: Dict containing: { @@ -38,17 +37,17 @@ class ClassificationPipeline(BaseDeploymentPipeline): """ def __init__( - self, + self, model: Any, device: str = "cpu", num_classes: int = 1000, class_names: List[str] = None, input_size: Tuple[int, int] = (224, 224), - backend_type: str = "unknown" + backend_type: str = "unknown", ): """ Initialize classification pipeline. - + Args: model: Model object device: Device for inference @@ -64,21 +63,17 @@ def __init__( self.input_size = input_size @abstractmethod - def preprocess( - self, - input_data: Any, - **kwargs - ) -> torch.Tensor: + def preprocess(self, input_data: Any, **kwargs) -> torch.Tensor: """ Preprocess input data for classification. - + This method should be implemented by specific classification pipelines. Preprocessing should be done by data loader before calling this method. - + Args: input_data: Preprocessed tensor from data loader or raw input **kwargs: Additional preprocessing parameters - + Returns: Preprocessed tensor [1, C, H, W] """ @@ -88,34 +83,29 @@ def preprocess( def run_model(self, preprocessed_input: torch.Tensor) -> torch.Tensor: """ Run classification model (backend-specific). - + Args: preprocessed_input: Preprocessed tensor [1, C, H, W] - + Returns: Model output (logits) [1, num_classes] """ pass - def postprocess( - self, - model_output: torch.Tensor, - metadata: Dict = None, - top_k: int = 5 - ) -> Dict: + def postprocess(self, model_output: torch.Tensor, metadata: Dict = None, top_k: int = 5) -> Dict: """ Standard classification postprocessing. - + Steps: 1. Apply softmax to get probabilities 2. Get predicted class 3. Optionally get top-K predictions - + Args: model_output: Model output (logits) [1, num_classes] metadata: Additional metadata (unused for classification) top_k: Number of top predictions to return - + Returns: Dictionary with classification results """ @@ -142,19 +132,20 @@ def postprocess( top_k_indices = np.argsort(probabilities)[::-1][:top_k] top_k_predictions = [] for idx in top_k_indices: - top_k_predictions.append({ - 'class_id': int(idx), - 'class_name': self.class_names[idx] if idx < len(self.class_names) else f"class_{idx}", - 'confidence': float(probabilities[idx]) - }) + top_k_predictions.append( + { + "class_id": int(idx), + "class_name": self.class_names[idx] if idx < len(self.class_names) else f"class_{idx}", + "confidence": float(probabilities[idx]), + } + ) result = { - 'class_id': class_id, - 'class_name': class_name, - 'confidence': confidence, - 'probabilities': probabilities, - 'top_k': top_k_predictions + "class_id": class_id, + "class_name": class_name, + "confidence": confidence, + "probabilities": probabilities, + "top_k": top_k_predictions, } - - return result \ No newline at end of file + return result diff --git a/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py index 934837716..4751c52dd 100644 --- a/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py @@ -4,28 +4,27 @@ implementing common preprocessing and postprocessing for models like YOLOX, YOLO, etc. """ -from abc import abstractmethod -from typing import List, Dict, Tuple, Any import logging +from abc import abstractmethod +from typing import Any, Dict, List, Tuple import numpy as np import torch from .base_pipeline import BaseDeploymentPipeline - logger = logging.getLogger(__name__) class Detection2DPipeline(BaseDeploymentPipeline): """ Base class for 2D object detection pipelines. - + Provides common functionality for 2D detection tasks including: - Image preprocessing (resize, normalize, padding) - Postprocessing (NMS, coordinate transformation) - Standard detection output format - + Expected output format: List[Dict] where each dict contains: { @@ -37,17 +36,17 @@ class Detection2DPipeline(BaseDeploymentPipeline): """ def __init__( - self, + self, model: Any, device: str = "cpu", num_classes: int = 80, class_names: List[str] = None, input_size: Tuple[int, int] = (640, 640), - backend_type: str = "unknown" + backend_type: str = "unknown", ): """ Initialize 2D detection pipeline. - + Args: model: Model object device: Device for inference @@ -63,21 +62,17 @@ def __init__( self.input_size = input_size @abstractmethod - def preprocess( - self, - input_data: Any, - **kwargs - ) -> Tuple[torch.Tensor, Dict]: + def preprocess(self, input_data: Any, **kwargs) -> Tuple[torch.Tensor, Dict]: """ Preprocess input data for 2D detection. - + This method should be implemented by specific detection pipelines. For YOLOX, preprocessing is done by MMDetection pipeline before calling this method. - + Args: input_data: Preprocessed tensor from MMDetection pipeline or raw input **kwargs: Additional preprocessing parameters - + Returns: Tuple of (preprocessed_tensor, preprocessing_metadata) - preprocessed_tensor: [1, C, H, W] @@ -89,57 +84,45 @@ def preprocess( def run_model(self, preprocessed_input: torch.Tensor) -> Any: """ Run detection model (backend-specific). - + Args: preprocessed_input: Preprocessed tensor [1, C, H, W] - + Returns: Model output (backend-specific format) """ pass - def postprocess( - self, - model_output: Any, - metadata: Dict = None - ) -> List[Dict]: + def postprocess(self, model_output: Any, metadata: Dict = None) -> List[Dict]: """ Standard 2D detection postprocessing. - + Steps: 1. Parse model outputs (boxes, scores, classes) 2. Apply NMS 3. Transform coordinates back to original image space 4. Filter by confidence threshold - + Args: model_output: Raw model output metadata: Preprocessing metadata - + Returns: List of detections in standard format """ # This should be overridden by specific detectors (YOLOX, YOLO, etc.) # as output formats differ - raise NotImplementedError( - "postprocess() must be implemented by specific detector pipeline" - ) + raise NotImplementedError("postprocess() must be implemented by specific detector pipeline") - - def _nms( - self, - boxes: np.ndarray, - scores: np.ndarray, - iou_threshold: float = 0.45 - ) -> np.ndarray: + def _nms(self, boxes: np.ndarray, scores: np.ndarray, iou_threshold: float = 0.45) -> np.ndarray: """ Non-Maximum Suppression. - + Args: boxes: Bounding boxes [N, 4] scores: Confidence scores [N] iou_threshold: IoU threshold for NMS - + Returns: Indices of boxes to keep """ @@ -170,4 +153,4 @@ def _nms( inds = np.where(iou <= iou_threshold)[0] order = order[inds + 1] - return np.array(keep) \ No newline at end of file + return np.array(keep) diff --git a/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py index 044842fcb..d0791476a 100644 --- a/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py @@ -5,35 +5,27 @@ implementing common functionality for point cloud-based detection models like CenterPoint. """ - -from abc import abstractmethod -from typing import List, Dict, Tuple, Any import logging +from abc import abstractmethod +from typing import Any, Dict, List, Tuple - -import torch import numpy as np - +import torch from .base_pipeline import BaseDeploymentPipeline - - - logger = logging.getLogger(__name__) - - class Detection3DPipeline(BaseDeploymentPipeline): """ Base class for 3D object detection pipelines. - + Provides common functionality for 3D detection tasks including: - Point cloud preprocessing (voxelization, normalization) - Postprocessing (NMS, coordinate transformation) - Standard 3D detection output format - + Expected output format: List[Dict] where each dict contains: { @@ -44,20 +36,19 @@ class Detection3DPipeline(BaseDeploymentPipeline): } """ - def __init__( - self, + self, model: Any, device: str = "cpu", num_classes: int = 10, class_names: List[str] = None, point_cloud_range: List[float] = None, voxel_size: List[float] = None, - backend_type: str = "unknown" + backend_type: str = "unknown", ): """ Initialize 3D detection pipeline. - + Args: model: Model object device: Device for inference @@ -69,29 +60,23 @@ def __init__( """ super().__init__(model, device, task_type="detection_3d", backend_type=backend_type) - self.num_classes = num_classes self.class_names = class_names or [f"class_{i}" for i in range(num_classes)] self.point_cloud_range = point_cloud_range self.voxel_size = voxel_size - - def preprocess( - self, - points: torch.Tensor, - **kwargs - ) -> Dict[str, torch.Tensor]: + def preprocess(self, points: torch.Tensor, **kwargs) -> Dict[str, torch.Tensor]: """ Standard 3D detection preprocessing. - + Note: For 3D detection, preprocessing is often model-specific (voxelization, pillar generation, etc.), so this method should be overridden by specific implementations. - + Args: points: Input point cloud [N, point_features] **kwargs: Additional preprocessing parameters - + Returns: Dictionary containing preprocessed data """ @@ -100,22 +85,21 @@ def preprocess( "3D detection preprocessing varies significantly between models." ) - def run_model(self, preprocessed_input: Any) -> Any: """ Run 3D detection model (backend-specific). - + **Note**: This method is intentionally not abstract for 3D detection pipelines. - + Most 3D detection models use a **multi-stage inference pipeline** rather than a single model call: - + ``` Points → Voxel Encoder → Middle Encoder → Backbone/Head → Postprocess ``` - + For 3D detection pipelines - + *Implement `run_model()` (Recommended)* - Implement all stages in `run_model()`: - `run_voxel_encoder()` - backend-specific voxel encoding @@ -124,17 +108,17 @@ def run_model(self, preprocessed_input: Any) -> Any: - Return final head outputs - Use base class `infer()` for unified pipeline orchestration - + Args: preprocessed_input: Preprocessed data (usually Dict from preprocess()) - + Returns: Model output (backend-specific format, usually List[torch.Tensor] for head outputs) - + Raises: NotImplementedError: Default implementation raises error. Subclasses should implement `run_model()` with all stages. - + Example: See `CenterPointDeploymentPipeline.run_model()` for a complete multi-stage implementation example. @@ -147,27 +131,22 @@ def run_model(self, preprocessed_input: Any) -> Any: "See CenterPointDeploymentPipeline.run_model() for an example implementation." ) - - def postprocess( - self, - model_output: Any, - metadata: Dict = None - ) -> List[Dict]: + def postprocess(self, model_output: Any, metadata: Dict = None) -> List[Dict]: """ Standard 3D detection postprocessing. - + Note: For 3D detection, postprocessing is often model-specific (CenterPoint uses predict_by_feat, PointPillars uses different logic), so this method should be overridden by specific implementations. - + Args: model_output: Raw model output metadata: Preprocessing metadata - + Returns: List of 3D detections in standard format """ raise NotImplementedError( "postprocess() must be implemented by specific 3D detector pipeline.\n" "3D detection postprocessing varies significantly between models." - ) \ No newline at end of file + ) diff --git a/autoware_ml/deployment/runners/deployment_runner.py b/autoware_ml/deployment/runners/deployment_runner.py index bdd5b0ebe..01716b0f5 100644 --- a/autoware_ml/deployment/runners/deployment_runner.py +++ b/autoware_ml/deployment/runners/deployment_runner.py @@ -1,18 +1,17 @@ - """ Unified deployment runner for common deployment workflows. This module provides a unified runner that handles the common deployment workflow across different projects, while allowing project-specific customization. """ -import os import logging -from typing import Any, Dict, Optional, Callable, List, Tuple +import os +from typing import Any, Callable, Dict, List, Optional, Tuple import torch from mmengine.config import Config -from autoware_ml.deployment.core import BaseDeploymentConfig, BaseDataLoader, BaseEvaluator +from autoware_ml.deployment.core import BaseDataLoader, BaseDeploymentConfig, BaseEvaluator from autoware_ml.deployment.exporters.onnx_exporter import ONNXExporter from autoware_ml.deployment.exporters.tensorrt_exporter import TensorRTExporter @@ -20,14 +19,14 @@ class DeploymentRunner: """ Unified deployment runner for common deployment workflows. - + This runner handles the standard deployment workflow: 1. Load PyTorch model (if needed) 2. Export to ONNX (if requested) 3. Export to TensorRT (if requested) 4. Verify outputs (if enabled) 5. Evaluate models (if enabled) - + Projects can customize behavior by: - Overriding methods (load_pytorch_model, export_onnx, export_tensorrt) - Providing custom callbacks @@ -49,7 +48,7 @@ def __init__( ): """ Initialize unified deployment runner. - + Args: data_loader: Data loader for samples evaluator: Evaluator for model evaluation @@ -73,20 +72,16 @@ def __init__( self._onnx_exporter = onnx_exporter self._tensorrt_exporter = tensorrt_exporter - def load_pytorch_model( - self, - checkpoint_path: str, - **kwargs - ) -> Any: + def load_pytorch_model(self, checkpoint_path: str, **kwargs) -> Any: """ Load PyTorch model from checkpoint. - + Uses custom function if provided, otherwise uses default implementation. - + Args: checkpoint_path: Path to checkpoint file **kwargs: Additional project-specific arguments - + Returns: Loaded PyTorch model """ @@ -97,20 +92,16 @@ def load_pytorch_model( self.logger.warning("Using default load_pytorch_model - projects should override this") raise NotImplementedError("load_pytorch_model must be implemented or provided via load_model_fn") - def export_onnx( - self, - pytorch_model: Any, - **kwargs - ) -> Optional[str]: + def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: """ Export model to ONNX format. - + Uses custom function if provided, otherwise uses standard ONNXExporter. - + Args: pytorch_model: PyTorch model to export **kwargs: Additional project-specific arguments - + Returns: Path to exported ONNX file/directory, or None if export failed """ @@ -138,8 +129,9 @@ def export_onnx( # Check if it's a CenterPoint exporter (needs special handling) # CenterPoint exporter has data_loader parameter in export method import inspect + sig = inspect.signature(exporter.export) - if 'data_loader' in sig.parameters: + if "data_loader" in sig.parameters: # CenterPoint exporter signature # Save to work_dir/onnx/ directory output_dir = os.path.join(self.config.export_config.work_dir, "onnx") @@ -149,10 +141,7 @@ def export_onnx( return None success = exporter.export( - model=pytorch_model, - data_loader=self.data_loader, - output_dir=output_dir, - sample_idx=0 + model=pytorch_model, data_loader=self.data_loader, output_dir=output_dir, sample_idx=0 ) if success: @@ -210,20 +199,16 @@ def export_onnx( self.logger.error(f"❌ ONNX export failed") return None - def export_tensorrt( - self, - onnx_path: str, - **kwargs - ) -> Optional[str]: + def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: """ Export ONNX model to TensorRT engine. - + Uses custom function if provided, otherwise uses standard TensorRTExporter. - + Args: onnx_path: Path to ONNX model file/directory **kwargs: Additional project-specific arguments - + Returns: Path to exported TensorRT engine file/directory, or None if export failed """ @@ -250,8 +235,9 @@ def export_tensorrt( # Check if it's a CenterPoint exporter (needs special handling) # CenterPoint exporter has onnx_dir parameter in export method import inspect + sig = inspect.signature(exporter.export) - if 'onnx_dir' in sig.parameters: + if "onnx_dir" in sig.parameters: # CenterPoint exporter signature if not os.path.isdir(onnx_path): self.logger.error("CenterPoint requires ONNX directory, not a single file") @@ -262,9 +248,7 @@ def export_tensorrt( os.makedirs(output_dir, exist_ok=True) success = exporter.export( - onnx_dir=onnx_path, - output_dir=output_dir, - device=self.config.export_config.device + onnx_dir=onnx_path, output_dir=output_dir, device=self.config.export_config.device ) if success: @@ -306,9 +290,9 @@ def export_tensorrt( sample_input = sample_input.float() # Merge backend_config.model_inputs into trt_settings for TensorRTExporter - if hasattr(self.config, 'backend_config') and hasattr(self.config.backend_config, 'model_inputs'): + if hasattr(self.config, "backend_config") and hasattr(self.config.backend_config, "model_inputs"): trt_settings = trt_settings.copy() - trt_settings['model_inputs'] = self.config.backend_config.model_inputs + trt_settings["model_inputs"] = self.config.backend_config.model_inputs # Use provided exporter or create default if self._tensorrt_exporter is not None: @@ -320,7 +304,7 @@ def export_tensorrt( model=None, # Not used for TensorRT sample_input=sample_input, output_path=output_path, - onnx_path=onnx_path + onnx_path=onnx_path, ) if success: @@ -363,7 +347,7 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: if os.path.exists(onnx_dir) and os.path.isdir(onnx_dir): # Check for ONNX files in work_dir/onnx/ directory - onnx_files = [f for f in os.listdir(onnx_dir) if f.endswith('.onnx')] + onnx_files = [f for f in os.listdir(onnx_dir) if f.endswith(".onnx")] if onnx_files: if multi_file: # Multi-file ONNX: return directory path @@ -385,7 +369,7 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: else: # Try single file path model_path = os.path.join(onnx_dir, save_file) - is_valid = os.path.exists(model_path) and model_path.endswith('.onnx') + is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") else: if multi_file: # Multi-file ONNX: return directory even if it doesn't exist yet @@ -394,21 +378,21 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: else: # Fallback: try in work_dir directly (for backward compatibility) model_path = os.path.join(work_dir, save_file) - is_valid = os.path.exists(model_path) and model_path.endswith('.onnx') + is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") else: # model_dir is explicitly set in config multi_file = self.config.onnx_config.get("multi_file", False) if os.path.exists(model_path): if os.path.isfile(model_path): # Single file ONNX - is_valid = model_path.endswith('.onnx') and not multi_file + is_valid = model_path.endswith(".onnx") and not multi_file elif os.path.isdir(model_path): # Directory: valid if multi_file is True, or if it contains ONNX files if multi_file: is_valid = True else: # Single file mode: find the ONNX file in directory - onnx_files = [f for f in os.listdir(model_path) if f.endswith('.onnx')] + onnx_files = [f for f in os.listdir(model_path) if f.endswith(".onnx")] if onnx_files: # Use the save_file if it exists, otherwise use the first ONNX file found save_file = self.config.onnx_config.get("save_file", "model.onnx") @@ -433,7 +417,7 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: multi_file = self.config.onnx_config.get("multi_file", False) # Use same config as ONNX if os.path.exists(engine_dir) and os.path.isdir(engine_dir): - engine_files = [f for f in os.listdir(engine_dir) if f.endswith('.engine')] + engine_files = [f for f in os.listdir(engine_dir) if f.endswith(".engine")] if engine_files: if multi_file: # Multi-file TensorRT: return directory path @@ -465,7 +449,7 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: else: # Fallback: try in work_dir directly (for backward compatibility) if os.path.exists(work_dir) and os.path.isdir(work_dir): - engine_files = [f for f in os.listdir(work_dir) if f.endswith('.engine')] + engine_files = [f for f in os.listdir(work_dir) if f.endswith(".engine")] if engine_files: onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") expected_engine = onnx_save_file.replace(".onnx", ".engine") @@ -485,14 +469,16 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: if os.path.exists(model_path): if os.path.isfile(model_path): # Single file TensorRT - is_valid = (model_path.endswith('.engine') or model_path.endswith('.trt')) and not multi_file + is_valid = ( + model_path.endswith(".engine") or model_path.endswith(".trt") + ) and not multi_file elif os.path.isdir(model_path): # Directory: valid if multi_file is True, or if it contains engine files if multi_file: is_valid = True else: # Single file mode: find the engine file in directory - engine_files = [f for f in os.listdir(model_path) if f.endswith('.engine')] + engine_files = [f for f in os.listdir(model_path) if f.endswith(".engine")] if engine_files: # Try to match ONNX filename, otherwise use the first engine file found onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") @@ -515,11 +501,7 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: return models_to_evaluate def run_verification( - self, - pytorch_checkpoint: Optional[str], - onnx_path: Optional[str], - tensorrt_path: Optional[str], - **kwargs + self, pytorch_checkpoint: Optional[str], onnx_path: Optional[str], tensorrt_path: Optional[str], **kwargs ) -> Dict[str, Any]: """ Run verification on exported models using policy-based verification. @@ -585,7 +567,9 @@ def run_verification( test_device = test_device_key self.logger.warning(f"Device alias '{test_device_key}' not found in devices map, using as-is") - self.logger.info(f"\Scenarios {i+1}/{len(scenarios)}: {ref_backend}({ref_device}) vs {test_backend}({test_device})") + self.logger.info( + f"\Scenarios {i+1}/{len(scenarios)}: {ref_backend}({ref_device}) vs {test_backend}({test_device})" + ) # Resolve model paths based on backend ref_path = None @@ -623,10 +607,10 @@ def run_verification( policy_key = f"{ref_backend}_{ref_device}_vs_{test_backend}_{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) + 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 @@ -643,10 +627,10 @@ def run_verification( 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, + all_results["summary"] = { + "passed": total_passed, + "failed": total_failed, + "total": total_passed + total_failed, } return all_results @@ -729,29 +713,25 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: 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'] + 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'] + 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") return all_results - def run( - self, - checkpoint_path: Optional[str] = None, - **kwargs - ) -> Dict[str, Any]: + def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any]: """ Execute the complete deployment workflow. - + Args: checkpoint_path: Path to PyTorch checkpoint (optional) **kwargs: Additional project-specific arguments - + Returns: Dictionary containing deployment results """ @@ -799,7 +779,9 @@ def run( if requires_pytorch_model: if not checkpoint_path: - self.logger.error("Checkpoint required but not provided. Please set export.checkpoint_path in config or pass via CLI.") + self.logger.error( + "Checkpoint required but not provided. Please set export.checkpoint_path in config or pass via CLI." + ) return results self.logger.info("\nLoading PyTorch model...") @@ -834,7 +816,9 @@ def run( if should_export_trt: onnx_source = results["onnx_path"] or external_onnx_path if not onnx_source: - self.logger.error("TensorRT export requires an ONNX path. Please set export.onnx_path in config or enable ONNX export.") + self.logger.error( + "TensorRT export requires an ONNX path. Please set export.onnx_path in config or enable ONNX export." + ) return results else: results["onnx_path"] = onnx_source # Ensure verification/evaluation can use this path @@ -865,7 +849,7 @@ def run( pytorch_checkpoint=checkpoint_path, onnx_path=results["onnx_path"], tensorrt_path=results["tensorrt_path"], - **kwargs + **kwargs, ) results["verification_results"] = verification_results @@ -878,5 +862,3 @@ def run( self.logger.info("=" * 80) return results - - From ad30f5179d6c76d013e175985a088c38162a955d Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 17 Nov 2025 14:09:44 +0900 Subject: [PATCH 07/62] chore: update architecture Signed-off-by: vividf --- autoware_ml/deployment/README.md | 240 ++++++- autoware_ml/deployment/__init__.py | 13 +- autoware_ml/deployment/core/__init__.py | 10 +- autoware_ml/deployment/core/base_config.py | 10 + .../deployment/core/base_data_loader.py | 10 + autoware_ml/deployment/core/base_evaluator.py | 14 +- .../deployment/core/preprocessing_builder.py | 38 +- autoware_ml/deployment/exporters/__init__.py | 34 +- .../exporters/{ => base}/base_exporter.py | 63 +- .../exporters/{ => base}/model_wrappers.py | 61 +- .../exporters/{ => base}/onnx_exporter.py | 12 +- .../exporters/{ => base}/tensorrt_exporter.py | 9 +- .../exporters/centerpoint/__init__.py | 11 + .../centerpoint/model_wrappers.py} | 0 .../centerpoint/onnx_exporter.py} | 0 .../centerpoint/tensorrt_exporter.py} | 0 autoware_ml/deployment/pipelines/__init__.py | 77 +-- .../deployment/pipelines/base/__init__.py | 8 +- .../pipelines/base/base_pipeline.py | 3 + .../pipelines/base/classification_pipeline.py | 3 +- .../pipelines/base/detection_2d_pipeline.py | 3 +- .../pipelines/base/detection_3d_pipeline.py | 2 +- .../calibration/calibration_tensorrt.py | 0 .../pipelines/centerpoint/__init__.py | 44 ++ .../deployment/pipelines/yolox/__init__.py | 0 .../deployment/pipelines/yolox/yolox_onnx.py | 0 .../pipelines/yolox/yolox_pipeline.py | 0 .../pipelines/yolox/yolox_pytorch.py | 0 .../pipelines/yolox/yolox_tensorrt.py | 0 autoware_ml/deployment/runners/__init__.py | 10 +- .../centerpoint_runner.py} | 0 .../deployment/runners/deployment_runner.py | 584 +++++++++--------- 32 files changed, 754 insertions(+), 505 deletions(-) rename autoware_ml/deployment/exporters/{ => base}/base_exporter.py (59%) rename autoware_ml/deployment/exporters/{ => base}/model_wrappers.py (52%) rename autoware_ml/deployment/exporters/{ => base}/onnx_exporter.py (95%) rename autoware_ml/deployment/exporters/{ => base}/tensorrt_exporter.py (96%) create mode 100644 autoware_ml/deployment/exporters/centerpoint/__init__.py rename autoware_ml/deployment/{pipelines/calibration/__init__.py => exporters/centerpoint/model_wrappers.py} (100%) rename autoware_ml/deployment/{pipelines/calibration/calibration_onnx.py => exporters/centerpoint/onnx_exporter.py} (100%) rename autoware_ml/deployment/{pipelines/calibration/calibration_pipeline.py => exporters/centerpoint/tensorrt_exporter.py} (100%) delete mode 100644 autoware_ml/deployment/pipelines/calibration/calibration_tensorrt.py delete mode 100644 autoware_ml/deployment/pipelines/yolox/__init__.py delete mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_onnx.py delete mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_pipeline.py delete mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_pytorch.py delete mode 100644 autoware_ml/deployment/pipelines/yolox/yolox_tensorrt.py rename autoware_ml/deployment/{pipelines/calibration/calibration_pytorch.py => runners/centerpoint_runner.py} (100%) diff --git a/autoware_ml/deployment/README.md b/autoware_ml/deployment/README.md index 390b55d98..5e0753f77 100644 --- a/autoware_ml/deployment/README.md +++ b/autoware_ml/deployment/README.md @@ -27,6 +27,7 @@ The AWML Deployment Framework provides a standardized approach to model deployme 3. **Backend Flexibility**: Support for PyTorch, ONNX, and TensorRT backends 4. **Pipeline Architecture**: Shared preprocessing/postprocessing with backend-specific inference 5. **Configuration-Driven**: All settings controlled via config files +6. **Dependency Injection**: Explicit creation and injection of exporters and wrappers for better clarity and type safety --- @@ -53,6 +54,9 @@ The AWML Deployment Framework provides a standardized approach to model deployme │ Exporters │ │ Evaluators │ │ - ONNX │ │ - Task-specific│ │ - TensorRT │ │ - Metrics │ +│ - Wrappers │ │ │ +│ (Unified │ │ │ +│ structure) │ │ │ └────────────────┘ └─────────────────┘ │ │ └──────────┬──────────┘ @@ -71,22 +75,58 @@ The AWML Deployment Framework provides a standardized approach to model deployme The unified runner that orchestrates the complete deployment workflow: - **Model Loading**: Loads PyTorch models from checkpoints -- **Export**: Exports to ONNX and/or TensorRT +- **Export**: Exports to ONNX and/or TensorRT using project-specific exporters - **Verification**: Scenario-based verification across backends - **Evaluation**: Performance metrics and latency statistics +**Required Parameters:** +- `onnx_exporter`: Project-specific ONNX exporter instance (e.g., `YOLOXONNXExporter`, `CenterPointONNXExporter`) +- `tensorrt_exporter`: Project-specific TensorRT exporter instance (e.g., `YOLOXTensorRTExporter`, `CenterPointTensorRTExporter`) +- `model_wrapper`: Project-specific model wrapper class (e.g., `YOLOXONNXWrapper`, `CenterPointONNXWrapper`) + +All exporters and wrappers are explicitly created and passed to the runner, following the dependency injection pattern. + #### 2. **Base Classes** - **`BaseDeploymentConfig`**: Configuration container for deployment settings - **`BaseEvaluator`**: Abstract interface for task-specific evaluation - **`BaseDataLoader`**: Abstract interface for data loading -- **`BaseDeploymentPipeline`**: Abstract pipeline for inference +- **`BaseDeploymentPipeline`**: Abstract pipeline for inference (in `pipelines/base/`) +- **`Detection2DPipeline`**: Base pipeline for 2D detection tasks +- **`Detection3DPipeline`**: Base pipeline for 3D detection tasks +- **`ClassificationPipeline`**: Base pipeline for classification tasks +- **`build_preprocessing_pipeline`**: Utility to extract preprocessing pipelines from MMDet/MMDet3D configs #### 3. **Exporters** -- **`ONNXExporter`**: Standard ONNX export with model wrapping support -- **`TensorRTExporter`**: TensorRT engine building with precision policies -- **Project-specific exporters**: Custom exporters for complex models (e.g., CenterPoint) +**Unified Architecture**: All projects follow a consistent structure with three files per model: +- `{model}/onnx_exporter.py`: Project-specific ONNX exporter +- `{model}/tensorrt_exporter.py`: Project-specific TensorRT exporter +- `{model}/model_wrappers.py`: Project-specific model wrapper + +- **Base Exporters** (in `exporters/base/`): + - **`ONNXExporter`**: Standard ONNX export with model wrapping support + - **`TensorRTExporter`**: TensorRT engine building with precision policies + - **`BaseModelWrapper`**: Abstract base class for model wrappers + - **`IdentityWrapper`**: Default wrapper that doesn't modify model output + +- **Project-Specific Exporters**: + - **YOLOX** (`exporters/yolox/`): + - **`YOLOXONNXExporter`**: Inherits base ONNX exporter (uses `YOLOXONNXWrapper`) + - **`YOLOXTensorRTExporter`**: Inherits base TensorRT exporter + - **`YOLOXONNXWrapper`**: Transforms YOLOX output to Tier4-compatible format + - **CenterPoint** (`exporters/centerpoint/`): + - **`CenterPointONNXExporter`**: Extends base exporter for multi-file ONNX export + - **`CenterPointTensorRTExporter`**: Extends base exporter for multi-file TensorRT export + - **`CenterPointONNXWrapper`**: Identity wrapper (no transformation needed) + - **Calibration** (`exporters/calibration/`): + - **`CalibrationONNXExporter`**: Inherits base ONNX exporter (uses `IdentityWrapper`) + - **`CalibrationTensorRTExporter`**: Inherits base TensorRT exporter + - **`CalibrationONNXWrapper`**: Identity wrapper (no transformation needed) + +**Architecture Pattern**: +- **Simple models** (YOLOX, Calibration): Inherit base exporters, use custom wrappers if needed +- **Complex models** (CenterPoint): Extend base exporters for special logic (e.g., multi-file export), use IdentityWrapper #### 4. **Pipelines** @@ -143,6 +183,8 @@ evaluation = dict( Shared preprocessing/postprocessing with backend-specific inference: - **Preprocessing**: Image resize, normalization, voxelization (shared) + - Can be built from MMDet/MMDet3D configs using `build_preprocessing_pipeline` + - Used in data loaders to prepare input data - **Model Inference**: Backend-specific (PyTorch/ONNX/TensorRT) - **Postprocessing**: NMS, coordinate transform, decoding (shared) @@ -188,6 +230,43 @@ python projects/CalibrationStatusClassification/deploy/main.py \ checkpoint.pth ``` +### Creating DeploymentRunner + +All projects follow the dependency injection pattern, explicitly creating exporters and wrappers: + +```python +from autoware_ml.deployment.runners import DeploymentRunner +from autoware_ml.deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter +from autoware_ml.deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter +from autoware_ml.deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper + +# Create project-specific exporters +onnx_settings = config.get_onnx_settings() +trt_settings = config.get_tensorrt_settings() + +onnx_exporter = YOLOXONNXExporter(onnx_settings, logger) +tensorrt_exporter = YOLOXTensorRTExporter(trt_settings, logger) + +# Create runner with required exporters and wrapper +runner = DeploymentRunner( + data_loader=data_loader, + evaluator=evaluator, + config=config, + model_cfg=model_cfg, + logger=logger, + load_model_fn=load_pytorch_model, + onnx_exporter=onnx_exporter, # Required + tensorrt_exporter=tensorrt_exporter, # Required + model_wrapper=YOLOXONNXWrapper, # Required +) +``` + +**Key Points:** +- All exporters and wrappers must be explicitly created +- `onnx_exporter`, `tensorrt_exporter`, and `model_wrapper` are **required** (cannot be None) +- Each project uses its own specific exporter and wrapper classes +- This ensures clear dependencies and better type safety + ### Command-Line Arguments ```bash @@ -335,10 +414,18 @@ See project-specific configs: - ONNX-compatible model configuration - Custom exporters for complex model structure +**Exporter and Wrapper:** +- `CenterPointONNXExporter`: Extends base exporter for multi-file ONNX export +- `CenterPointTensorRTExporter`: Extends base exporter for multi-file TensorRT export +- `CenterPointONNXWrapper`: Identity wrapper (no output transformation) + **Key Files:** - `projects/CenterPoint/deploy/main.py` - `projects/CenterPoint/deploy/evaluator.py` - `autoware_ml/deployment/pipelines/centerpoint/` +- `autoware_ml/deployment/exporters/centerpoint/onnx_exporter.py` +- `autoware_ml/deployment/exporters/centerpoint/tensorrt_exporter.py` +- `autoware_ml/deployment/exporters/centerpoint/model_wrappers.py` **Pipeline Structure:** ``` @@ -353,10 +440,18 @@ run_backbone_head() → postprocess() - Model wrapper for ONNX-compatible output format - ReLU6 → ReLU replacement for ONNX compatibility +**Exporter and Wrapper:** +- `YOLOXONNXExporter`: Inherits base ONNX exporter, uses `YOLOXONNXWrapper` by default +- `YOLOXTensorRTExporter`: Inherits base TensorRT exporter +- `YOLOXONNXWrapper`: Transforms output from `(1, 8, 120, 120)` to `(1, 18900, 13)` format + **Key Files:** - `projects/YOLOX_opt_elan/deploy/main.py` - `projects/YOLOX_opt_elan/deploy/evaluator.py` - `autoware_ml/deployment/pipelines/yolox/` +- `autoware_ml/deployment/exporters/yolox/onnx_exporter.py` +- `autoware_ml/deployment/exporters/yolox/tensorrt_exporter.py` +- `autoware_ml/deployment/exporters/yolox/model_wrappers.py` **Pipeline Structure:** ``` @@ -370,10 +465,18 @@ preprocess() → run_model() → postprocess() - Simple single-file ONNX export - Calibrated/miscalibrated data loader variants +**Exporter and Wrapper:** +- `CalibrationONNXExporter`: Inherits base ONNX exporter, uses `IdentityWrapper` by default +- `CalibrationTensorRTExporter`: Inherits base TensorRT exporter +- `CalibrationONNXWrapper`: Identity wrapper (no output transformation) + **Key Files:** - `projects/CalibrationStatusClassification/deploy/main.py` - `projects/CalibrationStatusClassification/deploy/evaluator.py` - `autoware_ml/deployment/pipelines/calibration/` +- `autoware_ml/deployment/exporters/calibration/onnx_exporter.py` +- `autoware_ml/deployment/exporters/calibration/tensorrt_exporter.py` +- `autoware_ml/deployment/exporters/calibration/model_wrappers.py` **Pipeline Structure:** ``` @@ -386,7 +489,7 @@ preprocess() → run_model() → postprocess() ### Base Pipeline -All pipelines inherit from `BaseDeploymentPipeline`: +All pipelines inherit from `BaseDeploymentPipeline` (located in `pipelines/base/base_pipeline.py`): ```python class BaseDeploymentPipeline(ABC): @@ -413,19 +516,21 @@ class BaseDeploymentPipeline(ABC): return predictions ``` -### Task-Specific Pipelines +### Task-Specific Base Pipelines + +Located in `pipelines/base/`, these provide task-specific abstractions: -#### Detection2DPipeline +#### Detection2DPipeline (`pipelines/base/detection_2d_pipeline.py`) - Shared preprocessing: image resize, normalization, padding - Shared postprocessing: bbox decoding, NMS, coordinate transform - Backend-specific: model inference -#### Detection3DPipeline +#### Detection3DPipeline (`pipelines/base/detection_3d_pipeline.py`) - Shared preprocessing: voxelization, feature extraction - Shared postprocessing: 3D bbox decoding, NMS - Backend-specific: voxel encoder, backbone/head inference -#### ClassificationPipeline +#### ClassificationPipeline (`pipelines/base/classification_pipeline.py`) - Shared preprocessing: image normalization - Shared postprocessing: softmax, top-k selection - Backend-specific: model inference @@ -549,13 +654,26 @@ autoware_ml/deployment/ │ ├── base_config.py # Configuration management │ ├── base_data_loader.py # Data loader interface │ ├── base_evaluator.py # Evaluator interface -│ └── preprocessing_builder.py # Preprocessing builder +│ └── preprocessing_builder.py # Preprocessing pipeline builder │ -├── exporters/ # Model exporters -│ ├── base_exporter.py # Exporter base class -│ ├── onnx_exporter.py # ONNX exporter -│ ├── tensorrt_exporter.py # TensorRT exporter -│ └── model_wrappers.py # Model wrappers for ONNX +├── exporters/ # Model exporters (unified structure) +│ ├── base/ # Base exporter classes +│ │ ├── base_exporter.py # Exporter base class +│ │ ├── onnx_exporter.py # ONNX exporter base class +│ │ ├── tensorrt_exporter.py # TensorRT exporter base class +│ │ └── model_wrappers.py # Base model wrappers (BaseModelWrapper, IdentityWrapper) +│ ├── centerpoint/ # CenterPoint exporters (extends base) +│ │ ├── onnx_exporter.py # CenterPoint ONNX exporter (multi-file export) +│ │ ├── tensorrt_exporter.py # CenterPoint TensorRT exporter (multi-file export) +│ │ └── model_wrappers.py # CenterPoint model wrappers (IdentityWrapper) +│ ├── yolox/ # YOLOX exporters (inherits base) +│ │ ├── onnx_exporter.py # YOLOX ONNX exporter (inherits base) +│ │ ├── tensorrt_exporter.py # YOLOX TensorRT exporter (inherits base) +│ │ └── model_wrappers.py # YOLOX model wrappers (YOLOXONNXWrapper) +│ └── calibration/ # CalibrationStatusClassification exporters (inherits base) +│ ├── onnx_exporter.py # Calibration ONNX exporter (inherits base) +│ ├── tensorrt_exporter.py # Calibration TensorRT exporter (inherits base) +│ └── model_wrappers.py # Calibration model wrappers (IdentityWrapper) │ ├── pipelines/ # Task-specific pipelines │ ├── base/ # Base pipeline classes @@ -581,6 +699,7 @@ autoware_ml/deployment/ │ └── runners/ # Deployment runners └── deployment_runner.py # Unified deployment runner + projects/ ├── CenterPoint/deploy/ │ ├── main.py # Entry point @@ -616,10 +735,67 @@ projects/ ### 2. Model Export +- Always explicitly create project-specific exporters in `main.py` +- Always provide required `model_wrapper` parameter to `DeploymentRunner` +- Use project-specific wrapper classes (e.g., `YOLOXONNXWrapper`, `CenterPointONNXWrapper`) +- Follow the unified architecture pattern: each model has `onnx_exporter.py`, `tensorrt_exporter.py`, and `model_wrappers.py` +- Simple models: inherit base exporters, use custom wrappers if needed +- Complex models: extend base exporters for special logic, use IdentityWrapper if no transformation needed - Always verify ONNX export before TensorRT conversion - Use appropriate precision policies for TensorRT - Test with multiple samples during export +### 2.1. Unified Architecture Pattern + +All projects follow a unified structure with three files per model: + +``` +exporters/{model}/ +├── onnx_exporter.py # Project-specific ONNX exporter +├── tensorrt_exporter.py # Project-specific TensorRT exporter +└── model_wrappers.py # Project-specific model wrapper +``` + +**Pattern 1: Simple Models** (YOLOX, Calibration) +- Inherit base exporters (no special logic needed) +- Use custom wrappers if output format transformation is required +- Example: `YOLOXONNXExporter` inherits `ONNXExporter`, uses `YOLOXONNXWrapper` + +**Pattern 2: Complex Models** (CenterPoint) +- Extend base exporters for special requirements (e.g., multi-file export) +- Use IdentityWrapper if no output transformation needed +- Example: `CenterPointONNXExporter` extends `ONNXExporter` for multi-file export + +### 2.2. Dependency Injection Pattern + +All projects should follow this pattern: + +```python +# 1. Import project-specific exporters and wrappers +from autoware_ml.deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter +from autoware_ml.deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter +from autoware_ml.deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper + +# 2. Create exporters with settings +onnx_exporter = YOLOXONNXExporter(onnx_settings, logger) +tensorrt_exporter = YOLOXTensorRTExporter(trt_settings, logger) + +# 3. Pass to DeploymentRunner (all required) +runner = DeploymentRunner( + ..., + onnx_exporter=onnx_exporter, # Required + tensorrt_exporter=tensorrt_exporter, # Required + model_wrapper=YOLOXONNXWrapper, # Required +) +``` + +**Benefits:** +- Clear dependencies: All components are visible in `main.py` +- Type safety: IDE can provide better type hints +- No hidden dependencies: No global registry or string-based lookups +- Easy testing: Can inject mock objects for testing +- Unified structure: All models follow the same architectural pattern + ### 3. Verification - Start with small tolerance (0.01) and increase if needed @@ -680,11 +856,33 @@ projects/ When adding a new project: -1. Create project-specific evaluator and data loader -2. Implement task-specific pipeline (if needed) -3. Create deployment configuration -4. Add entry point script -5. Update documentation +1. **Create project-specific evaluator and data loader** + - Implement `BaseEvaluator` for task-specific metrics + - Implement `BaseDataLoader` for data loading + +2. **Create exporters following unified architecture pattern** + - Create `exporters/{model}/onnx_exporter.py` (inherit or extend `ONNXExporter`) + - Create `exporters/{model}/tensorrt_exporter.py` (inherit or extend `TensorRTExporter`) + - Create `exporters/{model}/model_wrappers.py` (use `IdentityWrapper` or implement custom wrapper) + - **Simple models**: Inherit base exporters, use custom wrapper if output transformation needed + - **Complex models**: Extend base exporters for special logic (e.g., multi-file export) + +3. **Implement task-specific pipeline** (if needed) + - Inherit from appropriate base pipeline (`Detection2DPipeline`, `Detection3DPipeline`, `ClassificationPipeline`) + - Implement backend-specific variants (PyTorch, ONNX, TensorRT) + +4. **Create deployment configuration** + - Add `projects/{project}/deploy/configs/deploy_config.py` + - Configure export, verification, and evaluation settings + +5. **Add entry point script** + - Create `projects/{project}/deploy/main.py` + - Follow dependency injection pattern: explicitly create exporters and wrappers + - Pass all required parameters to `DeploymentRunner` + +6. **Update documentation** + - Add project to README's "Project-Specific Implementations" section + - Document any special requirements or configurations --- diff --git a/autoware_ml/deployment/__init__.py b/autoware_ml/deployment/__init__.py index c667f538c..1af010f25 100644 --- a/autoware_ml/deployment/__init__.py +++ b/autoware_ml/deployment/__init__.py @@ -1,22 +1,23 @@ """ 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, TorchScript, etc.). """ -from .core.base_config import BaseDeploymentConfig -from .core.base_data_loader import BaseDataLoader -from .core.base_evaluator import BaseEvaluator -from .core.preprocessing_builder import build_preprocessing_pipeline -from .runners import DeploymentRunner +from autoware_ml.deployment.core.base_config import BaseDeploymentConfig +from autoware_ml.deployment.core.base_data_loader import BaseDataLoader +from autoware_ml.deployment.core.base_evaluator import BaseEvaluator +from autoware_ml.deployment.core.preprocessing_builder import build_preprocessing_pipeline +from autoware_ml.deployment.runners import BaseDeploymentRunner __all__ = [ "BaseDeploymentConfig", "BaseDataLoader", "BaseEvaluator", - "DeploymentRunner", + "BaseDeploymentRunner", "build_preprocessing_pipeline", ] diff --git a/autoware_ml/deployment/core/__init__.py b/autoware_ml/deployment/core/__init__.py index fada1ad39..4b59c5739 100644 --- a/autoware_ml/deployment/core/__init__.py +++ b/autoware_ml/deployment/core/__init__.py @@ -1,6 +1,6 @@ """Core components for deployment framework.""" -from .base_config import ( +from autoware_ml.deployment.core.base_config import ( BackendConfig, BaseDeploymentConfig, ExportConfig, @@ -8,11 +8,10 @@ parse_base_args, setup_logging, ) -from .base_data_loader import BaseDataLoader -from .base_evaluator import BaseEvaluator -from .preprocessing_builder import ( +from autoware_ml.deployment.core.base_data_loader import BaseDataLoader +from autoware_ml.deployment.core.base_evaluator import BaseEvaluator +from autoware_ml.deployment.core.preprocessing_builder import ( build_preprocessing_pipeline, - register_preprocessing_builder, ) __all__ = [ @@ -25,5 +24,4 @@ "BaseDataLoader", "BaseEvaluator", "build_preprocessing_pipeline", - "register_preprocessing_builder", ] diff --git a/autoware_ml/deployment/core/base_config.py b/autoware_ml/deployment/core/base_config.py index 20ba64e21..17d170220 100644 --- a/autoware_ml/deployment/core/base_config.py +++ b/autoware_ml/deployment/core/base_config.py @@ -1,5 +1,6 @@ """ Base configuration classes for deployment framework. + This module provides the foundation for task-agnostic deployment configuration. Task-specific deployment configs should extend BaseDeploymentConfig. """ @@ -85,6 +86,7 @@ def get_max_workspace_size(self) -> int: 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. """ @@ -92,6 +94,7 @@ class BaseDeploymentConfig: def __init__(self, deploy_cfg: Config): """ Initialize deployment configuration. + Args: deploy_cfg: MMEngine Config object containing deployment settings """ @@ -173,6 +176,7 @@ def task_type(self) -> Optional[str]: def get_onnx_settings(self) -> Dict[str, Any]: """ Get ONNX export settings. + Returns: Dictionary containing ONNX export parameters """ @@ -225,6 +229,7 @@ def get_onnx_settings(self) -> Dict[str, Any]: def get_tensorrt_settings(self) -> Dict[str, Any]: """ Get TensorRT export settings with precision policy support. + Returns: Dictionary containing TensorRT export parameters """ @@ -238,6 +243,7 @@ def get_tensorrt_settings(self) -> Dict[str, Any]: def update_batch_size(self, batch_size: int) -> None: """ Update batch size in backend config model_inputs. + Args: batch_size: New batch size to set """ @@ -312,8 +318,10 @@ def update_batch_size(self, batch_size: int) -> None: def setup_logging(level: str = "INFO") -> logging.Logger: """ Setup logging configuration. + Args: level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + Returns: Configured logger instance """ @@ -324,8 +332,10 @@ def setup_logging(level: str = "INFO") -> logging.Logger: 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 """ diff --git a/autoware_ml/deployment/core/base_data_loader.py b/autoware_ml/deployment/core/base_data_loader.py index a119bf501..86206de0f 100644 --- a/autoware_ml/deployment/core/base_data_loader.py +++ b/autoware_ml/deployment/core/base_data_loader.py @@ -1,5 +1,6 @@ """ Abstract base class for data loading in deployment. + Each task (classification, detection, segmentation, etc.) must implement a concrete DataLoader that extends this base class. """ @@ -13,6 +14,7 @@ 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. @@ -21,6 +23,7 @@ class BaseDataLoader(ABC): def __init__(self, config: Dict[str, Any]): """ Initialize data loader. + Args: config: Configuration dictionary containing task-specific settings """ @@ -30,14 +33,17 @@ def __init__(self, config: Dict[str, Any]): def load_sample(self, index: int) -> Dict[str, Any]: """ 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 @@ -48,11 +54,14 @@ def load_sample(self, index: int) -> Dict[str, Any]: def preprocess(self, sample: Dict[str, Any]) -> 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 """ @@ -62,6 +71,7 @@ def preprocess(self, sample: Dict[str, Any]) -> torch.Tensor: def get_num_samples(self) -> int: """ Get total number of samples in the dataset. + Returns: Total number of samples available """ diff --git a/autoware_ml/deployment/core/base_evaluator.py b/autoware_ml/deployment/core/base_evaluator.py index 231a8c261..2dc3bfb91 100644 --- a/autoware_ml/deployment/core/base_evaluator.py +++ b/autoware_ml/deployment/core/base_evaluator.py @@ -1,5 +1,6 @@ """ Abstract base class for model evaluation in deployment. + Each task (classification, detection, segmentation, etc.) must implement a concrete Evaluator that extends this base class to compute task-specific metrics. """ @@ -9,12 +10,13 @@ import numpy as np -from .base_data_loader import BaseDataLoader +from autoware_ml.deployment.core.base_data_loader import BaseDataLoader class BaseEvaluator(ABC): """ Abstract base class for task-specific evaluators. + This class defines the interface that all task-specific evaluators must implement. It handles running inference on a dataset and computing evaluation metrics appropriate for the task. @@ -23,6 +25,7 @@ class BaseEvaluator(ABC): def __init__(self, config: Dict[str, Any]): """ Initialize evaluator. + Args: config: Configuration dictionary containing evaluation settings """ @@ -40,6 +43,7 @@ def evaluate( ) -> Dict[str, Any]: """ Run full evaluation on a model. + Args: model_path: Path to model checkpoint/weights data_loader: DataLoader for loading samples @@ -47,6 +51,7 @@ def evaluate( backend: Backend to use ('pytorch', 'onnx', 'tensorrt') device: Device to run inference on verbose: Whether to print detailed progress + Returns: Dictionary containing evaluation metrics. The exact metrics depend on the task, but should include: @@ -54,6 +59,7 @@ def evaluate( - Per-class metrics (if applicable) - Inference latency statistics - Any other relevant metrics + Example: For classification: { @@ -64,6 +70,7 @@ def evaluate( "confusion_matrix": [...], "avg_latency_ms": 5.2, } + For detection: { "mAP": 0.72, @@ -79,6 +86,7 @@ def evaluate( def print_results(self, results: Dict[str, Any]) -> None: """ Pretty print evaluation results. + Args: results: Results dictionary returned by evaluate() """ @@ -131,8 +139,10 @@ def verify( def compute_latency_stats(self, latencies: list) -> Dict[str, float]: """ Compute latency statistics from a list of latency measurements. + Args: latencies: List of latency values in milliseconds + Returns: Dictionary with latency statistics """ @@ -158,8 +168,10 @@ def compute_latency_stats(self, latencies: list) -> Dict[str, float]: def format_latency_stats(self, stats: Dict[str, float]) -> str: """ Format latency statistics as a readable string. + Args: stats: Latency statistics dictionary + Returns: Formatted string """ diff --git a/autoware_ml/deployment/core/preprocessing_builder.py b/autoware_ml/deployment/core/preprocessing_builder.py index 3cd12f080..29dd3e4af 100644 --- a/autoware_ml/deployment/core/preprocessing_builder.py +++ b/autoware_ml/deployment/core/preprocessing_builder.py @@ -1,7 +1,9 @@ """ 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. + NOTE: This module is compatible with the new pipeline architecture (BaseDeploymentPipeline). They serve complementary purposes: - preprocessing_builder.py: Builds MMDet/MMDet3D preprocessing pipelines for data loaders @@ -36,12 +38,15 @@ def build( ) -> 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 """ @@ -95,6 +100,7 @@ def _register_default_builders(self): def register(self, task_type: str, builder: Callable[[List], Any]): """ Register a pipeline builder for a task type. + Args: task_type: Task type identifier builder: Builder function that takes pipeline_cfg and returns Compose object @@ -107,11 +113,14 @@ def register(self, task_type: str, builder: Callable[[List], Any]): def build(self, task_type: str, pipeline_cfg: List) -> Any: """ Build pipeline for given task type. + Args: task_type: Task type identifier pipeline_cfg: Pipeline configuration + Returns: Compose object + Raises: ValueError: If task_type is not registered """ @@ -168,8 +177,10 @@ def build_preprocessing_pipeline( ) -> 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. Can have pipeline defined in one of these locations: @@ -182,11 +193,14 @@ def build_preprocessing_pipeline( Recommended: specify in deploy_config.py as ``task_type = "detection3d"``. backend: Target backend ('pytorch', 'onnx', 'tensorrt'). Currently not used, reserved for future backend-specific optimizations. + 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') @@ -204,11 +218,14 @@ def build_preprocessing_pipeline( def _resolve_task_type(model_cfg: Config, task_type: Optional[str] = None) -> str: """ Resolve task type from various sources. + Args: model_cfg: Model configuration task_type: Explicit task type (highest priority) + Returns: Resolved task type string + Raises: ValueError: If task_type cannot be resolved """ @@ -240,8 +257,10 @@ def _resolve_task_type(model_cfg: Config, task_type: Optional[str] = None) -> st def _validate_task_type(task_type: str) -> None: """ Validate task type. + Args: task_type: Task type to validate + Raises: ValueError: If task_type is invalid """ @@ -255,10 +274,13 @@ def _validate_task_type(task_type: str) -> None: def _extract_pipeline_config(model_cfg: Config) -> List: """ Extract pipeline configuration from model config. + Args: model_cfg: Model configuration + Returns: List of transform configurations + Raises: ValueError: If no valid pipeline found """ @@ -288,19 +310,3 @@ def _extract_pipeline_config(model_cfg: Config) -> List: "Expected pipeline at one of: test_dataloader.dataset.pipeline, " "test_pipeline, or val_dataloader.dataset.pipeline" ) - - -# Public API: Allow custom pipeline builder registration -def register_preprocessing_builder(task_type: str, builder: Callable[[List], Any]): - """ - Register a custom preprocessing pipeline builder. - Args: - task_type: Task type identifier - builder: Builder function that takes pipeline_cfg and returns Compose object - Examples: - >>> def custom_builder(pipeline_cfg): - ... # Custom logic - ... return Compose(pipeline_cfg) - >>> register_preprocessing_builder("custom_task", custom_builder) - """ - _registry.register(task_type, builder) diff --git a/autoware_ml/deployment/exporters/__init__.py b/autoware_ml/deployment/exporters/__init__.py index 8813a9396..40be78078 100644 --- a/autoware_ml/deployment/exporters/__init__.py +++ b/autoware_ml/deployment/exporters/__init__.py @@ -1,18 +1,21 @@ """Model exporters for different backends.""" -from .base_exporter import BaseExporter -from .centerpoint_exporter import CenterPointONNXExporter -from .centerpoint_tensorrt_exporter import CenterPointTensorRTExporter -from .model_wrappers import ( +from autoware_ml.deployment.exporters.base.base_exporter import BaseExporter +from autoware_ml.deployment.exporters.base.model_wrappers import ( BaseModelWrapper, IdentityWrapper, - YOLOXONNXWrapper, - get_model_wrapper, - list_model_wrappers, - register_model_wrapper, ) -from .onnx_exporter import ONNXExporter -from .tensorrt_exporter import TensorRTExporter +from autoware_ml.deployment.exporters.base.onnx_exporter import ONNXExporter +from autoware_ml.deployment.exporters.base.tensorrt_exporter import TensorRTExporter +from autoware_ml.deployment.exporters.calibration.model_wrappers import CalibrationONNXWrapper +from autoware_ml.deployment.exporters.calibration.onnx_exporter import CalibrationONNXExporter +from autoware_ml.deployment.exporters.calibration.tensorrt_exporter import CalibrationTensorRTExporter +from autoware_ml.deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper +from autoware_ml.deployment.exporters.centerpoint.onnx_exporter import CenterPointONNXExporter +from autoware_ml.deployment.exporters.centerpoint.tensorrt_exporter import CenterPointTensorRTExporter +from autoware_ml.deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper +from autoware_ml.deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter +from autoware_ml.deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter __all__ = [ "BaseExporter", @@ -20,10 +23,13 @@ "TensorRTExporter", "CenterPointONNXExporter", "CenterPointTensorRTExporter", - "BaseModelWrapper", + "CenterPointONNXWrapper", + "YOLOXONNXExporter", + "YOLOXTensorRTExporter", "YOLOXONNXWrapper", + "CalibrationONNXExporter", + "CalibrationTensorRTExporter", + "CalibrationONNXWrapper", + "BaseModelWrapper", "IdentityWrapper", - "register_model_wrapper", - "get_model_wrapper", - "list_model_wrappers", ] diff --git a/autoware_ml/deployment/exporters/base_exporter.py b/autoware_ml/deployment/exporters/base/base_exporter.py similarity index 59% rename from autoware_ml/deployment/exporters/base_exporter.py rename to autoware_ml/deployment/exporters/base/base_exporter.py index 9e7a7e24d..6d7d2a086 100644 --- a/autoware_ml/deployment/exporters/base_exporter.py +++ b/autoware_ml/deployment/exporters/base/base_exporter.py @@ -1,5 +1,6 @@ """ Abstract base class for model exporters. + Provides a unified interface for exporting models to different formats. """ @@ -13,6 +14,7 @@ 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.). @@ -22,46 +24,20 @@ class BaseExporter(ABC): - Better logging and error handling """ - def __init__(self, config: Dict[str, Any], logger: logging.Logger = None): + def __init__(self, config: Dict[str, Any], logger: logging.Logger = None, model_wrapper: Optional[Any] = None): """ Initialize exporter. + Args: config: Configuration dictionary for export settings logger: Optional logger instance + model_wrapper: Optional model wrapper class or instance. + 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. """ self.config = config self.logger = logger or logging.getLogger(__name__) - self._model_wrapper_fn: Optional[Callable] = None - - # Extract wrapper configuration if present - wrapper_config = config.get("model_wrapper") - if wrapper_config: - self._setup_model_wrapper(wrapper_config) - - def _setup_model_wrapper(self, wrapper_config): - """ - Setup model wrapper from configuration. - - Args: - wrapper_config: Either a string (wrapper name) or dict with 'type' and kwargs - """ - from .model_wrappers import get_model_wrapper - - if isinstance(wrapper_config, str): - # Simple string: wrapper name only - wrapper_class = get_model_wrapper(wrapper_config) - self._model_wrapper_fn = lambda model: wrapper_class(model) - elif isinstance(wrapper_config, dict): - # Dict with type and additional arguments - wrapper_type = wrapper_config.get("type") - if not wrapper_type: - raise ValueError("Model wrapper config must have 'type' field") - - wrapper_class = get_model_wrapper(wrapper_type) - wrapper_kwargs = {k: v for k, v in wrapper_config.items() if k != "type"} - self._model_wrapper_fn = lambda model: wrapper_class(model, **wrapper_kwargs) - else: - raise TypeError(f"Model wrapper config must be str or dict, got {type(wrapper_config)}") + self._model_wrapper = model_wrapper def prepare_model(self, model: torch.nn.Module) -> torch.nn.Module: """ @@ -73,22 +49,34 @@ def prepare_model(self, model: torch.nn.Module) -> torch.nn.Module: Returns: Prepared model (wrapped if wrapper configured) """ - if self._model_wrapper_fn: - self.logger.info("Applying model wrapper for export") - return self._model_wrapper_fn(model) - return model + if self._model_wrapper is None: + return model + + self.logger.info("Applying model wrapper for export") + + # If model_wrapper is a class, instantiate it with the model + if isinstance(self._model_wrapper, type): + return self._model_wrapper(model) + # If model_wrapper is a callable (function or instance with __call__), use it + elif callable(self._model_wrapper): + return self._model_wrapper(model) + else: + raise TypeError(f"model_wrapper must be a class or callable, got {type(self._model_wrapper)}") @abstractmethod def export(self, model: torch.nn.Module, sample_input: torch.Tensor, output_path: str, **kwargs) -> bool: """ Export model to target format. + Args: model: PyTorch model to export sample_input: Sample input tensor for tracing/shape inference output_path: Path to save exported model **kwargs: Additional format-specific arguments + Returns: True if export succeeded, False otherwise + Raises: RuntimeError: If export fails """ @@ -97,9 +85,12 @@ def export(self, model: torch.nn.Module, sample_input: torch.Tensor, output_path def validate_export(self, output_path: str) -> bool: """ Validate that the exported model file is valid. + Override this in subclasses to add format-specific validation. + Args: output_path: Path to exported model file + Returns: True if valid, False otherwise """ diff --git a/autoware_ml/deployment/exporters/model_wrappers.py b/autoware_ml/deployment/exporters/base/model_wrappers.py similarity index 52% rename from autoware_ml/deployment/exporters/model_wrappers.py rename to autoware_ml/deployment/exporters/base/model_wrappers.py index 9d3d41d46..c1c215536 100644 --- a/autoware_ml/deployment/exporters/model_wrappers.py +++ b/autoware_ml/deployment/exporters/base/model_wrappers.py @@ -1,7 +1,12 @@ """ -Model wrappers for ONNX export. -This module provides wrapper classes that prepare models for ONNX export -with specific output formats and processing requirements. +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 @@ -17,6 +22,9 @@ class BaseModelWrapper(nn.Module, ABC): 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): @@ -45,14 +53,12 @@ def get_config(self) -> Dict[str, Any]: return self._wrapper_config -# TODO(vividf): class YOLOXONNXWrapper - - 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): @@ -61,46 +67,3 @@ def __init__(self, model: nn.Module, **kwargs): def forward(self, *args, **kwargs): """Forward pass without modification.""" return self.model(*args, **kwargs) - - -# Model wrapper registry -_MODEL_WRAPPERS = { - # 'yolox': YOLOXONNXWrapper, - "identity": IdentityWrapper, -} - - -def register_model_wrapper(name: str, wrapper_class: type): - """ - Register a custom model wrapper. - - Args: - name: Wrapper name - wrapper_class: Wrapper class (must inherit from BaseModelWrapper) - """ - if not issubclass(wrapper_class, BaseModelWrapper): - raise TypeError(f"Wrapper class must inherit from BaseModelWrapper, got {wrapper_class}") - _MODEL_WRAPPERS[name] = wrapper_class - - -def get_model_wrapper(name: str): - """ - Get model wrapper class by name. - - Args: - name: Wrapper name - - Returns: - Wrapper class - - Raises: - KeyError: If wrapper name not found - """ - if name not in _MODEL_WRAPPERS: - raise KeyError(f"Model wrapper '{name}' not found. " f"Available wrappers: {list(_MODEL_WRAPPERS.keys())}") - return _MODEL_WRAPPERS[name] - - -def list_model_wrappers(): - """List all registered model wrappers.""" - return list(_MODEL_WRAPPERS.keys()) diff --git a/autoware_ml/deployment/exporters/onnx_exporter.py b/autoware_ml/deployment/exporters/base/onnx_exporter.py similarity index 95% rename from autoware_ml/deployment/exporters/onnx_exporter.py rename to autoware_ml/deployment/exporters/base/onnx_exporter.py index 0ec4023c9..ef3611b96 100644 --- a/autoware_ml/deployment/exporters/onnx_exporter.py +++ b/autoware_ml/deployment/exporters/base/onnx_exporter.py @@ -8,12 +8,13 @@ import onnxsim import torch -from .base_exporter import BaseExporter +from autoware_ml.deployment.exporters.base.base_exporter import BaseExporter 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 @@ -21,14 +22,16 @@ class ONNXExporter(BaseExporter): - Configuration override capability """ - def __init__(self, config: Dict[str, Any], logger: logging.Logger = None): + def __init__(self, config: Dict[str, Any], logger: logging.Logger = None, model_wrapper: Optional[Any] = None): """ Initialize ONNX exporter. + Args: config: ONNX export configuration logger: Optional logger instance + model_wrapper: Optional model wrapper class (e.g., YOLOXONNXWrapper) """ - super().__init__(config, logger) + super().__init__(config, logger, model_wrapper=model_wrapper) def export( self, @@ -39,11 +42,13 @@ def export( ) -> bool: """ 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 config overrides for this specific export + Returns: True if export succeeded """ @@ -160,6 +165,7 @@ def export_multi( def _simplify_model(self, onnx_path: str) -> None: """ Simplify ONNX model using onnxsim. + Args: onnx_path: Path to ONNX model file """ diff --git a/autoware_ml/deployment/exporters/tensorrt_exporter.py b/autoware_ml/deployment/exporters/base/tensorrt_exporter.py similarity index 96% rename from autoware_ml/deployment/exporters/tensorrt_exporter.py rename to autoware_ml/deployment/exporters/base/tensorrt_exporter.py index feee3fc58..3f65a154d 100644 --- a/autoware_ml/deployment/exporters/tensorrt_exporter.py +++ b/autoware_ml/deployment/exporters/base/tensorrt_exporter.py @@ -1,12 +1,12 @@ """TensorRT model exporter.""" import logging -from typing import Any, Dict +from typing import Any, Dict, Optional import tensorrt as trt import torch -from .base_exporter import BaseExporter +from autoware_ml.deployment.exporters.base.base_exporter import BaseExporter class TensorRTExporter(BaseExporter): @@ -16,15 +16,16 @@ class TensorRTExporter(BaseExporter): Converts ONNX models to TensorRT engine format with precision policy support. """ - def __init__(self, config: Dict[str, Any], logger: logging.Logger = None): + def __init__(self, config: Dict[str, Any], logger: logging.Logger = None, model_wrapper: Optional[Any] = None): """ Initialize TensorRT exporter. Args: config: TensorRT export configuration logger: Optional logger instance + model_wrapper: Optional model wrapper class (usually not needed for TensorRT) """ - super().__init__(config) + super().__init__(config, logger, model_wrapper=model_wrapper) self.logger = logger or logging.getLogger(__name__) def export( diff --git a/autoware_ml/deployment/exporters/centerpoint/__init__.py b/autoware_ml/deployment/exporters/centerpoint/__init__.py new file mode 100644 index 000000000..b9c33f70d --- /dev/null +++ b/autoware_ml/deployment/exporters/centerpoint/__init__.py @@ -0,0 +1,11 @@ +"""CenterPoint-specific exporters and model wrappers.""" + +from autoware_ml.deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper +from autoware_ml.deployment.exporters.centerpoint.onnx_exporter import CenterPointONNXExporter +from autoware_ml.deployment.exporters.centerpoint.tensorrt_exporter import CenterPointTensorRTExporter + +__all__ = [ + "CenterPointONNXExporter", + "CenterPointTensorRTExporter", + "CenterPointONNXWrapper", +] diff --git a/autoware_ml/deployment/pipelines/calibration/__init__.py b/autoware_ml/deployment/exporters/centerpoint/model_wrappers.py similarity index 100% rename from autoware_ml/deployment/pipelines/calibration/__init__.py rename to autoware_ml/deployment/exporters/centerpoint/model_wrappers.py diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_onnx.py b/autoware_ml/deployment/exporters/centerpoint/onnx_exporter.py similarity index 100% rename from autoware_ml/deployment/pipelines/calibration/calibration_onnx.py rename to autoware_ml/deployment/exporters/centerpoint/onnx_exporter.py diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_pipeline.py b/autoware_ml/deployment/exporters/centerpoint/tensorrt_exporter.py similarity index 100% rename from autoware_ml/deployment/pipelines/calibration/calibration_pipeline.py rename to autoware_ml/deployment/exporters/centerpoint/tensorrt_exporter.py diff --git a/autoware_ml/deployment/pipelines/__init__.py b/autoware_ml/deployment/pipelines/__init__.py index 42e90fa7b..00e73ab0f 100644 --- a/autoware_ml/deployment/pipelines/__init__.py +++ b/autoware_ml/deployment/pipelines/__init__.py @@ -1,47 +1,48 @@ """ Deployment Pipelines for Complex Models. + This module provides pipeline abstractions for models that require multi-stage processing with mixed PyTorch and optimized backend inference. """ -# # CenterPoint pipelines (3D detection) -# from .centerpoint import ( -# CenterPointDeploymentPipeline, -# CenterPointPyTorchPipeline, -# CenterPointONNXPipeline, -# CenterPointTensorRTPipeline, -# ) +# Calibration pipelines (classification) +from autoware_ml.deployment.pipelines.calibration import ( + CalibrationDeploymentPipeline, + CalibrationONNXPipeline, + CalibrationPyTorchPipeline, + CalibrationTensorRTPipeline, +) -# # YOLOX pipelines (2D detection) -# from .yolox import ( -# YOLOXDeploymentPipeline, -# YOLOXPyTorchPipeline, -# YOLOXONNXPipeline, -# YOLOXTensorRTPipeline, -# ) +# CenterPoint pipelines (3D detection) +from autoware_ml.deployment.pipelines.centerpoint import ( + CenterPointDeploymentPipeline, + CenterPointONNXPipeline, + CenterPointPyTorchPipeline, + CenterPointTensorRTPipeline, +) -# # Calibration pipelines (classification) -# from .calibration import ( -# CalibrationDeploymentPipeline, -# CalibrationPyTorchPipeline, -# CalibrationONNXPipeline, -# CalibrationTensorRTPipeline, -# ) +# YOLOX pipelines (2D detection) +from autoware_ml.deployment.pipelines.yolox import ( + YOLOXDeploymentPipeline, + YOLOXONNXPipeline, + YOLOXPyTorchPipeline, + YOLOXTensorRTPipeline, +) -# __all__ = [ -# # CenterPoint -# 'CenterPointDeploymentPipeline', -# 'CenterPointPyTorchPipeline', -# 'CenterPointONNXPipeline', -# 'CenterPointTensorRTPipeline', -# # YOLOX -# 'YOLOXDeploymentPipeline', -# 'YOLOXPyTorchPipeline', -# 'YOLOXONNXPipeline', -# 'YOLOXTensorRTPipeline', -# # Calibration -# 'CalibrationDeploymentPipeline', -# 'CalibrationPyTorchPipeline', -# 'CalibrationONNXPipeline', -# 'CalibrationTensorRTPipeline', -# ] +__all__ = [ + # CenterPoint + "CenterPointDeploymentPipeline", + "CenterPointPyTorchPipeline", + "CenterPointONNXPipeline", + "CenterPointTensorRTPipeline", + # YOLOX + "YOLOXDeploymentPipeline", + "YOLOXPyTorchPipeline", + "YOLOXONNXPipeline", + "YOLOXTensorRTPipeline", + # Calibration + "CalibrationDeploymentPipeline", + "CalibrationPyTorchPipeline", + "CalibrationONNXPipeline", + "CalibrationTensorRTPipeline", +] diff --git a/autoware_ml/deployment/pipelines/base/__init__.py b/autoware_ml/deployment/pipelines/base/__init__.py index 256fd1a72..ff2f975d1 100644 --- a/autoware_ml/deployment/pipelines/base/__init__.py +++ b/autoware_ml/deployment/pipelines/base/__init__.py @@ -5,10 +5,10 @@ including base pipeline, classification, 2D detection, and 3D detection pipelines. """ -from .base_pipeline import BaseDeploymentPipeline -from .classification_pipeline import ClassificationPipeline -from .detection_2d_pipeline import Detection2DPipeline -from .detection_3d_pipeline import Detection3DPipeline +from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline +from autoware_ml.deployment.pipelines.base.classification_pipeline import ClassificationPipeline +from autoware_ml.deployment.pipelines.base.detection_2d_pipeline import Detection2DPipeline +from autoware_ml.deployment.pipelines.base.detection_3d_pipeline import Detection3DPipeline __all__ = [ "BaseDeploymentPipeline", diff --git a/autoware_ml/deployment/pipelines/base/base_pipeline.py b/autoware_ml/deployment/pipelines/base/base_pipeline.py index 00bcebf30..cfd801830 100644 --- a/autoware_ml/deployment/pipelines/base/base_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/base_pipeline.py @@ -1,10 +1,13 @@ """ Base Deployment Pipeline for Unified Model Deployment. + This module provides the abstract base class for all deployment pipelines, defining a unified interface across different backends (PyTorch, ONNX, TensorRT) and task types (detection, classification, segmentation). + Architecture: Input → preprocess() → run_model() → postprocess() → Output + Key Design Principles: 1. Shared Logic: preprocess/postprocess are shared across backends 2. Backend-Specific: run_model() is implemented per backend diff --git a/autoware_ml/deployment/pipelines/base/classification_pipeline.py b/autoware_ml/deployment/pipelines/base/classification_pipeline.py index 78e10d900..5d8791a2b 100644 --- a/autoware_ml/deployment/pipelines/base/classification_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/classification_pipeline.py @@ -1,5 +1,6 @@ """ Classification Pipeline Base Class. + This module provides the base class for classification pipelines, implementing common preprocessing and postprocessing for image/point cloud classification. """ @@ -11,7 +12,7 @@ import numpy as np import torch -from .base_pipeline import BaseDeploymentPipeline +from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline logger = logging.getLogger(__name__) diff --git a/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py index 4751c52dd..3df7b27f4 100644 --- a/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py @@ -1,5 +1,6 @@ """ 2D Object Detection Pipeline Base Class. + This module provides the base class for 2D object detection pipelines, implementing common preprocessing and postprocessing for models like YOLOX, YOLO, etc. """ @@ -11,7 +12,7 @@ import numpy as np import torch -from .base_pipeline import BaseDeploymentPipeline +from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline logger = logging.getLogger(__name__) diff --git a/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py index d0791476a..acf002072 100644 --- a/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py +++ b/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py @@ -12,7 +12,7 @@ import numpy as np import torch -from .base_pipeline import BaseDeploymentPipeline +from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline logger = logging.getLogger(__name__) diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_tensorrt.py b/autoware_ml/deployment/pipelines/calibration/calibration_tensorrt.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/centerpoint/__init__.py b/autoware_ml/deployment/pipelines/centerpoint/__init__.py index e69de29bb..37890aa0e 100644 --- a/autoware_ml/deployment/pipelines/centerpoint/__init__.py +++ b/autoware_ml/deployment/pipelines/centerpoint/__init__.py @@ -0,0 +1,44 @@ +""" +CenterPoint Deployment Pipelines. + +This module provides unified deployment pipelines for CenterPoint 3D object detection +across different backends (PyTorch, ONNX, TensorRT). + +Example usage: + +PyTorch: + >>> from autoware_ml.deployment.pipelines.centerpoint import CenterPointPyTorchPipeline + >>> pipeline = CenterPointPyTorchPipeline(model, device='cuda') + >>> predictions, latency, breakdown = pipeline.infer(points) + +ONNX: + >>> from autoware_ml.deployment.pipelines.centerpoint import CenterPointONNXPipeline + >>> pipeline = CenterPointONNXPipeline(pytorch_model, onnx_dir='models', device='cuda') + >>> predictions, latency, breakdown = pipeline.infer(points) + +TensorRT: + >>> from autoware_ml.deployment.pipelines.centerpoint import CenterPointTensorRTPipeline + >>> pipeline = CenterPointTensorRTPipeline(pytorch_model, tensorrt_dir='engines', device='cuda') + >>> predictions, latency, breakdown = pipeline.infer(points) + +Note: + All pipelines now use the unified `infer()` interface from the base class. + The `breakdown` dict contains stage-wise latencies: + - preprocessing_ms + - voxel_encoder_ms + - middle_encoder_ms + - backbone_head_ms + - postprocessing_ms +""" + +from autoware_ml.deployment.pipelines.centerpoint.centerpoint_onnx import CenterPointONNXPipeline +from autoware_ml.deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline +from autoware_ml.deployment.pipelines.centerpoint.centerpoint_pytorch import CenterPointPyTorchPipeline +from autoware_ml.deployment.pipelines.centerpoint.centerpoint_tensorrt import CenterPointTensorRTPipeline + +__all__ = [ + "CenterPointDeploymentPipeline", + "CenterPointPyTorchPipeline", + "CenterPointONNXPipeline", + "CenterPointTensorRTPipeline", +] diff --git a/autoware_ml/deployment/pipelines/yolox/__init__.py b/autoware_ml/deployment/pipelines/yolox/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_onnx.py b/autoware_ml/deployment/pipelines/yolox/yolox_onnx.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_pipeline.py b/autoware_ml/deployment/pipelines/yolox/yolox_pipeline.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_pytorch.py b/autoware_ml/deployment/pipelines/yolox/yolox_pytorch.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/yolox/yolox_tensorrt.py b/autoware_ml/deployment/pipelines/yolox/yolox_tensorrt.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/runners/__init__.py b/autoware_ml/deployment/runners/__init__.py index d7fe4989b..5bc1507f8 100644 --- a/autoware_ml/deployment/runners/__init__.py +++ b/autoware_ml/deployment/runners/__init__.py @@ -1,7 +1,13 @@ """Deployment runners for unified deployment workflow.""" -from .deployment_runner import DeploymentRunner +from autoware_ml.deployment.runners.calibration_runner import CalibrationDeploymentRunner +from autoware_ml.deployment.runners.centerpoint_runner import CenterPointDeploymentRunner +from autoware_ml.deployment.runners.deployment_runner import BaseDeploymentRunner +from autoware_ml.deployment.runners.yolox_runner import YOLOXDeploymentRunner __all__ = [ - "DeploymentRunner", + "BaseDeploymentRunner", + "CenterPointDeploymentRunner", + "YOLOXDeploymentRunner", + "CalibrationDeploymentRunner", ] diff --git a/autoware_ml/deployment/pipelines/calibration/calibration_pytorch.py b/autoware_ml/deployment/runners/centerpoint_runner.py similarity index 100% rename from autoware_ml/deployment/pipelines/calibration/calibration_pytorch.py rename to autoware_ml/deployment/runners/centerpoint_runner.py diff --git a/autoware_ml/deployment/runners/deployment_runner.py b/autoware_ml/deployment/runners/deployment_runner.py index 01716b0f5..ff6b9751e 100644 --- a/autoware_ml/deployment/runners/deployment_runner.py +++ b/autoware_ml/deployment/runners/deployment_runner.py @@ -1,24 +1,23 @@ """ Unified deployment runner for common deployment workflows. + This module provides a unified runner that handles the common deployment workflow across different projects, while allowing project-specific customization. """ import logging import os -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import torch from mmengine.config import Config from autoware_ml.deployment.core import BaseDataLoader, BaseDeploymentConfig, BaseEvaluator -from autoware_ml.deployment.exporters.onnx_exporter import ONNXExporter -from autoware_ml.deployment.exporters.tensorrt_exporter import TensorRTExporter -class DeploymentRunner: +class BaseDeploymentRunner: """ - Unified deployment runner for common deployment workflows. + Base deployment runner for common deployment workflows. This runner handles the standard deployment workflow: 1. Load PyTorch model (if needed) @@ -27,10 +26,10 @@ class DeploymentRunner: 4. Verify outputs (if enabled) 5. Evaluate models (if enabled) - Projects can customize behavior by: - - Overriding methods (load_pytorch_model, export_onnx, export_tensorrt) - - Providing custom callbacks - - Extending this class + Projects should extend this class and override methods as needed: + - Override export_onnx() for project-specific ONNX export logic + - Override export_tensorrt() for project-specific TensorRT export logic + - Override load_pytorch_model() for project-specific model loading """ def __init__( @@ -40,14 +39,11 @@ def __init__( config: BaseDeploymentConfig, model_cfg: Config, logger: logging.Logger, - load_model_fn: Optional[Callable] = None, - export_onnx_fn: Optional[Callable] = None, - export_tensorrt_fn: Optional[Callable] = None, - onnx_exporter: Optional[Any] = None, - tensorrt_exporter: Optional[Any] = None, + onnx_exporter: Any = None, + tensorrt_exporter: Any = None, ): """ - Initialize unified deployment runner. + Initialize base deployment runner. Args: data_loader: Data loader for samples @@ -55,20 +51,23 @@ def __init__( config: Deployment configuration model_cfg: Model configuration logger: Logger instance - load_model_fn: Optional custom function to load PyTorch model - export_onnx_fn: Optional custom function to export ONNX - export_tensorrt_fn: Optional custom function to export TensorRT - onnx_exporter: Optional ONNX exporter instance (e.g., CenterPointONNXExporter) - tensorrt_exporter: Optional TensorRT exporter instance (e.g., CenterPointTensorRTExporter) + onnx_exporter: Required ONNX exporter instance (e.g., CenterPointONNXExporter, YOLOXONNXExporter) + tensorrt_exporter: Required TensorRT exporter instance (e.g., CenterPointTensorRTExporter, YOLOXTensorRTExporter) + + Raises: + ValueError: If onnx_exporter or tensorrt_exporter is None """ + # Validate required exporters + if onnx_exporter is None: + raise ValueError("onnx_exporter is required and cannot be None") + if tensorrt_exporter is None: + raise ValueError("tensorrt_exporter is required and cannot be None") + self.data_loader = data_loader self.evaluator = evaluator self.config = config self.model_cfg = model_cfg self.logger = logger - self._load_model_fn = load_model_fn - self._export_onnx_fn = export_onnx_fn - self._export_tensorrt_fn = export_tensorrt_fn self._onnx_exporter = onnx_exporter self._tensorrt_exporter = tensorrt_exporter @@ -76,7 +75,7 @@ def load_pytorch_model(self, checkpoint_path: str, **kwargs) -> Any: """ Load PyTorch model from checkpoint. - Uses custom function if provided, otherwise uses default implementation. + Subclasses must implement this method to provide project-specific model loading logic. Args: checkpoint_path: Path to checkpoint file @@ -84,19 +83,17 @@ def load_pytorch_model(self, checkpoint_path: str, **kwargs) -> Any: Returns: Loaded PyTorch model - """ - if self._load_model_fn: - return self._load_model_fn(checkpoint_path, **kwargs) - # Default implementation - should be overridden by projects - self.logger.warning("Using default load_pytorch_model - projects should override this") - raise NotImplementedError("load_pytorch_model must be implemented or provided via load_model_fn") + Raises: + NotImplementedError: If not implemented by subclass + """ + raise NotImplementedError(f"{self.__class__.__name__}.load_pytorch_model() must be implemented by subclasses.") def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: """ Export model to ONNX format. - Uses custom function if provided, otherwise uses standard ONNXExporter. + Uses the provided ONNX exporter instance. Args: pytorch_model: PyTorch model to export @@ -105,9 +102,6 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: Returns: Path to exported ONNX file/directory, or None if export failed """ - if self._export_onnx_fn: - return self._export_onnx_fn(pytorch_model, self.data_loader, self.config, self.logger, **kwargs) - # Standard ONNX export using ONNXExporter if not self.config.export_config.should_export_onnx(): return None @@ -119,37 +113,11 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: # Get ONNX settings onnx_settings = self.config.get_onnx_settings() - # Use provided exporter instance if available - if self._onnx_exporter is not None: - exporter = self._onnx_exporter - self.logger.info("=" * 80) - self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") - self.logger.info("=" * 80) - - # Check if it's a CenterPoint exporter (needs special handling) - # CenterPoint exporter has data_loader parameter in export method - import inspect - - sig = inspect.signature(exporter.export) - if "data_loader" in sig.parameters: - # CenterPoint exporter signature - # Save to work_dir/onnx/ directory - output_dir = os.path.join(self.config.export_config.work_dir, "onnx") - os.makedirs(output_dir, exist_ok=True) - if not hasattr(pytorch_model, "_extract_features"): - self.logger.error("❌ ONNX export requires an ONNX-compatible model (CenterPointONNX).") - return None - - success = exporter.export( - model=pytorch_model, data_loader=self.data_loader, output_dir=output_dir, sample_idx=0 - ) - - if success: - self.logger.info(f"✅ ONNX export successful: {output_dir}") - return output_dir - else: - self.logger.error(f"❌ ONNX export failed") - return None + # Use provided exporter (required, cannot be None) + exporter = self._onnx_exporter + self.logger.info("=" * 80) + self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") + self.logger.info("=" * 80) # Standard ONNX export # Save to work_dir/onnx/ directory @@ -162,10 +130,6 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: sample = self.data_loader.load_sample(sample_idx) single_input = self.data_loader.preprocess(sample) - # Ensure tensor is float32 - if single_input.dtype != torch.float32: - single_input = single_input.float() - # Get batch size from configuration batch_size = onnx_settings.get("batch_size", 1) if batch_size is None: @@ -184,11 +148,8 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: input_tensor = single_input.repeat(batch_size, *([1] * (len(single_input.shape) - 1))) self.logger.info(f"Using fixed batch size: {batch_size}") - # Use provided exporter or create default - if self._onnx_exporter is not None: - exporter = self._onnx_exporter - else: - exporter = ONNXExporter(onnx_settings, self.logger) + # Use provided exporter (required, cannot be None) + exporter = self._onnx_exporter success = exporter.export(pytorch_model, input_tensor, output_path) @@ -203,7 +164,7 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: """ Export ONNX model to TensorRT engine. - Uses custom function if provided, otherwise uses standard TensorRTExporter. + Uses the provided TensorRT exporter instance. Args: onnx_path: Path to ONNX model file/directory @@ -212,9 +173,6 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: Returns: Path to exported TensorRT engine file/directory, or None if export failed """ - if self._export_tensorrt_fn: - return self._export_tensorrt_fn(onnx_path, self.config, self.data_loader, self.logger, **kwargs) - # Standard TensorRT export using TensorRTExporter if not self.config.export_config.should_export_tensorrt(): return None @@ -223,46 +181,13 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: self.logger.warning("ONNX path not available, skipping TensorRT export") return None - trt_settings = self.config.get_tensorrt_settings() - - # Use provided exporter instance if available - if self._tensorrt_exporter is not None: - exporter = self._tensorrt_exporter - self.logger.info("=" * 80) - self.logger.info(f"Exporting to TensorRT (Using {type(exporter).__name__})") - self.logger.info("=" * 80) - - # Check if it's a CenterPoint exporter (needs special handling) - # CenterPoint exporter has onnx_dir parameter in export method - import inspect - - sig = inspect.signature(exporter.export) - if "onnx_dir" in sig.parameters: - # CenterPoint exporter signature - if not os.path.isdir(onnx_path): - self.logger.error("CenterPoint requires ONNX directory, not a single file") - return None - - # Save to work_dir/tensorrt/ directory - output_dir = os.path.join(self.config.export_config.work_dir, "tensorrt") - os.makedirs(output_dir, exist_ok=True) - - success = exporter.export( - onnx_dir=onnx_path, output_dir=output_dir, device=self.config.export_config.device - ) - - if success: - self.logger.info(f"✅ TensorRT export successful: {output_dir}") - return output_dir - else: - self.logger.error(f"❌ TensorRT export failed") - return None - - # Standard TensorRT export + # Use provided exporter (required, cannot be None) + exporter = self._tensorrt_exporter self.logger.info("=" * 80) - self.logger.info("Exporting to TensorRT (Using Unified TensorRTExporter)") + self.logger.info(f"Exporting to TensorRT (Using {type(exporter).__name__})") self.logger.info("=" * 80) + # Standard TensorRT export # Save to work_dir/tensorrt/ directory tensorrt_dir = os.path.join(self.config.export_config.work_dir, "tensorrt") os.makedirs(tensorrt_dir, exist_ok=True) @@ -283,22 +208,15 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: sample = self.data_loader.load_sample(sample_idx) sample_input = self.data_loader.preprocess(sample) - # Ensure tensor is float32 if isinstance(sample_input, (list, tuple)): sample_input = sample_input[0] # Use first input for shape - if sample_input.dtype != torch.float32: - sample_input = sample_input.float() - # Merge backend_config.model_inputs into trt_settings for TensorRTExporter - if hasattr(self.config, "backend_config") and hasattr(self.config.backend_config, "model_inputs"): - trt_settings = trt_settings.copy() - trt_settings["model_inputs"] = self.config.backend_config.model_inputs + # Note: trt_settings are read from exporter.config in TensorRTExporter._configure_input_shapes + # The exporter's config should already include model_inputs if backend_config is properly set up + # No need to pass trt_settings here as the exporter reads from self.config - # Use provided exporter or create default - if self._tensorrt_exporter is not None: - exporter = self._tensorrt_exporter - else: - exporter = TensorRTExporter(trt_settings, self.logger) + # Use provided exporter (required, cannot be None) + exporter = self._tensorrt_exporter success = exporter.export( model=None, # Not used for TensorRT @@ -314,10 +232,198 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: self.logger.error(f"❌ TensorRT export failed") return None - # TODO(vivdf): check this, the current design is not clean. + def _resolve_pytorch_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str], bool]: + """ + Resolve PyTorch model path from backend config. + + Args: + backend_cfg: Backend configuration dictionary + + Returns: + Tuple of (model_path, is_valid) + """ + model_path = backend_cfg.get("checkpoint") + if model_path: + is_valid = os.path.exists(model_path) and os.path.isfile(model_path) + else: + is_valid = False + return model_path, is_valid + + def _resolve_onnx_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str], bool]: + """ + Resolve ONNX model path from backend config. + + Args: + backend_cfg: Backend configuration dictionary + + Returns: + Tuple of (model_path, is_valid) + """ + model_path = backend_cfg.get("model_dir") + multi_file = self.config.onnx_config.get("multi_file", False) + save_file = self.config.onnx_config.get("save_file", "model.onnx") + + # If model_dir is explicitly set in config + if model_path is not None: + if os.path.exists(model_path): + if os.path.isfile(model_path): + # Single file ONNX + is_valid = model_path.endswith(".onnx") and not multi_file + elif os.path.isdir(model_path): + # Directory: valid if multi_file is True, or if it contains ONNX files + if multi_file: + is_valid = True + else: + # Single file mode: find the ONNX file in directory + onnx_files = [f for f in os.listdir(model_path) if f.endswith(".onnx")] + if onnx_files: + expected_file = os.path.join(model_path, save_file) + if os.path.exists(expected_file): + model_path = expected_file + else: + model_path = os.path.join(model_path, onnx_files[0]) + is_valid = True + else: + is_valid = False + else: + is_valid = False + else: + is_valid = False + return model_path, is_valid + + # Infer from export config + work_dir = self.config.export_config.work_dir + onnx_dir = os.path.join(work_dir, "onnx") + + if os.path.exists(onnx_dir) and os.path.isdir(onnx_dir): + onnx_files = [f for f in os.listdir(onnx_dir) if f.endswith(".onnx")] + if onnx_files: + if multi_file: + model_path = onnx_dir + is_valid = True + else: + # Single file ONNX: use the save_file if it exists, otherwise use the first ONNX file found + expected_file = os.path.join(onnx_dir, save_file) + if os.path.exists(expected_file): + model_path = expected_file + else: + model_path = os.path.join(onnx_dir, onnx_files[0]) + is_valid = True + else: + if multi_file: + model_path = onnx_dir + is_valid = True + else: + # Try single file path + model_path = os.path.join(onnx_dir, save_file) + is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") + else: + if multi_file: + # Multi-file ONNX: return directory even if it doesn't exist yet + model_path = onnx_dir + is_valid = True + else: + # Fallback: try in work_dir directly (for backward compatibility) + model_path = os.path.join(work_dir, save_file) + is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") + + return model_path, is_valid + + def _resolve_tensorrt_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str], bool]: + """ + Resolve TensorRT model path from backend config. + + Args: + backend_cfg: Backend configuration dictionary + + Returns: + Tuple of (model_path, is_valid) + """ + model_path = backend_cfg.get("engine_dir") + multi_file = self.config.onnx_config.get("multi_file", False) + onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") + expected_engine = onnx_save_file.replace(".onnx", ".engine") + + # If engine_dir is explicitly set in config + if model_path is not None: + if os.path.exists(model_path): + if os.path.isfile(model_path): + # Single file TensorRT + is_valid = (model_path.endswith(".engine") or model_path.endswith(".trt")) and not multi_file + elif os.path.isdir(model_path): + # Directory: valid if multi_file is True, or if it contains engine files + if multi_file: + is_valid = True + else: + # Single file mode: find the engine file in directory + engine_files = [f for f in os.listdir(model_path) if f.endswith(".engine")] + if engine_files: + expected_path = os.path.join(model_path, expected_engine) + if os.path.exists(expected_path): + model_path = expected_path + else: + model_path = os.path.join(model_path, engine_files[0]) + is_valid = True + else: + is_valid = False + else: + is_valid = False + else: + is_valid = False + return model_path, is_valid + + # Infer from export config + work_dir = self.config.export_config.work_dir + engine_dir = os.path.join(work_dir, "tensorrt") + + if os.path.exists(engine_dir) and os.path.isdir(engine_dir): + engine_files = [f for f in os.listdir(engine_dir) if f.endswith(".engine")] + if engine_files: + if multi_file: + model_path = engine_dir + is_valid = True + else: + # Single file TensorRT: use the engine file matching ONNX filename + expected_path = os.path.join(engine_dir, expected_engine) + if os.path.exists(expected_path): + model_path = expected_path + else: + # Fallback: use the first engine file found + model_path = os.path.join(engine_dir, engine_files[0]) + is_valid = True + else: + if multi_file: + model_path = engine_dir + is_valid = True + else: + is_valid = False + else: + if multi_file: + # Multi-file TensorRT: return directory even if it doesn't exist yet + model_path = engine_dir + is_valid = True + else: + # Fallback: try in work_dir directly (for backward compatibility) + if os.path.exists(work_dir) and os.path.isdir(work_dir): + engine_files = [f for f in os.listdir(work_dir) if f.endswith(".engine")] + if engine_files: + expected_path = os.path.join(work_dir, expected_engine) + if os.path.exists(expected_path): + model_path = expected_path + else: + model_path = os.path.join(work_dir, engine_files[0]) + is_valid = True + else: + is_valid = False + else: + is_valid = False + + return model_path, is_valid + def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: """ Get list of models to evaluate from config. + Returns: List of tuples (backend_name, model_path, device) """ @@ -333,164 +439,11 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: is_valid = False if backend_name == "pytorch": - model_path = backend_cfg.get("checkpoint") - if model_path: - is_valid = os.path.exists(model_path) and os.path.isfile(model_path) + model_path, is_valid = self._resolve_pytorch_model(backend_cfg) elif backend_name == "onnx": - model_path = backend_cfg.get("model_dir") - # If model_dir is None, try to infer from export config - if model_path is None: - work_dir = self.config.export_config.work_dir - onnx_dir = os.path.join(work_dir, "onnx") - save_file = self.config.onnx_config.get("save_file", "model.onnx") - multi_file = self.config.onnx_config.get("multi_file", False) # Default to single file - - if os.path.exists(onnx_dir) and os.path.isdir(onnx_dir): - # Check for ONNX files in work_dir/onnx/ directory - onnx_files = [f for f in os.listdir(onnx_dir) if f.endswith(".onnx")] - if onnx_files: - if multi_file: - # Multi-file ONNX: return directory path - model_path = onnx_dir - is_valid = True - else: - # Single file ONNX: use the save_file if it exists, otherwise use the first ONNX file found - expected_file = os.path.join(onnx_dir, save_file) - if os.path.exists(expected_file): - model_path = expected_file - else: - model_path = os.path.join(onnx_dir, onnx_files[0]) - is_valid = True - else: - if multi_file: - # Multi-file ONNX but no files found: still return directory - model_path = onnx_dir - is_valid = True - else: - # Try single file path - model_path = os.path.join(onnx_dir, save_file) - is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") - else: - if multi_file: - # Multi-file ONNX: return directory even if it doesn't exist yet - model_path = onnx_dir - is_valid = True - else: - # Fallback: try in work_dir directly (for backward compatibility) - model_path = os.path.join(work_dir, save_file) - is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") - else: - # model_dir is explicitly set in config - multi_file = self.config.onnx_config.get("multi_file", False) - if os.path.exists(model_path): - if os.path.isfile(model_path): - # Single file ONNX - is_valid = model_path.endswith(".onnx") and not multi_file - elif os.path.isdir(model_path): - # Directory: valid if multi_file is True, or if it contains ONNX files - if multi_file: - is_valid = True - else: - # Single file mode: find the ONNX file in directory - onnx_files = [f for f in os.listdir(model_path) if f.endswith(".onnx")] - if onnx_files: - # Use the save_file if it exists, otherwise use the first ONNX file found - save_file = self.config.onnx_config.get("save_file", "model.onnx") - expected_file = os.path.join(model_path, save_file) - if os.path.exists(expected_file): - model_path = expected_file - else: - model_path = os.path.join(model_path, onnx_files[0]) - is_valid = True - else: - is_valid = False - else: - is_valid = False - else: - is_valid = False + model_path, is_valid = self._resolve_onnx_model(backend_cfg) elif backend_name == "tensorrt": - model_path = backend_cfg.get("engine_dir") - # If engine_dir is None, try to infer from export config - if model_path is None: - work_dir = self.config.export_config.work_dir - engine_dir = os.path.join(work_dir, "tensorrt") - multi_file = self.config.onnx_config.get("multi_file", False) # Use same config as ONNX - - if os.path.exists(engine_dir) and os.path.isdir(engine_dir): - engine_files = [f for f in os.listdir(engine_dir) if f.endswith(".engine")] - if engine_files: - if multi_file: - # Multi-file TensorRT: return directory path - model_path = engine_dir - is_valid = True - else: - # Single file TensorRT: use the engine file matching ONNX filename - onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") - expected_engine = onnx_save_file.replace(".onnx", ".engine") - expected_path = os.path.join(engine_dir, expected_engine) - if os.path.exists(expected_path): - model_path = expected_path - else: - # Fallback: use the first engine file found - model_path = os.path.join(engine_dir, engine_files[0]) - is_valid = True - else: - if multi_file: - # Multi-file TensorRT but no files found: still return directory - model_path = engine_dir - is_valid = True - else: - is_valid = False - else: - if multi_file: - # Multi-file TensorRT: return directory even if it doesn't exist yet - model_path = engine_dir - is_valid = True - else: - # Fallback: try in work_dir directly (for backward compatibility) - if os.path.exists(work_dir) and os.path.isdir(work_dir): - engine_files = [f for f in os.listdir(work_dir) if f.endswith(".engine")] - if engine_files: - onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") - expected_engine = onnx_save_file.replace(".onnx", ".engine") - expected_path = os.path.join(work_dir, expected_engine) - if os.path.exists(expected_path): - model_path = expected_path - else: - model_path = os.path.join(work_dir, engine_files[0]) - is_valid = True - else: - is_valid = False - else: - is_valid = False - else: - # engine_dir is explicitly set in config - multi_file = self.config.onnx_config.get("multi_file", False) - if os.path.exists(model_path): - if os.path.isfile(model_path): - # Single file TensorRT - is_valid = ( - model_path.endswith(".engine") or model_path.endswith(".trt") - ) and not multi_file - elif os.path.isdir(model_path): - # Directory: valid if multi_file is True, or if it contains engine files - if multi_file: - is_valid = True - else: - # Single file mode: find the engine file in directory - engine_files = [f for f in os.listdir(model_path) if f.endswith(".engine")] - if engine_files: - # Try to match ONNX filename, otherwise use the first engine file found - onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") - expected_engine = onnx_save_file.replace(".onnx", ".engine") - expected_path = os.path.join(model_path, expected_engine) - if os.path.exists(expected_path): - model_path = expected_path - else: - model_path = os.path.join(model_path, engine_files[0]) - is_valid = True - else: - is_valid = False + model_path, is_valid = self._resolve_tensorrt_model(backend_cfg) if is_valid and model_path: models_to_evaluate.append((backend_name, model_path, device)) @@ -505,11 +458,13 @@ def run_verification( ) -> Dict[str, Any]: """ Run verification on exported models using policy-based verification. + Args: pytorch_checkpoint: Path to PyTorch checkpoint (reference) onnx_path: Path to ONNX model file/directory tensorrt_path: Path to TensorRT engine file/directory **kwargs: Additional project-specific arguments + Returns: Verification results dictionary """ @@ -527,11 +482,17 @@ def run_verification( self.logger.info(f"No verification scenarios for export mode '{export_mode}', skipping...") return {} - if not pytorch_checkpoint: - self.logger.warning("PyTorch checkpoint path not available, skipping verification") + # Check if any scenario actually needs PyTorch checkpoint + needs_pytorch = any( + policy.get("ref_backend") == "pytorch" or policy.get("test_backend") == "pytorch" for policy in scenarios + ) + + if needs_pytorch and not pytorch_checkpoint: + self.logger.warning( + "PyTorch checkpoint path not available, but required by verification scenarios. Skipping verification." + ) return {} - verification_cfg = self.config.verification_config num_verify_samples = verification_cfg.get("num_verify_samples", 3) tolerance = verification_cfg.get("tolerance", 0.1) devices_map = verification_cfg.get("devices", {}) or {} @@ -568,7 +529,7 @@ def run_verification( self.logger.warning(f"Device alias '{test_device_key}' not found in devices map, using as-is") self.logger.info( - f"\Scenarios {i+1}/{len(scenarios)}: {ref_backend}({ref_device}) vs {test_backend}({test_device})" + f"\nScenarios {i+1}/{len(scenarios)}: {ref_backend}({ref_device}) vs {test_backend}({test_device})" ) # Resolve model paths based on backend @@ -638,8 +599,10 @@ def run_verification( def run_evaluation(self, **kwargs) -> Dict[str, Any]: """ Run evaluation on specified models. + Args: **kwargs: Additional project-specific arguments + Returns: Dictionary containing evaluation results for all backends """ @@ -756,25 +719,32 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] # Check if we need model loading and export eval_config = self.config.evaluation_config verification_cfg = self.config.verification_config - needs_onnx_eval = False - if eval_config.get("enabled", False): - models_to_eval = eval_config.get("models", {}) - if models_to_eval.get("onnx") or models_to_eval.get("tensorrt"): - needs_onnx_eval = True - requires_pytorch_model = False - if should_export_onnx: - requires_pytorch_model = True - elif eval_config.get("enabled", False): + # Determine what we need PyTorch model for + needs_export_onnx = should_export_onnx + + # Check if PyTorch evaluation is needed + needs_pytorch_eval = False + if eval_config.get("enabled", False): models_to_eval = eval_config.get("models", {}) if models_to_eval.get("pytorch"): - requires_pytorch_model = True - elif needs_onnx_eval and eval_config.get("models", {}).get("pytorch"): - requires_pytorch_model = True - elif verification_cfg.get("enabled", False) and should_export_onnx: - requires_pytorch_model = True + needs_pytorch_eval = True + + # Check if PyTorch is needed for verification + needs_pytorch_for_verification = False + if verification_cfg.get("enabled", False): + export_mode = self.config.export_config.mode + scenarios = self.config.get_verification_scenarios(export_mode) + if scenarios: + needs_pytorch_for_verification = any( + policy.get("ref_backend") == "pytorch" or policy.get("test_backend") == "pytorch" + for policy in scenarios + ) + + requires_pytorch_model = needs_export_onnx or needs_pytorch_eval or needs_pytorch_for_verification # Load model if needed for export or ONNX/TensorRT evaluation + # Runner is always responsible for loading model, never reads from evaluator pytorch_model = None if requires_pytorch_model: @@ -788,6 +758,11 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] try: pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) results["pytorch_model"] = pytorch_model + + # Single-direction injection: write model to evaluator via setter (never read from it) + 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()") except Exception as e: self.logger.error(f"Failed to load PyTorch model: {e}") return results @@ -802,6 +777,11 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] try: pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) results["pytorch_model"] = pytorch_model + + # Single-direction injection: write model to evaluator via setter (never read from it) + 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()") except Exception as e: self.logger.error(f"Failed to load PyTorch model: {e}") return results From 2e970c3e11eb515435a15789832a9fd1fe419658 Mon Sep 17 00:00:00 2001 From: vividf Date: Wed, 19 Nov 2025 00:17:07 +0900 Subject: [PATCH 08/62] chore: change deployment directory Signed-off-by: vividf --- autoware_ml/deployment/exporters/__init__.py | 35 --- .../exporters/centerpoint/__init__.py | 11 - .../exporters/centerpoint/model_wrappers.py | 0 .../exporters/centerpoint/onnx_exporter.py | 0 .../centerpoint/tensorrt_exporter.py | 0 autoware_ml/deployment/pipelines/__init__.py | 48 ---- .../deployment/pipelines/base/__init__.py | 18 -- .../pipelines/base/classification_pipeline.py | 152 ----------- .../pipelines/base/detection_2d_pipeline.py | 157 ----------- .../pipelines/base/detection_3d_pipeline.py | 152 ----------- .../pipelines/centerpoint/__init__.py | 44 --- .../pipelines/centerpoint/centerpoint_onnx.py | 0 .../centerpoint/centerpoint_pipeline.py | 0 .../centerpoint/centerpoint_pytorch.py | 0 .../centerpoint/centerpoint_tensorrt.py | 0 autoware_ml/deployment/runners/__init__.py | 13 - .../deployment/runners/centerpoint_runner.py | 0 .../deployment => deployment}/README.md | 126 +++++---- .../deployment => deployment}/__init__.py | 12 +- .../core/__init__.py | 16 +- .../core/base_config.py | 255 ++++++++++-------- .../core/base_data_loader.py | 21 +- .../core/base_evaluator.py | 92 ++++--- .../core/preprocessing_builder.py | 150 +++++------ deployment/exporters/__init__.py | 36 +++ .../exporters/base/base_exporter.py | 36 +-- .../exporters/base/model_wrappers.py | 0 .../exporters/base/onnx_exporter.py | 66 ++--- .../exporters/base/tensorrt_exporter.py | 115 +++----- deployment/pipelines/__init__.py | 48 ++++ deployment/pipelines/base/__init__.py | 18 ++ .../pipelines/base/base_pipeline.py | 88 ++---- deployment/runners/__init__.py | 13 + .../runners/deployment_runner.py | 104 ++++--- 34 files changed, 655 insertions(+), 1171 deletions(-) delete mode 100644 autoware_ml/deployment/exporters/__init__.py delete mode 100644 autoware_ml/deployment/exporters/centerpoint/__init__.py delete mode 100644 autoware_ml/deployment/exporters/centerpoint/model_wrappers.py delete mode 100644 autoware_ml/deployment/exporters/centerpoint/onnx_exporter.py delete mode 100644 autoware_ml/deployment/exporters/centerpoint/tensorrt_exporter.py delete mode 100644 autoware_ml/deployment/pipelines/__init__.py delete mode 100644 autoware_ml/deployment/pipelines/base/__init__.py delete mode 100644 autoware_ml/deployment/pipelines/base/classification_pipeline.py delete mode 100644 autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py delete mode 100644 autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py delete mode 100644 autoware_ml/deployment/pipelines/centerpoint/__init__.py delete mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_onnx.py delete mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_pipeline.py delete mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_pytorch.py delete mode 100644 autoware_ml/deployment/pipelines/centerpoint/centerpoint_tensorrt.py delete mode 100644 autoware_ml/deployment/runners/__init__.py delete mode 100644 autoware_ml/deployment/runners/centerpoint_runner.py rename {autoware_ml/deployment => deployment}/README.md (87%) rename {autoware_ml/deployment => deployment}/__init__.py (52%) rename {autoware_ml/deployment => deployment}/core/__init__.py (56%) rename {autoware_ml/deployment => deployment}/core/base_config.py (59%) rename {autoware_ml/deployment => deployment}/core/base_data_loader.py (78%) rename {autoware_ml/deployment => deployment}/core/base_evaluator.py (67%) rename {autoware_ml/deployment => deployment}/core/preprocessing_builder.py (65%) create mode 100644 deployment/exporters/__init__.py rename {autoware_ml/deployment => deployment}/exporters/base/base_exporter.py (71%) rename {autoware_ml/deployment => deployment}/exporters/base/model_wrappers.py (100%) rename {autoware_ml/deployment => deployment}/exporters/base/onnx_exporter.py (78%) rename {autoware_ml/deployment => deployment}/exporters/base/tensorrt_exporter.py (57%) create mode 100644 deployment/pipelines/__init__.py create mode 100644 deployment/pipelines/base/__init__.py rename {autoware_ml/deployment => deployment}/pipelines/base/base_pipeline.py (74%) create mode 100644 deployment/runners/__init__.py rename {autoware_ml/deployment => deployment}/runners/deployment_runner.py (92%) diff --git a/autoware_ml/deployment/exporters/__init__.py b/autoware_ml/deployment/exporters/__init__.py deleted file mode 100644 index 40be78078..000000000 --- a/autoware_ml/deployment/exporters/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Model exporters for different backends.""" - -from autoware_ml.deployment.exporters.base.base_exporter import BaseExporter -from autoware_ml.deployment.exporters.base.model_wrappers import ( - BaseModelWrapper, - IdentityWrapper, -) -from autoware_ml.deployment.exporters.base.onnx_exporter import ONNXExporter -from autoware_ml.deployment.exporters.base.tensorrt_exporter import TensorRTExporter -from autoware_ml.deployment.exporters.calibration.model_wrappers import CalibrationONNXWrapper -from autoware_ml.deployment.exporters.calibration.onnx_exporter import CalibrationONNXExporter -from autoware_ml.deployment.exporters.calibration.tensorrt_exporter import CalibrationTensorRTExporter -from autoware_ml.deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper -from autoware_ml.deployment.exporters.centerpoint.onnx_exporter import CenterPointONNXExporter -from autoware_ml.deployment.exporters.centerpoint.tensorrt_exporter import CenterPointTensorRTExporter -from autoware_ml.deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper -from autoware_ml.deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter -from autoware_ml.deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter - -__all__ = [ - "BaseExporter", - "ONNXExporter", - "TensorRTExporter", - "CenterPointONNXExporter", - "CenterPointTensorRTExporter", - "CenterPointONNXWrapper", - "YOLOXONNXExporter", - "YOLOXTensorRTExporter", - "YOLOXONNXWrapper", - "CalibrationONNXExporter", - "CalibrationTensorRTExporter", - "CalibrationONNXWrapper", - "BaseModelWrapper", - "IdentityWrapper", -] diff --git a/autoware_ml/deployment/exporters/centerpoint/__init__.py b/autoware_ml/deployment/exporters/centerpoint/__init__.py deleted file mode 100644 index b9c33f70d..000000000 --- a/autoware_ml/deployment/exporters/centerpoint/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""CenterPoint-specific exporters and model wrappers.""" - -from autoware_ml.deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper -from autoware_ml.deployment.exporters.centerpoint.onnx_exporter import CenterPointONNXExporter -from autoware_ml.deployment.exporters.centerpoint.tensorrt_exporter import CenterPointTensorRTExporter - -__all__ = [ - "CenterPointONNXExporter", - "CenterPointTensorRTExporter", - "CenterPointONNXWrapper", -] diff --git a/autoware_ml/deployment/exporters/centerpoint/model_wrappers.py b/autoware_ml/deployment/exporters/centerpoint/model_wrappers.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/exporters/centerpoint/onnx_exporter.py b/autoware_ml/deployment/exporters/centerpoint/onnx_exporter.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/exporters/centerpoint/tensorrt_exporter.py b/autoware_ml/deployment/exporters/centerpoint/tensorrt_exporter.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/__init__.py b/autoware_ml/deployment/pipelines/__init__.py deleted file mode 100644 index 00e73ab0f..000000000 --- a/autoware_ml/deployment/pipelines/__init__.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Deployment Pipelines for Complex Models. - -This module provides pipeline abstractions for models that require -multi-stage processing with mixed PyTorch and optimized backend inference. -""" - -# Calibration pipelines (classification) -from autoware_ml.deployment.pipelines.calibration import ( - CalibrationDeploymentPipeline, - CalibrationONNXPipeline, - CalibrationPyTorchPipeline, - CalibrationTensorRTPipeline, -) - -# CenterPoint pipelines (3D detection) -from autoware_ml.deployment.pipelines.centerpoint import ( - CenterPointDeploymentPipeline, - CenterPointONNXPipeline, - CenterPointPyTorchPipeline, - CenterPointTensorRTPipeline, -) - -# YOLOX pipelines (2D detection) -from autoware_ml.deployment.pipelines.yolox import ( - YOLOXDeploymentPipeline, - YOLOXONNXPipeline, - YOLOXPyTorchPipeline, - YOLOXTensorRTPipeline, -) - -__all__ = [ - # CenterPoint - "CenterPointDeploymentPipeline", - "CenterPointPyTorchPipeline", - "CenterPointONNXPipeline", - "CenterPointTensorRTPipeline", - # YOLOX - "YOLOXDeploymentPipeline", - "YOLOXPyTorchPipeline", - "YOLOXONNXPipeline", - "YOLOXTensorRTPipeline", - # Calibration - "CalibrationDeploymentPipeline", - "CalibrationPyTorchPipeline", - "CalibrationONNXPipeline", - "CalibrationTensorRTPipeline", -] diff --git a/autoware_ml/deployment/pipelines/base/__init__.py b/autoware_ml/deployment/pipelines/base/__init__.py deleted file mode 100644 index ff2f975d1..000000000 --- a/autoware_ml/deployment/pipelines/base/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Base Pipeline Classes for Deployment Framework. - -This module provides the base abstract classes for all deployment pipelines, -including base pipeline, classification, 2D detection, and 3D detection pipelines. -""" - -from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline -from autoware_ml.deployment.pipelines.base.classification_pipeline import ClassificationPipeline -from autoware_ml.deployment.pipelines.base.detection_2d_pipeline import Detection2DPipeline -from autoware_ml.deployment.pipelines.base.detection_3d_pipeline import Detection3DPipeline - -__all__ = [ - "BaseDeploymentPipeline", - "ClassificationPipeline", - "Detection2DPipeline", - "Detection3DPipeline", -] diff --git a/autoware_ml/deployment/pipelines/base/classification_pipeline.py b/autoware_ml/deployment/pipelines/base/classification_pipeline.py deleted file mode 100644 index 5d8791a2b..000000000 --- a/autoware_ml/deployment/pipelines/base/classification_pipeline.py +++ /dev/null @@ -1,152 +0,0 @@ -""" -Classification Pipeline Base Class. - -This module provides the base class for classification pipelines, -implementing common preprocessing and postprocessing for image/point cloud classification. -""" - -import logging -from abc import abstractmethod -from typing import Any, Dict, List, Tuple - -import numpy as np -import torch - -from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline - -logger = logging.getLogger(__name__) - - -class ClassificationPipeline(BaseDeploymentPipeline): - """ - Base class for classification pipelines. - - Provides common functionality for classification tasks including: - - Image/data preprocessing (via data loader) - - Postprocessing (softmax, top-k selection) - - Standard classification output format - - Expected output format: - Dict containing: - { - 'class_id': int, # Predicted class ID - 'class_name': str, # Class name - 'confidence': float, # Confidence score - 'probabilities': np.ndarray, # All class probabilities - 'top_k': List[Dict] # Top-K predictions (optional) - } - """ - - def __init__( - self, - model: Any, - device: str = "cpu", - num_classes: int = 1000, - class_names: List[str] = None, - input_size: Tuple[int, int] = (224, 224), - backend_type: str = "unknown", - ): - """ - Initialize classification pipeline. - - Args: - model: Model object - device: Device for inference - num_classes: Number of classes - class_names: List of class names - input_size: Model input size (height, width) - for reference only - backend_type: Backend type - """ - super().__init__(model, device, task_type="classification", backend_type=backend_type) - - self.num_classes = num_classes - self.class_names = class_names or [f"class_{i}" for i in range(num_classes)] - self.input_size = input_size - - @abstractmethod - def preprocess(self, input_data: Any, **kwargs) -> torch.Tensor: - """ - Preprocess input data for classification. - - This method should be implemented by specific classification pipelines. - Preprocessing should be done by data loader before calling this method. - - Args: - input_data: Preprocessed tensor from data loader or raw input - **kwargs: Additional preprocessing parameters - - Returns: - Preprocessed tensor [1, C, H, W] - """ - pass - - @abstractmethod - def run_model(self, preprocessed_input: torch.Tensor) -> torch.Tensor: - """ - Run classification model (backend-specific). - - Args: - preprocessed_input: Preprocessed tensor [1, C, H, W] - - Returns: - Model output (logits) [1, num_classes] - """ - pass - - def postprocess(self, model_output: torch.Tensor, metadata: Dict = None, top_k: int = 5) -> Dict: - """ - Standard classification postprocessing. - - Steps: - 1. Apply softmax to get probabilities - 2. Get predicted class - 3. Optionally get top-K predictions - - Args: - model_output: Model output (logits) [1, num_classes] - metadata: Additional metadata (unused for classification) - top_k: Number of top predictions to return - - Returns: - Dictionary with classification results - """ - # Convert to numpy if needed - if isinstance(model_output, torch.Tensor): - logits = model_output.cpu().numpy() - else: - logits = model_output - - # Remove batch dimension if present - if logits.ndim == 2: - logits = logits[0] - - # Apply softmax - exp_logits = np.exp(logits - np.max(logits)) # Numerical stability - probabilities = exp_logits / np.sum(exp_logits) - - # Get predicted class - class_id = int(np.argmax(probabilities)) - confidence = float(probabilities[class_id]) - class_name = self.class_names[class_id] if class_id < len(self.class_names) else f"class_{class_id}" - - # Get top-K predictions - top_k_indices = np.argsort(probabilities)[::-1][:top_k] - top_k_predictions = [] - for idx in top_k_indices: - top_k_predictions.append( - { - "class_id": int(idx), - "class_name": self.class_names[idx] if idx < len(self.class_names) else f"class_{idx}", - "confidence": float(probabilities[idx]), - } - ) - - result = { - "class_id": class_id, - "class_name": class_name, - "confidence": confidence, - "probabilities": probabilities, - "top_k": top_k_predictions, - } - - return result diff --git a/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py deleted file mode 100644 index 3df7b27f4..000000000 --- a/autoware_ml/deployment/pipelines/base/detection_2d_pipeline.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -2D Object Detection Pipeline Base Class. - -This module provides the base class for 2D object detection pipelines, -implementing common preprocessing and postprocessing for models like YOLOX, YOLO, etc. -""" - -import logging -from abc import abstractmethod -from typing import Any, Dict, List, Tuple - -import numpy as np -import torch - -from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline - -logger = logging.getLogger(__name__) - - -class Detection2DPipeline(BaseDeploymentPipeline): - """ - Base class for 2D object detection pipelines. - - Provides common functionality for 2D detection tasks including: - - Image preprocessing (resize, normalize, padding) - - Postprocessing (NMS, coordinate transformation) - - Standard detection output format - - Expected output format: - List[Dict] where each dict contains: - { - 'bbox': [x1, y1, x2, y2], # Bounding box coordinates - 'score': float, # Confidence score - 'class_id': int, # Class ID - 'class_name': str # Class name (optional) - } - """ - - def __init__( - self, - model: Any, - device: str = "cpu", - num_classes: int = 80, - class_names: List[str] = None, - input_size: Tuple[int, int] = (640, 640), - backend_type: str = "unknown", - ): - """ - Initialize 2D detection pipeline. - - Args: - model: Model object - device: Device for inference - num_classes: Number of classes - class_names: List of class names - input_size: Model input size (height, width) - backend_type: Backend type - """ - super().__init__(model, device, task_type="detection_2d", backend_type=backend_type) - - self.num_classes = num_classes - self.class_names = class_names or [f"class_{i}" for i in range(num_classes)] - self.input_size = input_size - - @abstractmethod - def preprocess(self, input_data: Any, **kwargs) -> Tuple[torch.Tensor, Dict]: - """ - Preprocess input data for 2D detection. - - This method should be implemented by specific detection pipelines. - For YOLOX, preprocessing is done by MMDetection pipeline before calling this method. - - Args: - input_data: Preprocessed tensor from MMDetection pipeline or raw input - **kwargs: Additional preprocessing parameters - - Returns: - Tuple of (preprocessed_tensor, preprocessing_metadata) - - preprocessed_tensor: [1, C, H, W] - - preprocessing_metadata: Dict with preprocessing information - """ - pass - - @abstractmethod - def run_model(self, preprocessed_input: torch.Tensor) -> Any: - """ - Run detection model (backend-specific). - - Args: - preprocessed_input: Preprocessed tensor [1, C, H, W] - - Returns: - Model output (backend-specific format) - """ - pass - - def postprocess(self, model_output: Any, metadata: Dict = None) -> List[Dict]: - """ - Standard 2D detection postprocessing. - - Steps: - 1. Parse model outputs (boxes, scores, classes) - 2. Apply NMS - 3. Transform coordinates back to original image space - 4. Filter by confidence threshold - - Args: - model_output: Raw model output - metadata: Preprocessing metadata - - Returns: - List of detections in standard format - """ - # This should be overridden by specific detectors (YOLOX, YOLO, etc.) - # as output formats differ - raise NotImplementedError("postprocess() must be implemented by specific detector pipeline") - - def _nms(self, boxes: np.ndarray, scores: np.ndarray, iou_threshold: float = 0.45) -> np.ndarray: - """ - Non-Maximum Suppression. - - Args: - boxes: Bounding boxes [N, 4] - scores: Confidence scores [N] - iou_threshold: IoU threshold for NMS - - Returns: - Indices of boxes to keep - """ - x1 = boxes[:, 0] - y1 = boxes[:, 1] - x2 = boxes[:, 2] - y2 = boxes[:, 3] - - areas = (x2 - x1) * (y2 - y1) - order = scores.argsort()[::-1] - - keep = [] - while order.size > 0: - i = order[0] - keep.append(i) - - xx1 = np.maximum(x1[i], x1[order[1:]]) - yy1 = np.maximum(y1[i], y1[order[1:]]) - xx2 = np.minimum(x2[i], x2[order[1:]]) - yy2 = np.minimum(y2[i], y2[order[1:]]) - - w = np.maximum(0.0, xx2 - xx1) - h = np.maximum(0.0, yy2 - yy1) - inter = w * h - - iou = inter / (areas[i] + areas[order[1:]] - inter) - - inds = np.where(iou <= iou_threshold)[0] - order = order[inds + 1] - - return np.array(keep) diff --git a/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py b/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py deleted file mode 100644 index acf002072..000000000 --- a/autoware_ml/deployment/pipelines/base/detection_3d_pipeline.py +++ /dev/null @@ -1,152 +0,0 @@ -""" -3D Object Detection Pipeline Base Class. - -This module provides the base class for 3D object detection pipelines, -implementing common functionality for point cloud-based detection models like CenterPoint. -""" - -import logging -from abc import abstractmethod -from typing import Any, Dict, List, Tuple - -import numpy as np -import torch - -from autoware_ml.deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline - -logger = logging.getLogger(__name__) - - -class Detection3DPipeline(BaseDeploymentPipeline): - """ - Base class for 3D object detection pipelines. - - Provides common functionality for 3D detection tasks including: - - Point cloud preprocessing (voxelization, normalization) - - Postprocessing (NMS, coordinate transformation) - - Standard 3D detection output format - - Expected output format: - List[Dict] where each dict contains: - { - 'bbox_3d': [x, y, z, w, l, h, yaw], # 3D bounding box - 'score': float, # Confidence score - 'label': int, # Class label - 'class_name': str # Class name (optional) - } - """ - - def __init__( - self, - model: Any, - device: str = "cpu", - num_classes: int = 10, - class_names: List[str] = None, - point_cloud_range: List[float] = None, - voxel_size: List[float] = None, - backend_type: str = "unknown", - ): - """ - Initialize 3D detection pipeline. - - Args: - model: Model object - device: Device for inference - num_classes: Number of classes - class_names: List of class names - point_cloud_range: Point cloud range [x_min, y_min, z_min, x_max, y_max, z_max] - voxel_size: Voxel size [vx, vy, vz] - backend_type: Backend type - """ - super().__init__(model, device, task_type="detection_3d", backend_type=backend_type) - - self.num_classes = num_classes - self.class_names = class_names or [f"class_{i}" for i in range(num_classes)] - self.point_cloud_range = point_cloud_range - self.voxel_size = voxel_size - - def preprocess(self, points: torch.Tensor, **kwargs) -> Dict[str, torch.Tensor]: - """ - Standard 3D detection preprocessing. - - Note: For 3D detection, preprocessing is often model-specific - (voxelization, pillar generation, etc.), so this method should - be overridden by specific implementations. - - Args: - points: Input point cloud [N, point_features] - **kwargs: Additional preprocessing parameters - - Returns: - Dictionary containing preprocessed data - """ - raise NotImplementedError( - "preprocess() must be implemented by specific 3D detector pipeline.\n" - "3D detection preprocessing varies significantly between models." - ) - - def run_model(self, preprocessed_input: Any) -> Any: - """ - Run 3D detection model (backend-specific). - - **Note**: This method is intentionally not abstract for 3D detection pipelines. - - Most 3D detection models use a **multi-stage inference pipeline** rather than - a single model call: - - ``` - Points → Voxel Encoder → Middle Encoder → Backbone/Head → Postprocess - ``` - - For 3D detection pipelines - - *Implement `run_model()` (Recommended)* - - Implement all stages in `run_model()`: - - `run_voxel_encoder()` - backend-specific voxel encoding - - `process_middle_encoder()` - sparse convolution (usually PyTorch-only) - - `run_backbone_head()` - backend-specific backbone/head inference - - Return final head outputs - - Use base class `infer()` for unified pipeline orchestration - - - Args: - preprocessed_input: Preprocessed data (usually Dict from preprocess()) - - Returns: - Model output (backend-specific format, usually List[torch.Tensor] for head outputs) - - Raises: - NotImplementedError: Default implementation raises error. - Subclasses should implement `run_model()` with all stages. - - Example: - See `CenterPointDeploymentPipeline.run_model()` for a complete multi-stage - implementation example. - """ - raise NotImplementedError( - "run_model() must be implemented by 3D detection pipelines. " - "3D detection typically uses a multi-stage inference pipeline " - "(voxel encoder → middle encoder → backbone/head). " - "Please implement run_model() with all stages. " - "See CenterPointDeploymentPipeline.run_model() for an example implementation." - ) - - def postprocess(self, model_output: Any, metadata: Dict = None) -> List[Dict]: - """ - Standard 3D detection postprocessing. - - Note: For 3D detection, postprocessing is often model-specific - (CenterPoint uses predict_by_feat, PointPillars uses different logic), - so this method should be overridden by specific implementations. - - Args: - model_output: Raw model output - metadata: Preprocessing metadata - - Returns: - List of 3D detections in standard format - """ - raise NotImplementedError( - "postprocess() must be implemented by specific 3D detector pipeline.\n" - "3D detection postprocessing varies significantly between models." - ) diff --git a/autoware_ml/deployment/pipelines/centerpoint/__init__.py b/autoware_ml/deployment/pipelines/centerpoint/__init__.py deleted file mode 100644 index 37890aa0e..000000000 --- a/autoware_ml/deployment/pipelines/centerpoint/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -CenterPoint Deployment Pipelines. - -This module provides unified deployment pipelines for CenterPoint 3D object detection -across different backends (PyTorch, ONNX, TensorRT). - -Example usage: - -PyTorch: - >>> from autoware_ml.deployment.pipelines.centerpoint import CenterPointPyTorchPipeline - >>> pipeline = CenterPointPyTorchPipeline(model, device='cuda') - >>> predictions, latency, breakdown = pipeline.infer(points) - -ONNX: - >>> from autoware_ml.deployment.pipelines.centerpoint import CenterPointONNXPipeline - >>> pipeline = CenterPointONNXPipeline(pytorch_model, onnx_dir='models', device='cuda') - >>> predictions, latency, breakdown = pipeline.infer(points) - -TensorRT: - >>> from autoware_ml.deployment.pipelines.centerpoint import CenterPointTensorRTPipeline - >>> pipeline = CenterPointTensorRTPipeline(pytorch_model, tensorrt_dir='engines', device='cuda') - >>> predictions, latency, breakdown = pipeline.infer(points) - -Note: - All pipelines now use the unified `infer()` interface from the base class. - The `breakdown` dict contains stage-wise latencies: - - preprocessing_ms - - voxel_encoder_ms - - middle_encoder_ms - - backbone_head_ms - - postprocessing_ms -""" - -from autoware_ml.deployment.pipelines.centerpoint.centerpoint_onnx import CenterPointONNXPipeline -from autoware_ml.deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline -from autoware_ml.deployment.pipelines.centerpoint.centerpoint_pytorch import CenterPointPyTorchPipeline -from autoware_ml.deployment.pipelines.centerpoint.centerpoint_tensorrt import CenterPointTensorRTPipeline - -__all__ = [ - "CenterPointDeploymentPipeline", - "CenterPointPyTorchPipeline", - "CenterPointONNXPipeline", - "CenterPointTensorRTPipeline", -] diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_onnx.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_onnx.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pipeline.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pipeline.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pytorch.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_pytorch.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/pipelines/centerpoint/centerpoint_tensorrt.py b/autoware_ml/deployment/pipelines/centerpoint/centerpoint_tensorrt.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/runners/__init__.py b/autoware_ml/deployment/runners/__init__.py deleted file mode 100644 index 5bc1507f8..000000000 --- a/autoware_ml/deployment/runners/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Deployment runners for unified deployment workflow.""" - -from autoware_ml.deployment.runners.calibration_runner import CalibrationDeploymentRunner -from autoware_ml.deployment.runners.centerpoint_runner import CenterPointDeploymentRunner -from autoware_ml.deployment.runners.deployment_runner import BaseDeploymentRunner -from autoware_ml.deployment.runners.yolox_runner import YOLOXDeploymentRunner - -__all__ = [ - "BaseDeploymentRunner", - "CenterPointDeploymentRunner", - "YOLOXDeploymentRunner", - "CalibrationDeploymentRunner", -] diff --git a/autoware_ml/deployment/runners/centerpoint_runner.py b/autoware_ml/deployment/runners/centerpoint_runner.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/autoware_ml/deployment/README.md b/deployment/README.md similarity index 87% rename from autoware_ml/deployment/README.md rename to deployment/README.md index 5e0753f77..43a97b9bc 100644 --- a/autoware_ml/deployment/README.md +++ b/deployment/README.md @@ -22,7 +22,7 @@ The AWML Deployment Framework provides a standardized approach to model deployme ### Design Principles -1. **Unified Interface**: Single entry point (`DeploymentRunner`) for all deployment tasks +1. **Unified Interface**: Shared base runner (`BaseDeploymentRunner`) with project-specific subclasses 2. **Task-Agnostic Core**: Base classes that work across detection, classification, and segmentation 3. **Backend Flexibility**: Support for PyTorch, ONNX, and TensorRT backends 4. **Pipeline Architecture**: Shared preprocessing/postprocessing with backend-specific inference @@ -43,9 +43,9 @@ The AWML Deployment Framework provides a standardized approach to model deployme └──────────────────┬──────────────────────────────────────┘ │ ┌──────────────────▼──────────────────────────────────────┐ -│ DeploymentRunner (Unified Runner) │ +│ BaseDeploymentRunner + Project Runners │ │ - Coordinates export → verification → evaluation │ -│ - Manages model loading, export, verification │ +│ - Each project extends the base class for custom logic│ └──────────────────┬──────────────────────────────────────┘ │ ┌──────────┴──────────┐ @@ -71,20 +71,19 @@ The AWML Deployment Framework provides a standardized approach to model deployme ### Core Components -#### 1. **DeploymentRunner** -The unified runner that orchestrates the complete deployment workflow: +#### 1. **BaseDeploymentRunner & Project Runners** +`BaseDeploymentRunner` orchestrates the complete deployment workflow, while each project provides a thin subclass (`CenterPointDeploymentRunner`, `YOLOXDeploymentRunner`, `CalibrationDeploymentRunner`) that plugs in model-specific logic. -- **Model Loading**: Loads PyTorch models from checkpoints -- **Export**: Exports to ONNX and/or TensorRT using project-specific exporters +- **Model Loading**: Implemented by each project runner to load PyTorch checkpoints +- **Export**: Uses injected ONNX/TensorRT exporters that encapsulate wrapper logic - **Verification**: Scenario-based verification across backends - **Evaluation**: Performance metrics and latency statistics **Required Parameters:** - `onnx_exporter`: Project-specific ONNX exporter instance (e.g., `YOLOXONNXExporter`, `CenterPointONNXExporter`) - `tensorrt_exporter`: Project-specific TensorRT exporter instance (e.g., `YOLOXTensorRTExporter`, `CenterPointTensorRTExporter`) -- `model_wrapper`: Project-specific model wrapper class (e.g., `YOLOXONNXWrapper`, `CenterPointONNXWrapper`) -All exporters and wrappers are explicitly created and passed to the runner, following the dependency injection pattern. +Exporters receive their corresponding `model_wrapper` during construction. Runners never implicitly create exporters/wrappers—everything is injected for clarity and testability. #### 2. **Base Classes** @@ -108,11 +107,11 @@ All exporters and wrappers are explicitly created and passed to the runner, foll - **`ONNXExporter`**: Standard ONNX export with model wrapping support - **`TensorRTExporter`**: TensorRT engine building with precision policies - **`BaseModelWrapper`**: Abstract base class for model wrappers - - **`IdentityWrapper`**: Default wrapper that doesn't modify model output + - **`IdentityWrapper`**: Provided wrapper that doesn't modify model output - **Project-Specific Exporters**: - **YOLOX** (`exporters/yolox/`): - - **`YOLOXONNXExporter`**: Inherits base ONNX exporter (uses `YOLOXONNXWrapper`) + - **`YOLOXONNXExporter`**: Inherits base ONNX exporter (requires `YOLOXONNXWrapper`) - **`YOLOXTensorRTExporter`**: Inherits base TensorRT exporter - **`YOLOXONNXWrapper`**: Transforms YOLOX output to Tier4-compatible format - **CenterPoint** (`exporters/centerpoint/`): @@ -120,7 +119,7 @@ All exporters and wrappers are explicitly created and passed to the runner, foll - **`CenterPointTensorRTExporter`**: Extends base exporter for multi-file TensorRT export - **`CenterPointONNXWrapper`**: Identity wrapper (no transformation needed) - **Calibration** (`exporters/calibration/`): - - **`CalibrationONNXExporter`**: Inherits base ONNX exporter (uses `IdentityWrapper`) + - **`CalibrationONNXExporter`**: Inherits base ONNX exporter (requires `IdentityWrapper`) - **`CalibrationTensorRTExporter`**: Inherits base TensorRT exporter - **`CalibrationONNXWrapper`**: Identity wrapper (no transformation needed) @@ -230,42 +229,48 @@ python projects/CalibrationStatusClassification/deploy/main.py \ checkpoint.pth ``` -### Creating DeploymentRunner +### Creating a Project Runner -All projects follow the dependency injection pattern, explicitly creating exporters and wrappers: +All projects follow the dependency injection pattern: explicitly create exporters (with their wrappers) and pass them to a project-specific runner subclass of `BaseDeploymentRunner`. Example (YOLOX): ```python -from autoware_ml.deployment.runners import DeploymentRunner -from autoware_ml.deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter -from autoware_ml.deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter -from autoware_ml.deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper +from deployment.runners import YOLOXDeploymentRunner +from deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter +from deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter +from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper # Create project-specific exporters onnx_settings = config.get_onnx_settings() trt_settings = config.get_tensorrt_settings() -onnx_exporter = YOLOXONNXExporter(onnx_settings, logger) -tensorrt_exporter = YOLOXTensorRTExporter(trt_settings, logger) +onnx_exporter = YOLOXONNXExporter( + onnx_settings, + model_wrapper=YOLOXONNXWrapper, + logger=logger, +) +tensorrt_exporter = YOLOXTensorRTExporter( + trt_settings, + model_wrapper=YOLOXONNXWrapper, + logger=logger, +) -# Create runner with required exporters and wrapper -runner = DeploymentRunner( +# Instantiate the project runner +runner = YOLOXDeploymentRunner( data_loader=data_loader, evaluator=evaluator, config=config, model_cfg=model_cfg, logger=logger, - load_model_fn=load_pytorch_model, onnx_exporter=onnx_exporter, # Required tensorrt_exporter=tensorrt_exporter, # Required - model_wrapper=YOLOXONNXWrapper, # Required ) ``` **Key Points:** -- All exporters and wrappers must be explicitly created -- `onnx_exporter`, `tensorrt_exporter`, and `model_wrapper` are **required** (cannot be None) -- Each project uses its own specific exporter and wrapper classes -- This ensures clear dependencies and better type safety +- Exporters (and their wrappers) must be explicitly created in the entry point +- `onnx_exporter` and `tensorrt_exporter` are **required** arguments for every runner +- Each project uses its own specific exporter, wrapper, data loader, evaluator, and runner class +- This explicit wiring keeps dependencies clear and improves testability ### Command-Line Arguments @@ -422,10 +427,10 @@ See project-specific configs: **Key Files:** - `projects/CenterPoint/deploy/main.py` - `projects/CenterPoint/deploy/evaluator.py` -- `autoware_ml/deployment/pipelines/centerpoint/` -- `autoware_ml/deployment/exporters/centerpoint/onnx_exporter.py` -- `autoware_ml/deployment/exporters/centerpoint/tensorrt_exporter.py` -- `autoware_ml/deployment/exporters/centerpoint/model_wrappers.py` +- `deployment/pipelines/centerpoint/` +- `deployment/exporters/centerpoint/onnx_exporter.py` +- `deployment/exporters/centerpoint/tensorrt_exporter.py` +- `deployment/exporters/centerpoint/model_wrappers.py` **Pipeline Structure:** ``` @@ -441,17 +446,17 @@ run_backbone_head() → postprocess() - ReLU6 → ReLU replacement for ONNX compatibility **Exporter and Wrapper:** -- `YOLOXONNXExporter`: Inherits base ONNX exporter, uses `YOLOXONNXWrapper` by default +- `YOLOXONNXExporter`: Inherits base ONNX exporter and requires explicit `YOLOXONNXWrapper` - `YOLOXTensorRTExporter`: Inherits base TensorRT exporter - `YOLOXONNXWrapper`: Transforms output from `(1, 8, 120, 120)` to `(1, 18900, 13)` format **Key Files:** - `projects/YOLOX_opt_elan/deploy/main.py` - `projects/YOLOX_opt_elan/deploy/evaluator.py` -- `autoware_ml/deployment/pipelines/yolox/` -- `autoware_ml/deployment/exporters/yolox/onnx_exporter.py` -- `autoware_ml/deployment/exporters/yolox/tensorrt_exporter.py` -- `autoware_ml/deployment/exporters/yolox/model_wrappers.py` +- `deployment/pipelines/yolox/` +- `deployment/exporters/yolox/onnx_exporter.py` +- `deployment/exporters/yolox/tensorrt_exporter.py` +- `deployment/exporters/yolox/model_wrappers.py` **Pipeline Structure:** ``` @@ -473,10 +478,10 @@ preprocess() → run_model() → postprocess() **Key Files:** - `projects/CalibrationStatusClassification/deploy/main.py` - `projects/CalibrationStatusClassification/deploy/evaluator.py` -- `autoware_ml/deployment/pipelines/calibration/` -- `autoware_ml/deployment/exporters/calibration/onnx_exporter.py` -- `autoware_ml/deployment/exporters/calibration/tensorrt_exporter.py` -- `autoware_ml/deployment/exporters/calibration/model_wrappers.py` +- `deployment/pipelines/calibration/` +- `deployment/exporters/calibration/onnx_exporter.py` +- `deployment/exporters/calibration/tensorrt_exporter.py` +- `deployment/exporters/calibration/model_wrappers.py` **Pipeline Structure:** ``` @@ -649,7 +654,7 @@ evaluation = dict( ## File Structure ``` -autoware_ml/deployment/ +deployment/ ├── core/ # Core base classes │ ├── base_config.py # Configuration management │ ├── base_data_loader.py # Data loader interface @@ -698,7 +703,10 @@ autoware_ml/deployment/ │ └── calibration_tensorrt.py │ └── runners/ # Deployment runners - └── deployment_runner.py # Unified deployment runner + ├── deployment_runner.py # BaseDeploymentRunner + ├── centerpoint_runner.py # CenterPointDeploymentRunner + ├── yolox_runner.py # YOLOXDeploymentRunner + └── calibration_runner.py # CalibrationDeploymentRunner projects/ ├── CenterPoint/deploy/ @@ -736,7 +744,7 @@ projects/ ### 2. Model Export - Always explicitly create project-specific exporters in `main.py` -- Always provide required `model_wrapper` parameter to `DeploymentRunner` +- Always provide required `model_wrapper` parameter when constructing exporters - Use project-specific wrapper classes (e.g., `YOLOXONNXWrapper`, `CenterPointONNXWrapper`) - Follow the unified architecture pattern: each model has `onnx_exporter.py`, `tensorrt_exporter.py`, and `model_wrappers.py` - Simple models: inherit base exporters, use custom wrappers if needed @@ -759,7 +767,7 @@ exporters/{model}/ **Pattern 1: Simple Models** (YOLOX, Calibration) - Inherit base exporters (no special logic needed) - Use custom wrappers if output format transformation is required -- Example: `YOLOXONNXExporter` inherits `ONNXExporter`, uses `YOLOXONNXWrapper` +- Example: `YOLOXONNXExporter` inherits `ONNXExporter`, requires `YOLOXONNXWrapper` **Pattern 2: Complex Models** (CenterPoint) - Extend base exporters for special requirements (e.g., multi-file export) @@ -771,21 +779,29 @@ exporters/{model}/ All projects should follow this pattern: ```python -# 1. Import project-specific exporters and wrappers -from autoware_ml.deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter -from autoware_ml.deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter -from autoware_ml.deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper +# 1. Import project-specific exporters, wrappers, and runner +from deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter +from deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter +from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper +from deployment.runners import YOLOXDeploymentRunner # 2. Create exporters with settings -onnx_exporter = YOLOXONNXExporter(onnx_settings, logger) -tensorrt_exporter = YOLOXTensorRTExporter(trt_settings, logger) +onnx_exporter = YOLOXONNXExporter( + onnx_settings, + model_wrapper=YOLOXONNXWrapper, + logger=logger, +) +tensorrt_exporter = YOLOXTensorRTExporter( + trt_settings, + model_wrapper=YOLOXONNXWrapper, + logger=logger, +) -# 3. Pass to DeploymentRunner (all required) -runner = DeploymentRunner( +# 3. Pass exporters to the project runner (all required) +runner = YOLOXDeploymentRunner( ..., onnx_exporter=onnx_exporter, # Required tensorrt_exporter=tensorrt_exporter, # Required - model_wrapper=YOLOXONNXWrapper, # Required ) ``` @@ -878,7 +894,7 @@ When adding a new project: 5. **Add entry point script** - Create `projects/{project}/deploy/main.py` - Follow dependency injection pattern: explicitly create exporters and wrappers - - Pass all required parameters to `DeploymentRunner` + - Pass exporters to the appropriate project runner (inherits `BaseDeploymentRunner`) 6. **Update documentation** - Add project to README's "Project-Specific Implementations" section diff --git a/autoware_ml/deployment/__init__.py b/deployment/__init__.py similarity index 52% rename from autoware_ml/deployment/__init__.py rename to deployment/__init__.py index 1af010f25..99a205797 100644 --- a/autoware_ml/deployment/__init__.py +++ b/deployment/__init__.py @@ -4,14 +4,14 @@ 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, TorchScript, etc.). +TensorRT). """ -from autoware_ml.deployment.core.base_config import BaseDeploymentConfig -from autoware_ml.deployment.core.base_data_loader import BaseDataLoader -from autoware_ml.deployment.core.base_evaluator import BaseEvaluator -from autoware_ml.deployment.core.preprocessing_builder import build_preprocessing_pipeline -from autoware_ml.deployment.runners import BaseDeploymentRunner +from deployment.core.base_config import BaseDeploymentConfig +from deployment.core.base_data_loader import BaseDataLoader +from deployment.core.base_evaluator import BaseEvaluator +from deployment.core.preprocessing_builder import build_preprocessing_pipeline +from deployment.runners import BaseDeploymentRunner __all__ = [ "BaseDeploymentConfig", diff --git a/autoware_ml/deployment/core/__init__.py b/deployment/core/__init__.py similarity index 56% rename from autoware_ml/deployment/core/__init__.py rename to deployment/core/__init__.py index 4b59c5739..5ca835b19 100644 --- a/autoware_ml/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -1,6 +1,6 @@ """Core components for deployment framework.""" -from autoware_ml.deployment.core.base_config import ( +from deployment.core.base_config import ( BackendConfig, BaseDeploymentConfig, ExportConfig, @@ -8,9 +8,14 @@ parse_base_args, setup_logging, ) -from autoware_ml.deployment.core.base_data_loader import BaseDataLoader -from autoware_ml.deployment.core.base_evaluator import BaseEvaluator -from autoware_ml.deployment.core.preprocessing_builder import ( +from deployment.core.base_data_loader import BaseDataLoader +from deployment.core.base_evaluator import ( + BaseEvaluator, + EvalResultDict, + ModelSpec, + VerifyResultDict, +) +from deployment.core.preprocessing_builder import ( build_preprocessing_pipeline, ) @@ -23,5 +28,8 @@ "parse_base_args", "BaseDataLoader", "BaseEvaluator", + "EvalResultDict", + "VerifyResultDict", + "ModelSpec", "build_preprocessing_pipeline", ] diff --git a/autoware_ml/deployment/core/base_config.py b/deployment/core/base_config.py similarity index 59% rename from autoware_ml/deployment/core/base_config.py rename to deployment/core/base_config.py index 17d170220..77aa991ef 100644 --- a/autoware_ml/deployment/core/base_config.py +++ b/deployment/core/base_config.py @@ -7,36 +7,60 @@ import argparse import logging +from dataclasses import dataclass, field +from enum import Enum from typing import Any, Dict, List, Optional +import torch from mmengine.config import Config # Constants -DEFAULT_VERIFICATION_TOLERANCE = 1e-3 DEFAULT_WORKSPACE_SIZE = 1 << 30 # 1 GB + +class PrecisionPolicy(str, Enum): + """Precision policy options for TensorRT.""" + + AUTO = "auto" + FP16 = "fp16" + FP32_TF32 = "fp32_tf32" + EXPLICIT_INT8 = "explicit_int8" + STRONGLY_TYPED = "strongly_typed" + + # Precision policy mapping for TensorRT PRECISION_POLICIES = { - "auto": {}, # No special flags, TensorRT decides - "fp16": {"FP16": True}, - "fp32_tf32": {"TF32": True}, # TF32 for FP32 operations - "explicit_int8": {"INT8": True}, - "strongly_typed": {"STRONGLY_TYPED": True}, # Network creation flag + PrecisionPolicy.AUTO.value: {}, # No special flags, TensorRT decides + PrecisionPolicy.FP16.value: {"FP16": True}, + PrecisionPolicy.FP32_TF32.value: {"TF32": True}, # TF32 for FP32 operations + PrecisionPolicy.EXPLICIT_INT8.value: {"INT8": True}, + PrecisionPolicy.STRONGLY_TYPED.value: {"STRONGLY_TYPED": True}, # Network creation flag } +@dataclass class ExportConfig: """Configuration for model export settings.""" - def __init__(self, config_dict: Dict[str, Any]): - self.mode = config_dict.get("mode", "both") - # Note: verify has been moved to verification.enabled in v2 config format - # Device is optional in v2 format (devices are specified per-backend in evaluation/verification) - # Default to cuda:0 for backward compatibility - self.device = config_dict.get("device", "cuda:0") - self.work_dir = config_dict.get("work_dir", "work_dirs") - self.checkpoint_path = config_dict.get("checkpoint_path") - self.onnx_path = config_dict.get("onnx_path") + mode: str = "both" + work_dir: str = "work_dirs" + checkpoint_path: Optional[str] = None + onnx_path: Optional[str] = None + cuda_device: str = "cuda:0" + + def __post_init__(self) -> None: + self.cuda_device = self._parse_cuda_device(self.cuda_device) + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": + """Create ExportConfig from dict.""" + return cls( + mode=config_dict.get("mode", cls.mode), + work_dir=config_dict.get("work_dir", cls.work_dir), + checkpoint_path=config_dict.get("checkpoint_path"), + onnx_path=config_dict.get("onnx_path"), + cuda_device=config_dict.get("cuda_device", cls.cuda_device), + ) def should_export_onnx(self) -> bool: """Check if ONNX export is requested.""" @@ -46,32 +70,82 @@ def should_export_tensorrt(self) -> bool: """Check if TensorRT export is requested.""" return self.mode in ["trt", "both"] + @staticmethod + def _parse_cuda_device(device: Optional[str]) -> str: + """Parse and normalize CUDA device string to 'cuda:N' format.""" + if device is None: + return "cuda:0" + + if not isinstance(device, str): + raise ValueError("cuda_device must be a string (e.g., 'cuda:0')") + + normalized = device.strip().lower() + if normalized == "": + normalized = "cuda:0" + + if normalized == "cuda": + normalized = "cuda:0" + + if not normalized.startswith("cuda"): + raise ValueError(f"Invalid cuda_device '{device}'. Must start with 'cuda'") + + if ":" in normalized: + suffix = normalized.split(":", 1)[1] + suffix = suffix.strip() + if suffix == "": + suffix = "0" + if not suffix.isdigit(): + raise ValueError(f"Invalid CUDA device index in '{device}'") + device_id = int(suffix) + else: + device_id = 0 + if device_id < 0: + raise ValueError("CUDA device index must be non-negative") + + return f"cuda:{device_id}" + + def get_cuda_device_index(self) -> int: + """Return CUDA device index as integer.""" + return int(self.cuda_device.split(":", 1)[1]) + + +@dataclass class RuntimeConfig: """Configuration for runtime I/O settings.""" - def __init__(self, config_dict: Dict[str, Any]): - self._config = config_dict + data: Dict[str, Any] + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> "RuntimeConfig": + return cls(config_dict) def get(self, key: str, default: Any = None) -> Any: """Get a runtime configuration value.""" - return self._config.get(key, default) + return self.data.get(key, default) def __getitem__(self, key: str) -> Any: """Dictionary-style access to runtime config.""" - return self._config[key] + return self.data[key] +@dataclass class BackendConfig: """Configuration for backend-specific settings.""" - def __init__(self, config_dict: Dict[str, Any]): - self.common_config = config_dict.get("common_config", {}) - self.model_inputs = config_dict.get("model_inputs", []) + common_config: Dict[str, Any] = field(default_factory=dict) + model_inputs: List[Dict[str, Any]] = field(default_factory=list) + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> "BackendConfig": + return cls( + common_config=config_dict.get("common_config", {}), + model_inputs=config_dict.get("model_inputs", []), + ) def get_precision_policy(self) -> str: """Get precision policy name.""" - return self.common_config.get("precision_policy", "auto") + return self.common_config.get("precision_policy", PrecisionPolicy.AUTO.value) def get_precision_flags(self) -> Dict[str, bool]: """Get TensorRT precision flags for the configured policy.""" @@ -102,9 +176,11 @@ def __init__(self, deploy_cfg: Config): self._validate_config() # Initialize config sections - self.export_config = ExportConfig(deploy_cfg.get("export", {})) - self.runtime_config = RuntimeConfig(deploy_cfg.get("runtime_io", {})) - self.backend_config = BackendConfig(deploy_cfg.get("backend_config", {})) + 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._validate_cuda_device() def _validate_config(self) -> None: """Validate configuration structure and required fields.""" @@ -123,12 +199,52 @@ def _validate_config(self) -> None: # 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", "auto") + 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.export_config.cuda_device + device_idx = self.export_config.get_cuda_device_index() + + 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.deploy_cfg.get("evaluation", {}) + backends_cfg = evaluation_cfg.get("backends", {}) + tensorrt_backend = backends_cfg.get("tensorrt", {}) + if tensorrt_backend.get("enabled", False): + return True + + verification_cfg = self.deploy_cfg.get("verification", {}) + scenarios_cfg = verification_cfg.get("scenarios", {}) + for scenario_list in scenarios_cfg.values(): + for scenario in scenario_list: + if scenario.get("ref_backend") == "tensorrt" or scenario.get("test_backend") == "tensorrt": + return True + + return False + @property def evaluation_config(self) -> Dict: """Get evaluation configuration.""" @@ -240,80 +356,6 @@ def get_tensorrt_settings(self) -> Dict[str, Any]: "model_inputs": self.backend_config.model_inputs, } - def update_batch_size(self, batch_size: int) -> None: - """ - Update batch size in backend config model_inputs. - - Args: - batch_size: New batch size to set - """ - if batch_size is not None: - # Check if model_inputs already has TensorRT-specific configuration - existing_model_inputs = self.backend_config.model_inputs - - # If model_inputs is None or empty, generate from model_io - if existing_model_inputs is None or len(existing_model_inputs) == 0: - # Get model_io configuration - model_io = self.deploy_cfg.get("model_io", {}) - input_name = model_io.get("input_name", "input") - input_shape = model_io.get("input_shape", (3, 960, 960)) - input_dtype = model_io.get("input_dtype", "float32") - - # Create model_inputs list - model_inputs = [] - - # Add primary input - full_shape = (batch_size,) + input_shape - model_inputs.append( - dict( - name=input_name, - shape=full_shape, - dtype=input_dtype, - ) - ) - - # Add additional inputs if specified - additional_inputs = model_io.get("additional_inputs", []) - for additional_input in additional_inputs: - if isinstance(additional_input, dict): - add_name = additional_input.get("name", "input") - add_shape = additional_input.get("shape", (-1,)) - add_dtype = additional_input.get("dtype", "float32") - - # Handle dynamic shapes (e.g., (-1,) for variable length) - if isinstance(add_shape, tuple) and len(add_shape) > 0 and add_shape[0] == -1: - # Keep dynamic shape for variable length inputs - full_add_shape = add_shape - else: - # Add batch dimension for fixed shapes - full_add_shape = (batch_size,) + add_shape - - model_inputs.append( - dict( - name=add_name, - shape=full_add_shape, - dtype=add_dtype, - ) - ) - - # Update model_inputs in backend config - self.backend_config.model_inputs = model_inputs - else: - # If model_inputs already exists (e.g., TensorRT shape ranges), - # update batch size in existing shapes if they are simple shapes - for model_input in existing_model_inputs: - if isinstance(model_input, dict) and "shape" in model_input: - # Simple shape format: {"name": "input", "shape": (batch, ...), "dtype": "float32"} - if isinstance(model_input["shape"], tuple) and len(model_input["shape"]) > 0: - # Update batch dimension (first dimension) - shape = list(model_input["shape"]) - shape[0] = batch_size - model_input["shape"] = tuple(shape) - elif isinstance(model_input, dict) and "input_shapes" in model_input: - # TensorRT shape ranges format: {"input_shapes": {"input": {"min_shape": [...], ...}}} - # For TensorRT shape ranges, we don't modify batch size as it's handled by dynamic_axes - pass - def setup_logging(level: str = "INFO") -> logging.Logger: """ @@ -347,13 +389,12 @@ def parse_base_args(parser: Optional[argparse.ArgumentParser] = None) -> argpars parser.add_argument("deploy_cfg", help="Deploy config path") parser.add_argument("model_cfg", help="Model config path") + # Optional overrides parser.add_argument( - "checkpoint", nargs="?", default=None, help="Model checkpoint path (optional when mode='none')" + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Logging level", ) - # Optional overrides - parser.add_argument("--work-dir", help="Override output directory from config") - parser.add_argument("--device", help="Override device from config") - parser.add_argument("--log-level", default="INFO", choices=list(logging._nameToLevel.keys()), help="Logging level") - return parser diff --git a/autoware_ml/deployment/core/base_data_loader.py b/deployment/core/base_data_loader.py similarity index 78% rename from autoware_ml/deployment/core/base_data_loader.py rename to deployment/core/base_data_loader.py index 86206de0f..80713c597 100644 --- a/autoware_ml/deployment/core/base_data_loader.py +++ b/deployment/core/base_data_loader.py @@ -6,11 +6,26 @@ """ from abc import ABC, abstractmethod -from typing import Any, Dict +from typing import Any, Dict, 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. @@ -30,7 +45,7 @@ def __init__(self, config: Dict[str, Any]): self.config = config @abstractmethod - def load_sample(self, index: int) -> Dict[str, Any]: + def load_sample(self, index: int) -> SampleData: """ Load a single sample from the dataset. @@ -51,7 +66,7 @@ def load_sample(self, index: int) -> Dict[str, Any]: pass @abstractmethod - def preprocess(self, sample: Dict[str, Any]) -> torch.Tensor: + def preprocess(self, sample: SampleData) -> torch.Tensor: """ Preprocess raw sample data into model input format. diff --git a/autoware_ml/deployment/core/base_evaluator.py b/deployment/core/base_evaluator.py similarity index 67% rename from autoware_ml/deployment/core/base_evaluator.py rename to deployment/core/base_evaluator.py index 2dc3bfb91..501b7d831 100644 --- a/autoware_ml/deployment/core/base_evaluator.py +++ b/deployment/core/base_evaluator.py @@ -6,11 +6,61 @@ """ from abc import ABC, abstractmethod -from typing import Any, Dict +from dataclasses import dataclass +from typing import Any, Dict, TypedDict import numpy as np -from autoware_ml.deployment.core.base_data_loader import BaseDataLoader +from deployment.core.base_data_loader import BaseDataLoader + + +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 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'). + path: Filesystem path to the artifact (checkpoint, ONNX file, TensorRT engine). + """ + + backend: str + device: str + path: str class BaseEvaluator(ABC): @@ -34,22 +84,18 @@ def __init__(self, config: Dict[str, Any]): @abstractmethod def evaluate( self, - model_path: str, + model: ModelSpec, data_loader: BaseDataLoader, num_samples: int, - backend: str = "pytorch", - device: str = "cpu", verbose: bool = False, - ) -> Dict[str, Any]: + ) -> EvalResultDict: """ Run full evaluation on a model. Args: - model_path: Path to model checkpoint/weights + model: Specification of the artifact/backend/device triplet to evaluate data_loader: DataLoader for loading samples num_samples: Number of samples to evaluate - backend: Backend to use ('pytorch', 'onnx', 'tensorrt') - device: Device to run inference on verbose: Whether to print detailed progress Returns: @@ -83,7 +129,7 @@ def evaluate( pass @abstractmethod - def print_results(self, results: Dict[str, Any]) -> None: + def print_results(self, results: EvalResultDict) -> None: """ Pretty print evaluation results. @@ -95,17 +141,13 @@ def print_results(self, results: Dict[str, Any]) -> None: @abstractmethod def verify( self, - ref_backend: str, - ref_device: str, - ref_path: str, - test_backend: str, - test_device: str, - test_path: str, + reference: ModelSpec, + test: ModelSpec, data_loader: BaseDataLoader, num_samples: int = 1, tolerance: float = 0.1, verbose: bool = False, - ) -> Dict[str, Any]: + ) -> VerifyResultDict: """ Verify exported models using scenario-based verification. @@ -114,25 +156,15 @@ def verify( than the legacy verify() method which compares all available backends. Args: - ref_backend: Reference backend name ('pytorch' or 'onnx') - ref_device: Device for reference backend (e.g., 'cpu', 'cuda:0') - ref_path: Path to reference model (checkpoint for pytorch, model path for onnx) - test_backend: Test backend name ('onnx' or 'tensorrt') - test_device: Device for test backend (e.g., 'cpu', 'cuda:0') - test_path: Path to test model (model path for onnx, engine path for tensorrt) + reference: Specification of backend/device/path for the reference model + test: Specification for the backend/device/path under test data_loader: Data loader for test samples num_samples: Number of samples to verify tolerance: Maximum allowed difference for verification to pass verbose: Whether to print detailed output Returns: - Dictionary containing verification results: - { - 'sample_0': bool (passed/failed), - 'sample_1': bool (passed/failed), - ... - 'summary': {'passed': int, 'failed': int, 'total': int} - } + Verification results with pass/fail summary and per-sample outcomes. """ pass diff --git a/autoware_ml/deployment/core/preprocessing_builder.py b/deployment/core/preprocessing_builder.py similarity index 65% rename from autoware_ml/deployment/core/preprocessing_builder.py rename to deployment/core/preprocessing_builder.py index 29dd3e4af..34cd7e74d 100644 --- a/autoware_ml/deployment/core/preprocessing_builder.py +++ b/deployment/core/preprocessing_builder.py @@ -19,8 +19,8 @@ logger = logging.getLogger(__name__) -# Valid task types -VALID_TASK_TYPES = ["detection2d", "detection3d", "classification", "segmentation"] +TransformConfig = Dict[str, Any] +PipelineBuilder = Callable[[List[TransformConfig]], Any] class ComposeBuilder: @@ -32,7 +32,7 @@ class ComposeBuilder: @staticmethod def build( - pipeline_cfg: List, + pipeline_cfg: List[TransformConfig], scope: str, import_modules: List[str], ) -> Any: @@ -73,107 +73,82 @@ def build( # Set default scope and build Compose try: init_default_scope(scope) - logger.info(f"Building pipeline with mmengine.dataset.Compose (default_scope='{scope}')") + logger.info( + "Building pipeline with mmengine.dataset.Compose (default_scope='%s')", + scope, + ) return Compose(pipeline_cfg) except Exception as e: - raise ImportError(f"Failed to build Compose pipeline for scope '{scope}'. " f"Error: {e}") from e - - -class PreprocessingPipelineRegistry: - """ - Registry for preprocessing pipeline builders by task type. - - Provides a clean way to register and retrieve pipeline builders. - """ + raise RuntimeError( + f"Failed to build Compose pipeline for scope '{scope}'. " + f"Check your pipeline configuration and transforms. Error: {e}" + ) from e - def __init__(self): - self._builders: Dict[str, Callable[[List], Any]] = {} - self._register_default_builders() - def _register_default_builders(self): - """Register default pipeline builders.""" - self.register("detection2d", self._build_detection2d) - self.register("detection3d", self._build_detection3d) - self.register("classification", self._build_classification) - self.register("segmentation", self._build_segmentation) +def _build_detection2d(pipeline_cfg: List[TransformConfig]) -> Any: + """Build 2D detection preprocessing pipeline.""" + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmdet", + import_modules=["mmdet.datasets.transforms"], + ) - def register(self, task_type: str, builder: Callable[[List], Any]): - """ - Register a pipeline builder for a task type. - Args: - task_type: Task type identifier - builder: Builder function that takes pipeline_cfg and returns Compose object - """ - if task_type not in VALID_TASK_TYPES: - logger.warning(f"Registering non-standard task_type: {task_type}") - self._builders[task_type] = builder - logger.debug(f"Registered pipeline builder for task_type: {task_type}") +def _build_detection3d(pipeline_cfg: List[TransformConfig]) -> Any: + """Build 3D detection preprocessing pipeline.""" + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmdet3d", + import_modules=["mmdet3d.datasets.transforms"], + ) - def build(self, task_type: str, pipeline_cfg: List) -> Any: - """ - Build pipeline for given task type. - Args: - task_type: Task type identifier - pipeline_cfg: Pipeline configuration +def _build_classification(pipeline_cfg: List[TransformConfig]) -> Any: + """ + Build classification preprocessing pipeline using mmpretrain. - Returns: - Compose object + Raises: + ImportError: If mmpretrain is not installed + """ + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmpretrain", + import_modules=["mmpretrain.datasets.transforms"], + ) - Raises: - ValueError: If task_type is not registered - """ - if task_type not in self._builders: - raise ValueError(f"Unknown task_type '{task_type}'. " f"Available types: {list(self._builders.keys())}") - return self._builders[task_type](pipeline_cfg) - - def _build_detection2d(self, pipeline_cfg: List) -> Any: - """Build 2D detection preprocessing pipeline.""" - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmdet", - import_modules=["mmdet.datasets.transforms"], - ) - def _build_detection3d(self, pipeline_cfg: List) -> Any: - """Build 3D detection preprocessing pipeline.""" - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmdet3d", - import_modules=["mmdet3d.datasets.transforms"], - ) +def _build_segmentation(pipeline_cfg: List[TransformConfig]) -> Any: + """Build segmentation preprocessing pipeline.""" + return ComposeBuilder.build( + pipeline_cfg=pipeline_cfg, + scope="mmseg", + import_modules=["mmseg.datasets.transforms"], + ) - def _build_classification(self, pipeline_cfg: List) -> Any: - """ - Build classification preprocessing pipeline using mmpretrain. - Raises: - ImportError: If mmpretrain is not installed - """ - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmpretrain", - import_modules=["mmpretrain.datasets.transforms"], - ) +_PIPELINE_BUILDERS: Dict[str, PipelineBuilder] = { + "detection2d": _build_detection2d, + "detection3d": _build_detection3d, + "classification": _build_classification, + "segmentation": _build_segmentation, +} - def _build_segmentation(self, pipeline_cfg: List) -> Any: - """Build segmentation preprocessing pipeline.""" - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmseg", - import_modules=["mmseg.datasets.transforms"], - ) +# Valid task types +VALID_TASK_TYPES = list(_PIPELINE_BUILDERS.keys()) -# Global registry instance -_registry = PreprocessingPipelineRegistry() +def _build_pipeline(task_type: str, pipeline_cfg: List[TransformConfig]) -> Any: + """Build pipeline for a given task_type using registered builders.""" + try: + builder = _PIPELINE_BUILDERS[task_type] + except KeyError: + raise ValueError(f"Unknown task_type '{task_type}'. " f"Must be one of {VALID_TASK_TYPES}") + return builder(pipeline_cfg) def build_preprocessing_pipeline( model_cfg: Config, task_type: Optional[str] = None, - backend: str = "pytorch", ) -> Any: """ Build preprocessing pipeline from model config. @@ -191,9 +166,6 @@ def build_preprocessing_pipeline( 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"``. - backend: Target backend ('pytorch', 'onnx', 'tensorrt'). - Currently not used, reserved for future backend-specific optimizations. - Returns: Pipeline compose object (e.g., mmdet.datasets.transforms.Compose) @@ -211,8 +183,8 @@ def build_preprocessing_pipeline( pipeline_cfg = _extract_pipeline_config(model_cfg) task_type = _resolve_task_type(model_cfg, task_type) - logger.info(f"Building preprocessing pipeline with task_type: {task_type}") - return _registry.build(task_type, pipeline_cfg) + logger.info("Building preprocessing pipeline with task_type: %s", task_type) + return _build_pipeline(task_type, pipeline_cfg) def _resolve_task_type(model_cfg: Config, task_type: Optional[str] = None) -> str: @@ -271,7 +243,7 @@ def _validate_task_type(task_type: str) -> None: ) -def _extract_pipeline_config(model_cfg: Config) -> List: +def _extract_pipeline_config(model_cfg: Config) -> List[TransformConfig]: """ Extract pipeline configuration from model config. diff --git a/deployment/exporters/__init__.py b/deployment/exporters/__init__.py new file mode 100644 index 000000000..e76b0e521 --- /dev/null +++ b/deployment/exporters/__init__.py @@ -0,0 +1,36 @@ +"""Model exporters for different backends.""" + +from deployment.exporters.base.base_exporter import BaseExporter +from deployment.exporters.base.model_wrappers import ( + BaseModelWrapper, + IdentityWrapper, +) + +# from deployment.exporters.base.onnx_exporter import ONNXExporter +# from deployment.exporters.base.tensorrt_exporter import TensorRTExporter +# from deployment.exporters.calibration.model_wrappers import CalibrationONNXWrapper +# from deployment.exporters.calibration.onnx_exporter import CalibrationONNXExporter +# from deployment.exporters.calibration.tensorrt_exporter import CalibrationTensorRTExporter +# from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper +# from deployment.exporters.centerpoint.onnx_exporter import CenterPointONNXExporter +# from deployment.exporters.centerpoint.tensorrt_exporter import CenterPointTensorRTExporter +# from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper +# from deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter +# from deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter + +# __all__ = [ +# "BaseExporter", +# "ONNXExporter", +# "TensorRTExporter", +# "CenterPointONNXExporter", +# "CenterPointTensorRTExporter", +# "CenterPointONNXWrapper", +# "YOLOXONNXExporter", +# "YOLOXTensorRTExporter", +# "YOLOXONNXWrapper", +# "CalibrationONNXExporter", +# "CalibrationTensorRTExporter", +# "CalibrationONNXWrapper", +# "BaseModelWrapper", +# "IdentityWrapper", +# ] diff --git a/autoware_ml/deployment/exporters/base/base_exporter.py b/deployment/exporters/base/base_exporter.py similarity index 71% rename from autoware_ml/deployment/exporters/base/base_exporter.py rename to deployment/exporters/base/base_exporter.py index 6d7d2a086..e08480cd4 100644 --- a/autoware_ml/deployment/exporters/base/base_exporter.py +++ b/deployment/exporters/base/base_exporter.py @@ -6,7 +6,7 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, Optional +from typing import Any, Mapping, Optional import torch @@ -24,16 +24,21 @@ class BaseExporter(ABC): - Better logging and error handling """ - def __init__(self, config: Dict[str, Any], logger: logging.Logger = None, model_wrapper: Optional[Any] = None): + def __init__( + self, + config: Mapping[str, Any], + model_wrapper: Optional[Any] = None, + logger: Optional[logging.Logger] = None, + ): """ Initialize exporter. Args: config: Configuration dictionary for export settings - logger: Optional logger instance - model_wrapper: Optional model wrapper class or instance. + 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 = config self.logger = logger or logging.getLogger(__name__) @@ -64,36 +69,17 @@ def prepare_model(self, model: torch.nn.Module) -> torch.nn.Module: raise TypeError(f"model_wrapper must be a class or callable, got {type(self._model_wrapper)}") @abstractmethod - def export(self, model: torch.nn.Module, sample_input: torch.Tensor, output_path: str, **kwargs) -> bool: + 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: Sample input tensor for tracing/shape inference + sample_input: Example model input(s) for tracing/shape inference output_path: Path to save exported model **kwargs: Additional format-specific arguments - Returns: - True if export succeeded, False otherwise - Raises: RuntimeError: If export fails """ pass - - def validate_export(self, output_path: str) -> bool: - """ - Validate that the exported model file is valid. - - Override this in subclasses to add format-specific validation. - - Args: - output_path: Path to exported model file - - Returns: - True if valid, False otherwise - """ - import os - - return os.path.exists(output_path) and os.path.getsize(output_path) > 0 diff --git a/autoware_ml/deployment/exporters/base/model_wrappers.py b/deployment/exporters/base/model_wrappers.py similarity index 100% rename from autoware_ml/deployment/exporters/base/model_wrappers.py rename to deployment/exporters/base/model_wrappers.py diff --git a/autoware_ml/deployment/exporters/base/onnx_exporter.py b/deployment/exporters/base/onnx_exporter.py similarity index 78% rename from autoware_ml/deployment/exporters/base/onnx_exporter.py rename to deployment/exporters/base/onnx_exporter.py index ef3611b96..1e645d095 100644 --- a/autoware_ml/deployment/exporters/base/onnx_exporter.py +++ b/deployment/exporters/base/onnx_exporter.py @@ -8,7 +8,7 @@ import onnxsim import torch -from autoware_ml.deployment.exporters.base.base_exporter import BaseExporter +from deployment.exporters.base.base_exporter import BaseExporter class ONNXExporter(BaseExporter): @@ -22,24 +22,29 @@ class ONNXExporter(BaseExporter): - Configuration override capability """ - def __init__(self, config: Dict[str, Any], logger: logging.Logger = None, model_wrapper: Optional[Any] = None): + def __init__( + self, + config: Dict[str, Any], + model_wrapper: Optional[Any] = None, + logger: logging.Logger = None, + ): """ Initialize ONNX exporter. Args: config: ONNX export configuration - logger: Optional logger instance model_wrapper: Optional model wrapper class (e.g., YOLOXONNXWrapper) + logger: Optional logger instance """ - super().__init__(config, logger, model_wrapper=model_wrapper) + super().__init__(config, model_wrapper=model_wrapper, logger=logger) def export( self, model: torch.nn.Module, - sample_input: torch.Tensor, + sample_input: Any, output_path: str, config_override: Optional[Dict[str, Any]] = None, - ) -> bool: + ) -> None: """ Export model to ONNX format. @@ -49,8 +54,8 @@ def export( output_path: Path to save ONNX model config_override: Optional config overrides for this specific export - Returns: - True if export succeeded + Raises: + RuntimeError: If export fails """ # Apply model wrapper if configured model = self.prepare_model(model) @@ -91,14 +96,12 @@ def export( if export_config.get("simplify", True): self._simplify_model(output_path) - return True - except Exception as e: self.logger.error(f"ONNX export failed: {e}") import traceback self.logger.error(traceback.format_exc()) - return False + raise RuntimeError("ONNX export failed") from e def export_multi( self, @@ -106,7 +109,7 @@ def export_multi( sample_inputs: Dict[str, torch.Tensor], output_dir: str, configs: Optional[Dict[str, Dict[str, Any]]] = None, - ) -> bool: + ) -> None: """ Export multiple models to separate ONNX files. @@ -119,48 +122,37 @@ def export_multi( output_dir: Directory to save ONNX files configs: Optional dict of {filename: config_override} - Returns: - True if all exports succeeded + Raises: + ValueError: If required inputs are missing + RuntimeError: If any export fails """ self.logger.info(f"Exporting {len(models)} models to {output_dir}") os.makedirs(output_dir, exist_ok=True) - success_count = 0 configs = configs or {} for name, model in models.items(): if name not in sample_inputs: - self.logger.error(f"No sample input provided for model: {name}") - continue + raise ValueError(f"No sample input provided for model: {name}") output_path = os.path.join(output_dir, name) if not output_path.endswith(".onnx"): output_path += ".onnx" config_override = configs.get(name) - success = self.export( - model=model, - sample_input=sample_inputs[name], - output_path=output_path, - config_override=config_override, - ) - - if success: - success_count += 1 + try: + self.export( + model=model, + sample_input=sample_inputs[name], + output_path=output_path, + config_override=config_override, + ) self.logger.info(f"✅ Exported {name}") - else: + except Exception as exc: self.logger.error(f"❌ Failed to export {name}") + raise - total = len(models) - if success_count == total: - self.logger.info(f"✅ All {total} models exported successfully") - return True - elif success_count > 0: - self.logger.warning(f"⚠️ Partial success: {success_count}/{total} models exported") - return False - else: - self.logger.error(f"❌ All exports failed") - return False + self.logger.info(f"✅ All {len(models)} models exported successfully") def _simplify_model(self, onnx_path: str) -> None: """ diff --git a/autoware_ml/deployment/exporters/base/tensorrt_exporter.py b/deployment/exporters/base/tensorrt_exporter.py similarity index 57% rename from autoware_ml/deployment/exporters/base/tensorrt_exporter.py rename to deployment/exporters/base/tensorrt_exporter.py index 3f65a154d..cf2c8ab9a 100644 --- a/autoware_ml/deployment/exporters/base/tensorrt_exporter.py +++ b/deployment/exporters/base/tensorrt_exporter.py @@ -6,7 +6,7 @@ import tensorrt as trt import torch -from autoware_ml.deployment.exporters.base.base_exporter import BaseExporter +from deployment.exporters.base.base_exporter import BaseExporter class TensorRTExporter(BaseExporter): @@ -16,25 +16,30 @@ class TensorRTExporter(BaseExporter): Converts ONNX models to TensorRT engine format with precision policy support. """ - def __init__(self, config: Dict[str, Any], logger: logging.Logger = None, model_wrapper: Optional[Any] = None): + def __init__( + self, + config: Dict[str, Any], + model_wrapper: Optional[Any] = None, + logger: logging.Logger = None, + ): """ Initialize TensorRT exporter. Args: config: TensorRT export configuration - logger: Optional logger instance model_wrapper: Optional model wrapper class (usually not needed for TensorRT) + logger: Optional logger instance """ - super().__init__(config, logger, model_wrapper=model_wrapper) + 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: torch.Tensor, + sample_input: Any, output_path: str, onnx_path: str = None, - ) -> bool: + ) -> None: """ Export ONNX model to TensorRT engine. @@ -44,12 +49,12 @@ def export( output_path: Path to save TensorRT engine onnx_path: Path to source ONNX model - Returns: - True if export succeeded + Raises: + RuntimeError: If export fails + ValueError: If ONNX path is missing """ if onnx_path is None: - self.logger.error("onnx_path is required for TensorRT export") - return False + raise ValueError("onnx_path is required for TensorRT export") precision_policy = self.config.get("precision_policy", "auto") policy_flags = self.config.get("policy_flags", {}) @@ -93,7 +98,7 @@ def export( with open(onnx_path, "rb") as f: if not parser.parse(f.read()): self._log_parser_errors(parser) - return False + raise RuntimeError("TensorRT export failed: unable to parse ONNX file") self.logger.info("Successfully parsed ONNX file") # Setup optimization profile after parsing ONNX to get actual input names @@ -107,7 +112,7 @@ def export( if serialized_engine is None: self.logger.error("Failed to build TensorRT engine") - return False + raise RuntimeError("TensorRT export failed: builder returned None") # Save engine with open(output_path, "wb") as f: @@ -116,16 +121,14 @@ def export( 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") - return True - except Exception as e: self.logger.error(f"TensorRT export failed: {e}") - return False + raise RuntimeError("TensorRT export failed") from e def _configure_input_shapes( self, profile: trt.IOptimizationProfile, - sample_input: torch.Tensor, + sample_input: Any, network: trt.INetworkDefinition = None, ) -> None: """ @@ -139,67 +142,33 @@ def _configure_input_shapes( model_inputs = self.config.get("model_inputs", []) if model_inputs: + # VIVID(calibration classifier) + print("model inputs: ", model_inputs) input_shapes = model_inputs[0].get("input_shapes", {}) for input_name, shapes in input_shapes.items(): - min_shape = shapes.get("min_shape", list(sample_input.shape)) - opt_shape = shapes.get("opt_shape", list(sample_input.shape)) - max_shape = shapes.get("max_shape", list(sample_input.shape)) - - self.logger.info(f"Setting input shapes - min: {min_shape}, " f"opt: {opt_shape}, max: {max_shape}") + min_shape = shapes.get("min_shape") + opt_shape = shapes.get("opt_shape") + max_shape = shapes.get("max_shape") + + if min_shape is None: + if sample_input is None: + raise ValueError(f"min_shape missing for {input_name} and sample_input is not provided") + min_shape = list(sample_input.shape) + + if opt_shape is None: + if sample_input is None: + raise ValueError(f"opt_shape missing for {input_name} and sample_input is not provided") + opt_shape = list(sample_input.shape) + + if max_shape is None: + if sample_input is None: + raise ValueError(f"max_shape missing for {input_name} and sample_input is not provided") + max_shape = list(sample_input.shape) + + 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) else: - # Handle different input types based on shape - input_shape = list(sample_input.shape) - - # Get actual input name from network if available - input_name = "input" # Default fallback - if network is not None and network.num_inputs > 0: - # Use the first input's name from the ONNX model - input_name = network.get_input(0).name - self.logger.info(f"Using input name from ONNX model: {input_name}") - - # Determine input type based on shape - if len(input_shape) == 3 and input_shape[1] == 32: # voxel encoder: (num_voxels, 32, 11) - # CenterPoint voxel encoder input: input_features - min_shape = [1000, 32, 11] # Minimum voxels - opt_shape = [10000, 32, 11] # Optimal voxels - max_shape = [50000, 32, 11] # Maximum voxels - if network is None: - input_name = "input_features" - elif ( - len(input_shape) == 4 and input_shape[1] == 32 - ): # CenterPoint backbone input: (batch, 32, height, width) - # Backbone input: spatial_features - use dynamic dimensions for H, W - # NOTE: Actual evaluation data can produce up to 760x760, so use 800x800 for max_shape - min_shape = [1, 32, 100, 100] - opt_shape = [1, 32, 200, 200] - max_shape = [1, 32, 800, 800] # Increased from 400x400 to support actual data - if network is None: - input_name = "spatial_features" - elif len(input_shape) == 4 and input_shape[1] in [ - 3, - 5, - ]: # Standard image input: (batch, channels, height, width) - # For YOLOX, CalibrationStatusClassification, etc. - # Use sample shape as optimal, allow some variation for batch dimension - batch_size = input_shape[0] - channels = input_shape[1] - height = input_shape[2] - width = input_shape[3] - - # Allow dynamic batch size if batch_size > 1, otherwise use fixed - if batch_size > 1: - min_shape = [1, channels, height, width] - opt_shape = [batch_size, channels, height, width] - max_shape = [batch_size, channels, height, width] - else: - min_shape = opt_shape = max_shape = input_shape - else: - # Default fallback: use sample shape as-is - min_shape = opt_shape = max_shape = input_shape - - 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) + raise ValueError("model_inputs is not set in the config") def _log_parser_errors(self, parser: trt.OnnxParser) -> None: """Log TensorRT parser errors.""" diff --git a/deployment/pipelines/__init__.py b/deployment/pipelines/__init__.py new file mode 100644 index 000000000..1b329596f --- /dev/null +++ b/deployment/pipelines/__init__.py @@ -0,0 +1,48 @@ +""" +Deployment Pipelines for Complex Models. + +This module provides pipeline abstractions for models that require +multi-stage processing with mixed PyTorch and optimized backend inference. +""" + +# # Calibration pipelines (classification) +# from deployment.pipelines.calibration import ( +# CalibrationDeploymentPipeline, +# CalibrationONNXPipeline, +# CalibrationPyTorchPipeline, +# CalibrationTensorRTPipeline, +# ) + +# # CenterPoint pipelines (3D detection) +# from deployment.pipelines.centerpoint import ( +# CenterPointDeploymentPipeline, +# CenterPointONNXPipeline, +# CenterPointPyTorchPipeline, +# CenterPointTensorRTPipeline, +# ) + +# # YOLOX pipelines (2D detection) +# from deployment.pipelines.yolox import ( +# YOLOXDeploymentPipeline, +# YOLOXONNXPipeline, +# YOLOXPyTorchPipeline, +# YOLOXTensorRTPipeline, +# ) + +# __all__ = [ +# # CenterPoint +# "CenterPointDeploymentPipeline", +# "CenterPointPyTorchPipeline", +# "CenterPointONNXPipeline", +# "CenterPointTensorRTPipeline", +# # YOLOX +# "YOLOXDeploymentPipeline", +# "YOLOXPyTorchPipeline", +# "YOLOXONNXPipeline", +# "YOLOXTensorRTPipeline", +# # Calibration +# "CalibrationDeploymentPipeline", +# "CalibrationPyTorchPipeline", +# "CalibrationONNXPipeline", +# "CalibrationTensorRTPipeline", +# ] diff --git a/deployment/pipelines/base/__init__.py b/deployment/pipelines/base/__init__.py new file mode 100644 index 000000000..844a7da27 --- /dev/null +++ b/deployment/pipelines/base/__init__.py @@ -0,0 +1,18 @@ +""" +Base Pipeline Classes for Deployment Framework. + +This module provides the base abstract classes for all deployment pipelines, +including base pipeline, classification, 2D detection, and 3D detection pipelines. +""" + +from deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.base.classification_pipeline import ClassificationPipeline +from deployment.pipelines.base.detection_2d_pipeline import Detection2DPipeline +from deployment.pipelines.base.detection_3d_pipeline import Detection3DPipeline + +__all__ = [ + "BaseDeploymentPipeline", + "ClassificationPipeline", + "Detection2DPipeline", + "Detection3DPipeline", +] diff --git a/autoware_ml/deployment/pipelines/base/base_pipeline.py b/deployment/pipelines/base/base_pipeline.py similarity index 74% rename from autoware_ml/deployment/pipelines/base/base_pipeline.py rename to deployment/pipelines/base/base_pipeline.py index cfd801830..d78b39e22 100644 --- a/autoware_ml/deployment/pipelines/base/base_pipeline.py +++ b/deployment/pipelines/base/base_pipeline.py @@ -35,7 +35,7 @@ class BaseDeploymentPipeline(ABC): Attributes: model: Model object (PyTorch model, ONNX session, TensorRT engine, etc.) device: Device for inference - task_type: Type of task ("detection_2d", "detection_3d", "classification", etc.) + task_type: Type of task ("detection2d", "detection3d", "classification", etc.) backend_type: Type of backend ("pytorch", "onnx", "tensorrt", etc.) """ @@ -53,6 +53,7 @@ def __init__(self, model: Any, device: str = "cpu", task_type: str = "unknown", 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}") @@ -145,7 +146,7 @@ def infer( latency_breakdown: Dict[str, float] = {} try: - start_time = time.time() + start_time = time.perf_counter() # 1. Preprocess preprocessed = self.preprocess(input_data, **kwargs) @@ -156,7 +157,7 @@ def infer( if isinstance(preprocessed, tuple) and len(preprocessed) == 2 and isinstance(preprocessed[1], dict): model_input, preprocess_metadata = preprocessed - preprocess_time = time.time() + preprocess_time = time.perf_counter() latency_breakdown["preprocessing_ms"] = (preprocess_time - start_time) * 1000 # Merge caller metadata (if any) with preprocess metadata (preprocess takes precedence by default) @@ -165,9 +166,9 @@ def infer( merged_metadata.update(preprocess_metadata) # 2. Run model (backend-specific) - model_start = time.time() + model_start = time.perf_counter() model_output = self.run_model(model_input) - model_time = time.time() + model_time = time.perf_counter() latency_breakdown["model_ms"] = (model_time - model_start) * 1000 # Merge stage-wise latencies if available (for multi-stage pipelines like CenterPoint) @@ -176,83 +177,24 @@ def infer( # Clear for next inference self._stage_latencies = {} - total_latency = (time.time() - start_time) * 1000 + total_latency = (time.perf_counter() - start_time) * 1000 # 3. Postprocess (optional) if return_raw_outputs: return model_output, total_latency, latency_breakdown else: - postprocess_start = time.time() + postprocess_start = time.perf_counter() predictions = self.postprocess(model_output, merged_metadata) - postprocess_time = time.time() + postprocess_time = time.perf_counter() latency_breakdown["postprocessing_ms"] = (postprocess_time - postprocess_start) * 1000 - total_latency = (time.time() - start_time) * 1000 + total_latency = (time.perf_counter() - start_time) * 1000 return predictions, total_latency, latency_breakdown - except Exception as e: - logger.error(f"Inference failed: {e}") - import traceback - - traceback.print_exc() + except Exception: + logger.exception("Inference failed.") raise - def warmup(self, input_data: Any, num_iterations: int = 10): - """ - Warmup the model with dummy inputs. - - Useful for stabilizing latency measurements, especially for GPU models. - - Args: - input_data: Sample input for warmup - num_iterations: Number of warmup iterations - """ - logger.info(f"Warming up {self.__class__.__name__} with {num_iterations} iterations...") - - for i in range(num_iterations): - try: - self.infer(input_data) - except Exception as e: - logger.warning(f"Warmup iteration {i} failed: {e}") - - logger.info("Warmup completed") - - def benchmark(self, input_data: Any, num_iterations: int = 100) -> Dict[str, float]: - """ - Benchmark inference performance. - - Args: - input_data: Sample input for benchmarking - num_iterations: Number of benchmark iterations - - Returns: - Dictionary with latency statistics (mean, std, min, max) - """ - logger.info(f"Benchmarking {self.__class__.__name__} with {num_iterations} iterations...") - - # Warmup first - self.warmup(input_data, num_iterations=10) - - # Benchmark - latencies = [] - for _ in range(num_iterations): - _, latency, _ = self.infer(input_data) - latencies.append(latency) - - import numpy as np - - results = { - "mean_ms": np.mean(latencies), - "std_ms": np.std(latencies), - "min_ms": np.min(latencies), - "max_ms": np.max(latencies), - "median_ms": np.median(latencies), - } - - logger.info(f"Benchmark results: {results['mean_ms']:.2f} ± {results['std_ms']:.2f} ms") - - return results - def __repr__(self): return ( f"{self.__class__.__name__}(" @@ -266,6 +208,10 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" + """Context manager exit. + + Subclasses can override this to release backend resources + (e.g., TensorRT contexts, ONNX sessions, CUDA streams). + """ # Cleanup resources if needed pass diff --git a/deployment/runners/__init__.py b/deployment/runners/__init__.py new file mode 100644 index 000000000..e6c3aee11 --- /dev/null +++ b/deployment/runners/__init__.py @@ -0,0 +1,13 @@ +"""Deployment runners for unified deployment workflow.""" + +from deployment.runners.calibration_runner import CalibrationDeploymentRunner +from deployment.runners.centerpoint_runner import CenterPointDeploymentRunner +from deployment.runners.deployment_runner import BaseDeploymentRunner +from deployment.runners.yolox_runner import YOLOXDeploymentRunner + +__all__ = [ + "BaseDeploymentRunner", + "CenterPointDeploymentRunner", + "YOLOXDeploymentRunner", + "CalibrationDeploymentRunner", +] diff --git a/autoware_ml/deployment/runners/deployment_runner.py b/deployment/runners/deployment_runner.py similarity index 92% rename from autoware_ml/deployment/runners/deployment_runner.py rename to deployment/runners/deployment_runner.py index ff6b9751e..471b17cd4 100644 --- a/autoware_ml/deployment/runners/deployment_runner.py +++ b/deployment/runners/deployment_runner.py @@ -12,7 +12,12 @@ import torch from mmengine.config import Config -from autoware_ml.deployment.core import BaseDataLoader, BaseDeploymentConfig, BaseEvaluator +from deployment.core import ( + BaseDataLoader, + BaseDeploymentConfig, + BaseEvaluator, + ModelSpec, +) class BaseDeploymentRunner: @@ -151,14 +156,14 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: # Use provided exporter (required, cannot be None) exporter = self._onnx_exporter - success = exporter.export(pytorch_model, input_tensor, output_path) + try: + exporter.export(pytorch_model, input_tensor, output_path) + except Exception: + self.logger.error("❌ ONNX export failed") + raise - if success: - self.logger.info(f"✅ ONNX export successful: {output_path}") - return output_path - else: - self.logger.error(f"❌ ONNX export failed") - return None + self.logger.info(f"✅ ONNX export successful: {output_path}") + return output_path def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: """ @@ -203,6 +208,12 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: engine_filename = onnx_filename.replace(".onnx", ".engine") output_path = os.path.join(tensorrt_dir, engine_filename) + # Set CUDA device for TensorRT exportd + cuda_device = self.config.export_config.cuda_device + device_id = self.config.export_config.get_cuda_device_index() + torch.cuda.set_device(device_id) + self.logger.info(f"Using CUDA device for TensorRT export: {cuda_device}") + # Get sample input for shape configuration sample_idx = self.config.runtime_config.get("sample_idx", 0) sample = self.data_loader.load_sample(sample_idx) @@ -218,19 +229,19 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: # Use provided exporter (required, cannot be None) exporter = self._tensorrt_exporter - success = exporter.export( - model=None, # Not used for TensorRT - sample_input=sample_input, - output_path=output_path, - onnx_path=onnx_path, - ) + try: + exporter.export( + model=None, # Not used for TensorRT + sample_input=sample_input, + output_path=output_path, + onnx_path=onnx_path, + ) + except Exception: + self.logger.error("❌ TensorRT export failed") + raise - if success: - self.logger.info(f"✅ TensorRT export successful: {output_path}") - return output_path - else: - self.logger.error(f"❌ TensorRT export failed") - return None + self.logger.info(f"✅ TensorRT export successful: {output_path}") + return output_path def _resolve_pytorch_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str], bool]: """ @@ -420,7 +431,7 @@ def _resolve_tensorrt_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional return model_path, is_valid - def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: + def get_models_to_evaluate(self) -> List[ModelSpec]: """ Get list of models to evaluate from config. @@ -428,7 +439,7 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: List of tuples (backend_name, model_path, device) """ backends = self.config.get_evaluation_backends() - models_to_evaluate: List[Tuple[str, str, str]] = [] + models_to_evaluate: List[ModelSpec] = [] for backend_name, backend_cfg in backends.items(): if not backend_cfg.get("enabled", False): @@ -446,8 +457,15 @@ def get_models_to_evaluate(self) -> List[Tuple[str, str, str]]: model_path, is_valid = self._resolve_tensorrt_model(backend_cfg) if is_valid and model_path: - models_to_evaluate.append((backend_name, model_path, device)) - self.logger.info(f" - {backend_name}: {model_path} (device: {device})") + normalized_device = str(device or "cpu") + models_to_evaluate.append( + ModelSpec( + backend=backend_name, + device=normalized_device, + path=model_path, + ) + ) + self.logger.info(f" - {backend_name}: {model_path} (device: {normalized_device})") elif model_path: self.logger.warning(f" - {backend_name}: {model_path} (not found or invalid, skipping)") @@ -551,13 +569,12 @@ def run_verification( continue # Use policy-based verification interface + reference_spec = ModelSpec(backend=ref_backend, device=ref_device, path=ref_path) + test_spec = ModelSpec(backend=test_backend, device=test_device, path=test_path) + verification_results = self.evaluator.verify( - ref_backend=ref_backend, - ref_device=ref_device, - ref_path=ref_path, - test_backend=test_backend, - test_device=test_device, - test_path=test_path, + reference=reference_spec, + test=test_spec, data_loader=self.data_loader, num_samples=num_verify_samples, tolerance=tolerance, @@ -630,32 +647,37 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: all_results: Dict[str, Any] = {} - for backend, model_path, backend_device in models_to_evaluate: + # TODO(vividf): a bit ungly here, need to refactor + for spec in models_to_evaluate: + backend = spec.backend + backend_device = spec.device + model_path = spec.path if backend in ("pytorch", "onnx"): - if backend_device not in (None, "cpu") and not str(backend_device).startswith("cuda"): + if backend_device not in ("cpu",) and not str(backend_device).startswith("cuda"): self.logger.warning( f"Unsupported device '{backend_device}' for backend '{backend}'. Falling back to CPU." ) backend_device = "cpu" elif backend == "tensorrt": - if backend_device is None: - backend_device = "cuda:0" - if backend_device != "cuda:0": + if not backend_device or backend_device == "cpu": + backend_device = self.config.export_config.cuda_device or "cuda:0" + if not str(backend_device).startswith("cuda"): self.logger.warning( - f"TensorRT evaluation only supports 'cuda:0'. Overriding device from '{backend_device}' to 'cuda:0'." + f"TensorRT evaluation requires CUDA device. Overriding device from '{backend_device}' to 'cuda:0'." ) backend_device = "cuda:0" - if backend_device is None: - backend_device = "cpu" + normalized_spec = ModelSpec( + backend=backend, + device=backend_device or "cpu", + path=model_path, + ) results = self.evaluator.evaluate( - model_path=model_path, + model=normalized_spec, data_loader=self.data_loader, num_samples=num_samples, - backend=backend, - device=backend_device, verbose=verbose_mode, ) From dbb9efa2ceda6be6b8256355490ed9dc0b6cb464 Mon Sep 17 00:00:00 2001 From: vividf Date: Wed, 19 Nov 2025 17:58:30 +0900 Subject: [PATCH 09/62] feat: cleanup deployment runner Signed-off-by: vividf --- deployment/core/__init__.py | 6 +- deployment/core/artifacts.py | 18 + deployment/core/base_config.py | 2 + deployment/core/base_evaluator.py | 15 +- deployment/core/preprocessing_builder.py | 36 +- deployment/exporters/base/base_exporter.py | 13 +- deployment/exporters/base/onnx_exporter.py | 116 +++--- .../exporters/base/tensorrt_exporter.py | 2 +- deployment/pipelines/base/base_pipeline.py | 16 +- deployment/runners/__init__.py | 20 +- deployment/runners/deployment_runner.py | 389 ++++++------------ 11 files changed, 250 insertions(+), 383 deletions(-) create mode 100644 deployment/core/artifacts.py diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py index 5ca835b19..60db08aa3 100644 --- a/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -1,5 +1,6 @@ """Core components for deployment framework.""" +from deployment.core.artifacts import Artifact from deployment.core.base_config import ( BackendConfig, BaseDeploymentConfig, @@ -15,9 +16,7 @@ ModelSpec, VerifyResultDict, ) -from deployment.core.preprocessing_builder import ( - build_preprocessing_pipeline, -) +from deployment.core.preprocessing_builder import build_preprocessing_pipeline __all__ = [ "BaseDeploymentConfig", @@ -30,6 +29,7 @@ "BaseEvaluator", "EvalResultDict", "VerifyResultDict", + "Artifact", "ModelSpec", "build_preprocessing_pipeline", ] diff --git a/deployment/core/artifacts.py b/deployment/core/artifacts.py new file mode 100644 index 000000000..be7f39bbf --- /dev/null +++ b/deployment/core/artifacts.py @@ -0,0 +1,18 @@ +"""Artifact descriptors for deployment outputs.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass +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 os.path.exists(self.path) diff --git a/deployment/core/base_config.py b/deployment/core/base_config.py index 77aa991ef..8aa517419 100644 --- a/deployment/core/base_config.py +++ b/deployment/core/base_config.py @@ -46,6 +46,7 @@ class ExportConfig: work_dir: str = "work_dirs" checkpoint_path: Optional[str] = None onnx_path: Optional[str] = None + tensorrt_path: Optional[str] = None cuda_device: str = "cuda:0" def __post_init__(self) -> None: @@ -59,6 +60,7 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": work_dir=config_dict.get("work_dir", cls.work_dir), checkpoint_path=config_dict.get("checkpoint_path"), onnx_path=config_dict.get("onnx_path"), + tensorrt_path=config_dict.get("tensorrt_path"), cuda_device=config_dict.get("cuda_device", cls.cuda_device), ) diff --git a/deployment/core/base_evaluator.py b/deployment/core/base_evaluator.py index 501b7d831..8d712e959 100644 --- a/deployment/core/base_evaluator.py +++ b/deployment/core/base_evaluator.py @@ -11,6 +11,7 @@ import numpy as np +from deployment.core.artifacts import Artifact from deployment.core.base_data_loader import BaseDataLoader @@ -55,12 +56,22 @@ class ModelSpec: Attributes: backend: Backend identifier such as 'pytorch', 'onnx', or 'tensorrt'. device: Target device string (e.g., 'cpu', 'cuda:0'). - path: Filesystem path to the artifact (checkpoint, ONNX file, TensorRT engine). + artifact: Filesystem representation of the produced model. """ backend: str device: str - path: 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 class BaseEvaluator(ABC): diff --git a/deployment/core/preprocessing_builder.py b/deployment/core/preprocessing_builder.py index 34cd7e74d..054bcf047 100644 --- a/deployment/core/preprocessing_builder.py +++ b/deployment/core/preprocessing_builder.py @@ -4,12 +4,7 @@ This module provides functions to extract and build preprocessing pipelines from MMDet/MMDet3D/MMPretrain configs for use in deployment data loaders. -NOTE: This module is compatible with the new pipeline architecture (BaseDeploymentPipeline). -They serve complementary purposes: -- preprocessing_builder.py: Builds MMDet/MMDet3D preprocessing pipelines for data loaders -- New pipeline architecture: Handles inference pipeline (preprocess → run_model → postprocess) -Data flow: DataLoader (uses preprocessing_builder) → Preprocessed Data → Pipeline (new architecture) → Predictions -See PIPELINE_BUILDER_INTEGRATION_ANALYSIS.md for detailed analysis. +This module is compatible with the BaseDeploymentPipeline. """ import logging @@ -158,10 +153,7 @@ def build_preprocessing_pipeline( Args: model_cfg: Model configuration containing test pipeline definition. - Can have pipeline defined in one of these locations: - model_cfg.test_dataloader.dataset.pipeline - - model_cfg.test_pipeline - - model_cfg.val_dataloader.dataset.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``. @@ -170,7 +162,7 @@ def build_preprocessing_pipeline( Pipeline compose object (e.g., mmdet.datasets.transforms.Compose) Raises: - ValueError: If no valid test pipeline found in config or invalid task_type + ValueError: If no valid test pipeline found in config or invalid task_type ImportError: If required transform packages are not available Examples: @@ -201,7 +193,6 @@ def _resolve_task_type(model_cfg: Config, task_type: Optional[str] = None) -> st Raises: ValueError: If task_type cannot be resolved """ - # Priority: function argument > model_cfg.task_type > model_cfg.deploy.task_type if task_type is not None: _validate_task_type(task_type) return task_type @@ -256,29 +247,26 @@ def _extract_pipeline_config(model_cfg: Config) -> List[TransformConfig]: Raises: ValueError: If no valid pipeline found """ - # Try different possible locations for pipeline config - pipeline_locations = [ - # Primary location: test_dataloader + pipeline_paths = [ ("test_dataloader", "dataset", "pipeline"), - # Alternative: direct test_pipeline + ("test_dataloader", "dataset", "dataset", "pipeline"), # nested dataset wrappers ("test_pipeline",), - # Fallback: val_dataloader - ("val_dataloader", "dataset", "pipeline"), ] - for location in pipeline_locations: + for path in pipeline_paths: + cfg = model_cfg try: - cfg = model_cfg - for key in location: + for key in path: cfg = cfg[key] - if cfg: - logger.info(f"Found test pipeline at: {'.'.join(location)}") - return cfg except (KeyError, TypeError): continue + if cfg: + logger.info("Found test pipeline at: %s", ".".join(path)) + return cfg + raise ValueError( "No test pipeline found in config. " "Expected pipeline at one of: test_dataloader.dataset.pipeline, " - "test_pipeline, or val_dataloader.dataset.pipeline" + "test_dataloader.dataset.dataset.pipeline, test_pipeline." ) diff --git a/deployment/exporters/base/base_exporter.py b/deployment/exporters/base/base_exporter.py index e08480cd4..ecff38ee0 100644 --- a/deployment/exporters/base/base_exporter.py +++ b/deployment/exporters/base/base_exporter.py @@ -10,6 +10,8 @@ import torch +from deployment.exporters.base.model_wrappers import BaseModelWrapper + class BaseExporter(ABC): """ @@ -27,7 +29,7 @@ class BaseExporter(ABC): def __init__( self, config: Mapping[str, Any], - model_wrapper: Optional[Any] = None, + model_wrapper: Optional[BaseModelWrapper] = None, logger: Optional[logging.Logger] = None, ): """ @@ -59,14 +61,7 @@ def prepare_model(self, model: torch.nn.Module) -> torch.nn.Module: self.logger.info("Applying model wrapper for export") - # If model_wrapper is a class, instantiate it with the model - if isinstance(self._model_wrapper, type): - return self._model_wrapper(model) - # If model_wrapper is a callable (function or instance with __call__), use it - elif callable(self._model_wrapper): - return self._model_wrapper(model) - else: - raise TypeError(f"model_wrapper must be a class or callable, got {type(self._model_wrapper)}") + return self._model_wrapper(model) @abstractmethod def export(self, model: torch.nn.Module, sample_input: Any, output_path: str, **kwargs) -> None: diff --git a/deployment/exporters/base/onnx_exporter.py b/deployment/exporters/base/onnx_exporter.py index 1e645d095..46ec8447d 100644 --- a/deployment/exporters/base/onnx_exporter.py +++ b/deployment/exporters/base/onnx_exporter.py @@ -2,7 +2,7 @@ import logging import os -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import onnx import onnxsim @@ -43,7 +43,15 @@ def export( model: torch.nn.Module, sample_input: Any, output_path: str, - config_override: Optional[Dict[str, Any]] = None, + *, + input_names: Optional[List[str]] = None, + output_names: Optional[List[str]] = None, + dynamic_axes: Optional[Dict[str, Dict[int, str]]] = None, + simplify: Optional[bool] = None, + opset_version: Optional[int] = None, + export_params: Optional[bool] = None, + keep_initializers_as_inputs: Optional[bool] = None, + verbose: Optional[bool] = None, ) -> None: """ Export model to ONNX format. @@ -52,24 +60,43 @@ def export( model: PyTorch model to export sample_input: Sample input tensor output_path: Path to save ONNX model - config_override: Optional config overrides for this specific export + input_names: Optional per-export input names + output_names: Optional per-export output names + dynamic_axes: Optional per-export dynamic axes mapping + simplify: Optional override for ONNX simplification flag + opset_version: Optional opset override + export_params: Optional export_params override + keep_initializers_as_inputs: Optional override for ONNX flag + verbose: Optional override for verbose flag Raises: RuntimeError: If export fails """ + # Merge per-export overrides with base configuration + export_cfg: Dict[str, Any] = dict(self.config) + overrides = { + "input_names": input_names, + "output_names": output_names, + "dynamic_axes": dynamic_axes, + "simplify": simplify, + "opset_version": opset_version, + "export_params": export_params, + "keep_initializers_as_inputs": keep_initializers_as_inputs, + "verbose": verbose, + } + for key, value in overrides.items(): + if value is not None: + export_cfg[key] = value + # Apply model wrapper if configured model = self.prepare_model(model) model.eval() - # Merge config with overrides - export_config = self.config.copy() - if config_override: - export_config.update(config_override) - self.logger.info("Exporting model to ONNX format...") - self.logger.info(f" Input shape: {sample_input.shape}") + 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_config.get('opset_version', 16)}") + self.logger.info(f" Opset version: {export_cfg.get('opset_version', 16)}") # Ensure output directory exists os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True) @@ -80,20 +107,20 @@ def export( model, sample_input, output_path, - export_params=export_config.get("export_params", True), - keep_initializers_as_inputs=export_config.get("keep_initializers_as_inputs", False), - opset_version=export_config.get("opset_version", 16), - do_constant_folding=export_config.get("do_constant_folding", True), - input_names=export_config.get("input_names", ["input"]), - output_names=export_config.get("output_names", ["output"]), - dynamic_axes=export_config.get("dynamic_axes"), - verbose=export_config.get("verbose", False), + export_params=export_cfg.get("export_params", True), + keep_initializers_as_inputs=export_cfg.get("keep_initializers_as_inputs", False), + opset_version=export_cfg.get("opset_version", 16), + do_constant_folding=export_cfg.get("do_constant_folding", True), + input_names=export_cfg.get("input_names", ["input"]), + output_names=export_cfg.get("output_names", ["output"]), + dynamic_axes=export_cfg.get("dynamic_axes"), + verbose=export_cfg.get("verbose", False), ) self.logger.info(f"ONNX export completed: {output_path}") # Optional model simplification - if export_config.get("simplify", True): + if export_cfg.get("simplify", True): self._simplify_model(output_path) except Exception as e: @@ -103,57 +130,6 @@ def export( self.logger.error(traceback.format_exc()) raise RuntimeError("ONNX export failed") from e - def export_multi( - self, - models: Dict[str, torch.nn.Module], - sample_inputs: Dict[str, torch.Tensor], - output_dir: str, - configs: Optional[Dict[str, Dict[str, Any]]] = None, - ) -> None: - """ - Export multiple models to separate ONNX files. - - Useful for complex models that need to be split into multiple files - (e.g., CenterPoint: voxel encoder + backbone/neck/head). - - Args: - models: Dict of {filename: model} - sample_inputs: Dict of {filename: input_tensor} - output_dir: Directory to save ONNX files - configs: Optional dict of {filename: config_override} - - Raises: - ValueError: If required inputs are missing - RuntimeError: If any export fails - """ - self.logger.info(f"Exporting {len(models)} models to {output_dir}") - os.makedirs(output_dir, exist_ok=True) - - configs = configs or {} - - for name, model in models.items(): - if name not in sample_inputs: - raise ValueError(f"No sample input provided for model: {name}") - - output_path = os.path.join(output_dir, name) - if not output_path.endswith(".onnx"): - output_path += ".onnx" - - config_override = configs.get(name) - try: - self.export( - model=model, - sample_input=sample_inputs[name], - output_path=output_path, - config_override=config_override, - ) - self.logger.info(f"✅ Exported {name}") - except Exception as exc: - self.logger.error(f"❌ Failed to export {name}") - raise - - self.logger.info(f"✅ All {len(models)} models exported successfully") - def _simplify_model(self, onnx_path: str) -> None: """ Simplify ONNX model using onnxsim. diff --git a/deployment/exporters/base/tensorrt_exporter.py b/deployment/exporters/base/tensorrt_exporter.py index cf2c8ab9a..d731f2ecc 100644 --- a/deployment/exporters/base/tensorrt_exporter.py +++ b/deployment/exporters/base/tensorrt_exporter.py @@ -86,7 +86,7 @@ def export( # Apply precision flags to builder config for flag_name, enabled in policy_flags.items(): if flag_name == "STRONGLY_TYPED": - continue # Already handled + 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") diff --git a/deployment/pipelines/base/base_pipeline.py b/deployment/pipelines/base/base_pipeline.py index d78b39e22..1ac205bbb 100644 --- a/deployment/pipelines/base/base_pipeline.py +++ b/deployment/pipelines/base/base_pipeline.py @@ -57,8 +57,6 @@ def __init__(self, model: Any, device: str = "cpu", task_type: str = "unknown", logger.info(f"Initialized {self.__class__.__name__} on device: {self.device}") - # ========== Abstract Methods (Must Implement) ========== - @abstractmethod def preprocess(self, input_data: Any, **kwargs) -> Any: """ @@ -109,8 +107,6 @@ def postprocess(self, model_output: Any, metadata: Dict = None) -> Any: """ pass - # ========== Concrete Methods (Shared Logic) ========== - def infer( self, input_data: Any, metadata: Optional[Dict] = None, return_raw_outputs: bool = False, **kwargs ) -> Tuple[Any, float, Dict[str, float]]: @@ -148,10 +144,10 @@ def infer( try: start_time = time.perf_counter() - # 1. Preprocess + # Preprocess preprocessed = self.preprocess(input_data, **kwargs) - # Unpack preprocess outputs: allow (data, metadata) tuple + # Unpack preprocess outputs preprocess_metadata = {} model_input = preprocessed if isinstance(preprocessed, tuple) and len(preprocessed) == 2 and isinstance(preprocessed[1], dict): @@ -160,18 +156,18 @@ def infer( preprocess_time = time.perf_counter() latency_breakdown["preprocessing_ms"] = (preprocess_time - start_time) * 1000 - # Merge caller metadata (if any) with preprocess metadata (preprocess takes precedence by default) + # Merge caller metadata with preprocess metadata merged_metadata = {} merged_metadata.update(metadata or {}) merged_metadata.update(preprocess_metadata) - # 2. Run model (backend-specific) + # Run model (backend-specific) model_start = time.perf_counter() model_output = self.run_model(model_input) model_time = time.perf_counter() latency_breakdown["model_ms"] = (model_time - model_start) * 1000 - # Merge stage-wise latencies if available (for multi-stage pipelines like CenterPoint) + # Merge stage-wise latencies if available if hasattr(self, "_stage_latencies") and isinstance(self._stage_latencies, dict): latency_breakdown.update(self._stage_latencies) # Clear for next inference @@ -179,7 +175,7 @@ def infer( total_latency = (time.perf_counter() - start_time) * 1000 - # 3. Postprocess (optional) + # Postprocess (optional) if return_raw_outputs: return model_output, total_latency, latency_breakdown else: diff --git a/deployment/runners/__init__.py b/deployment/runners/__init__.py index e6c3aee11..b1d687e9f 100644 --- a/deployment/runners/__init__.py +++ b/deployment/runners/__init__.py @@ -1,13 +1,13 @@ """Deployment runners for unified deployment workflow.""" -from deployment.runners.calibration_runner import CalibrationDeploymentRunner -from deployment.runners.centerpoint_runner import CenterPointDeploymentRunner -from deployment.runners.deployment_runner import BaseDeploymentRunner -from deployment.runners.yolox_runner import YOLOXDeploymentRunner +# from deployment.runners.calibration_runner import CalibrationDeploymentRunner +# from deployment.runners.centerpoint_runner import CenterPointDeploymentRunner +# from deployment.runners.deployment_runner import BaseDeploymentRunner +# from deployment.runners.yolox_runner import YOLOXDeploymentRunner -__all__ = [ - "BaseDeploymentRunner", - "CenterPointDeploymentRunner", - "YOLOXDeploymentRunner", - "CalibrationDeploymentRunner", -] +# __all__ = [ +# "BaseDeploymentRunner", +# "CenterPointDeploymentRunner", +# "YOLOXDeploymentRunner", +# "CalibrationDeploymentRunner", +# ] diff --git a/deployment/runners/deployment_runner.py b/deployment/runners/deployment_runner.py index 471b17cd4..c375efd0c 100644 --- a/deployment/runners/deployment_runner.py +++ b/deployment/runners/deployment_runner.py @@ -12,12 +12,7 @@ import torch from mmengine.config import Config -from deployment.core import ( - BaseDataLoader, - BaseDeploymentConfig, - BaseEvaluator, - ModelSpec, -) +from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig, BaseEvaluator, ModelSpec class BaseDeploymentRunner: @@ -75,6 +70,7 @@ def __init__( self.logger = logger self._onnx_exporter = onnx_exporter self._tensorrt_exporter = tensorrt_exporter + self.artifacts: Dict[str, Artifact] = {} def load_pytorch_model(self, checkpoint_path: str, **kwargs) -> Any: """ @@ -94,7 +90,7 @@ def load_pytorch_model(self, checkpoint_path: str, **kwargs) -> Any: """ raise NotImplementedError(f"{self.__class__.__name__}.load_pytorch_model() must be implemented by subclasses.") - def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: + def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[Artifact]: """ Export model to ONNX format. @@ -105,26 +101,18 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: **kwargs: Additional project-specific arguments Returns: - Path to exported ONNX file/directory, or None if export failed + Artifact describing the exported ONNX output, or None if skipped """ - # Standard ONNX export using ONNXExporter if not self.config.export_config.should_export_onnx(): return None - self.logger.info("=" * 80) - self.logger.info("Exporting to ONNX (Using Unified ONNXExporter)") - self.logger.info("=" * 80) - - # Get ONNX settings onnx_settings = self.config.get_onnx_settings() - # Use provided exporter (required, cannot be None) exporter = self._onnx_exporter self.logger.info("=" * 80) self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") self.logger.info("=" * 80) - # Standard ONNX export # Save to work_dir/onnx/ directory onnx_dir = os.path.join(self.config.export_config.work_dir, "onnx") os.makedirs(onnx_dir, exist_ok=True) @@ -153,19 +141,20 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[str]: input_tensor = single_input.repeat(batch_size, *([1] * (len(single_input.shape) - 1))) self.logger.info(f"Using fixed batch size: {batch_size}") - # Use provided exporter (required, cannot be None) - exporter = self._onnx_exporter - try: exporter.export(pytorch_model, input_tensor, output_path) except Exception: - self.logger.error("❌ ONNX export failed") + self.logger.error("ONNX export failed") raise - self.logger.info(f"✅ ONNX export successful: {output_path}") - return 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.artifacts["onnx"] = artifact + self.logger.info(f"ONNX export successful: {artifact.path}") + return artifact - def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: + def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: """ Export ONNX model to TensorRT engine. @@ -176,9 +165,8 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: **kwargs: Additional project-specific arguments Returns: - Path to exported TensorRT engine file/directory, or None if export failed + Artifact describing the exported TensorRT output, or None if skipped """ - # Standard TensorRT export using TensorRTExporter if not self.config.export_config.should_export_tensorrt(): return None @@ -186,24 +174,19 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: self.logger.warning("ONNX path not available, skipping TensorRT export") return None - # Use provided exporter (required, cannot be None) exporter = self._tensorrt_exporter self.logger.info("=" * 80) self.logger.info(f"Exporting to TensorRT (Using {type(exporter).__name__})") self.logger.info("=" * 80) - # Standard TensorRT export # Save to work_dir/tensorrt/ directory tensorrt_dir = os.path.join(self.config.export_config.work_dir, "tensorrt") os.makedirs(tensorrt_dir, exist_ok=True) # Determine output path based on ONNX file name if os.path.isdir(onnx_path): - # For multi-file ONNX (shouldn't happen in standard export, but handle it) - # Use the directory name or a default name output_path = os.path.join(tensorrt_dir, "model.engine") else: - # Single file: extract filename and change extension onnx_filename = os.path.basename(onnx_path) engine_filename = onnx_filename.replace(".onnx", ".engine") output_path = os.path.join(tensorrt_dir, engine_filename) @@ -222,28 +205,25 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[str]: if isinstance(sample_input, (list, tuple)): sample_input = sample_input[0] # Use first input for shape - # Note: trt_settings are read from exporter.config in TensorRTExporter._configure_input_shapes - # The exporter's config should already include model_inputs if backend_config is properly set up - # No need to pass trt_settings here as the exporter reads from self.config - - # Use provided exporter (required, cannot be None) exporter = self._tensorrt_exporter try: exporter.export( - model=None, # Not used for TensorRT + model=None, sample_input=sample_input, output_path=output_path, onnx_path=onnx_path, ) except Exception: - self.logger.error("❌ TensorRT export failed") + self.logger.error("TensorRT export failed") raise - self.logger.info(f"✅ TensorRT export successful: {output_path}") - return output_path + artifact = Artifact(path=output_path, multi_file=False) + self.artifacts["tensorrt"] = artifact + self.logger.info(f"TensorRT export successful: {artifact.path}") + return artifact - def _resolve_pytorch_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str], bool]: + def _resolve_pytorch_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[Artifact], bool]: """ Resolve PyTorch model path from backend config. @@ -251,16 +231,56 @@ def _resolve_pytorch_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[ backend_cfg: Backend configuration dictionary Returns: - Tuple of (model_path, is_valid) + Tuple of (artifact, is_valid). + artifact is an `Artifact` instance if a path could be resolved, otherwise None. """ - model_path = backend_cfg.get("checkpoint") - if model_path: - is_valid = os.path.exists(model_path) and os.path.isfile(model_path) - else: - is_valid = False - return model_path, is_valid + artifact = self.artifacts.get("pytorch") + if artifact: + return artifact, artifact.exists() + + model_path = backend_cfg.get("checkpoint") or self.config.export_config.checkpoint_path + if not model_path: + return None, False + + artifact = Artifact(path=model_path, multi_file=False) + return artifact, artifact.exists() + + def _artifact_from_path(self, backend: str, path: str) -> Artifact: + existing = self.artifacts.get(backend) + if existing and existing.path == path: + return existing + + multi_file = os.path.isdir(path) if path and os.path.exists(path) else False + return Artifact(path=path, multi_file=multi_file) + + def _build_model_spec(self, backend: str, artifact: Artifact, device: str) -> ModelSpec: + return ModelSpec( + backend=backend, + device=device, + artifact=artifact, + ) + + def _normalize_device_for_backend(self, backend: str, device: Optional[str]) -> str: + normalized_device = str(device or "cpu") - def _resolve_onnx_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str], bool]: + if backend in ("pytorch", "onnx"): + if normalized_device not in ("cpu",) and not normalized_device.startswith("cuda"): + self.logger.warning( + f"Unsupported device '{normalized_device}' for backend '{backend}'. Falling back to CPU." + ) + normalized_device = "cpu" + elif backend == "tensorrt": + if not normalized_device or normalized_device == "cpu": + normalized_device = self.config.export_config.cuda_device or "cuda:0" + if not normalized_device.startswith("cuda"): + self.logger.warning( + f"TensorRT evaluation requires CUDA device. Overriding device from '{normalized_device}' to 'cuda:0'." + ) + normalized_device = "cuda:0" + + return normalized_device + + def _resolve_onnx_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[Artifact], bool]: """ Resolve ONNX model path from backend config. @@ -268,79 +288,21 @@ def _resolve_onnx_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str backend_cfg: Backend configuration dictionary Returns: - Tuple of (model_path, is_valid) + Tuple of (artifact, is_valid). + artifact is an `Artifact` instance if a path could be resolved, otherwise None. """ - model_path = backend_cfg.get("model_dir") - multi_file = self.config.onnx_config.get("multi_file", False) - save_file = self.config.onnx_config.get("save_file", "model.onnx") - - # If model_dir is explicitly set in config - if model_path is not None: - if os.path.exists(model_path): - if os.path.isfile(model_path): - # Single file ONNX - is_valid = model_path.endswith(".onnx") and not multi_file - elif os.path.isdir(model_path): - # Directory: valid if multi_file is True, or if it contains ONNX files - if multi_file: - is_valid = True - else: - # Single file mode: find the ONNX file in directory - onnx_files = [f for f in os.listdir(model_path) if f.endswith(".onnx")] - if onnx_files: - expected_file = os.path.join(model_path, save_file) - if os.path.exists(expected_file): - model_path = expected_file - else: - model_path = os.path.join(model_path, onnx_files[0]) - is_valid = True - else: - is_valid = False - else: - is_valid = False - else: - is_valid = False - return model_path, is_valid - - # Infer from export config - work_dir = self.config.export_config.work_dir - onnx_dir = os.path.join(work_dir, "onnx") - - if os.path.exists(onnx_dir) and os.path.isdir(onnx_dir): - onnx_files = [f for f in os.listdir(onnx_dir) if f.endswith(".onnx")] - if onnx_files: - if multi_file: - model_path = onnx_dir - is_valid = True - else: - # Single file ONNX: use the save_file if it exists, otherwise use the first ONNX file found - expected_file = os.path.join(onnx_dir, save_file) - if os.path.exists(expected_file): - model_path = expected_file - else: - model_path = os.path.join(onnx_dir, onnx_files[0]) - is_valid = True - else: - if multi_file: - model_path = onnx_dir - is_valid = True - else: - # Try single file path - model_path = os.path.join(onnx_dir, save_file) - is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") - else: - if multi_file: - # Multi-file ONNX: return directory even if it doesn't exist yet - model_path = onnx_dir - is_valid = True - else: - # Fallback: try in work_dir directly (for backward compatibility) - model_path = os.path.join(work_dir, save_file) - is_valid = os.path.exists(model_path) and model_path.endswith(".onnx") + artifact = self.artifacts.get("onnx") + if artifact: + return artifact, artifact.exists() + + explicit_path = backend_cfg.get("model_dir") or self.config.export_config.onnx_path + if explicit_path: + fallback_artifact = Artifact(path=explicit_path, multi_file=os.path.isdir(explicit_path)) + return fallback_artifact, fallback_artifact.exists() - return model_path, is_valid + return None, False - def _resolve_tensorrt_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[str], bool]: + def _resolve_tensorrt_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[Artifact], bool]: """ Resolve TensorRT model path from backend config. @@ -348,95 +310,26 @@ def _resolve_tensorrt_model(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional backend_cfg: Backend configuration dictionary Returns: - Tuple of (model_path, is_valid) + Tuple of (artifact, is_valid). + artifact is an `Artifact` instance if a path could be resolved, otherwise None. """ - model_path = backend_cfg.get("engine_dir") - multi_file = self.config.onnx_config.get("multi_file", False) - onnx_save_file = self.config.onnx_config.get("save_file", "model.onnx") - expected_engine = onnx_save_file.replace(".onnx", ".engine") - - # If engine_dir is explicitly set in config - if model_path is not None: - if os.path.exists(model_path): - if os.path.isfile(model_path): - # Single file TensorRT - is_valid = (model_path.endswith(".engine") or model_path.endswith(".trt")) and not multi_file - elif os.path.isdir(model_path): - # Directory: valid if multi_file is True, or if it contains engine files - if multi_file: - is_valid = True - else: - # Single file mode: find the engine file in directory - engine_files = [f for f in os.listdir(model_path) if f.endswith(".engine")] - if engine_files: - expected_path = os.path.join(model_path, expected_engine) - if os.path.exists(expected_path): - model_path = expected_path - else: - model_path = os.path.join(model_path, engine_files[0]) - is_valid = True - else: - is_valid = False - else: - is_valid = False - else: - is_valid = False - return model_path, is_valid - - # Infer from export config - work_dir = self.config.export_config.work_dir - engine_dir = os.path.join(work_dir, "tensorrt") - - if os.path.exists(engine_dir) and os.path.isdir(engine_dir): - engine_files = [f for f in os.listdir(engine_dir) if f.endswith(".engine")] - if engine_files: - if multi_file: - model_path = engine_dir - is_valid = True - else: - # Single file TensorRT: use the engine file matching ONNX filename - expected_path = os.path.join(engine_dir, expected_engine) - if os.path.exists(expected_path): - model_path = expected_path - else: - # Fallback: use the first engine file found - model_path = os.path.join(engine_dir, engine_files[0]) - is_valid = True - else: - if multi_file: - model_path = engine_dir - is_valid = True - else: - is_valid = False - else: - if multi_file: - # Multi-file TensorRT: return directory even if it doesn't exist yet - model_path = engine_dir - is_valid = True - else: - # Fallback: try in work_dir directly (for backward compatibility) - if os.path.exists(work_dir) and os.path.isdir(work_dir): - engine_files = [f for f in os.listdir(work_dir) if f.endswith(".engine")] - if engine_files: - expected_path = os.path.join(work_dir, expected_engine) - if os.path.exists(expected_path): - model_path = expected_path - else: - model_path = os.path.join(work_dir, engine_files[0]) - is_valid = True - else: - is_valid = False - else: - is_valid = False + artifact = self.artifacts.get("tensorrt") + if artifact: + return artifact, artifact.exists() + + explicit_path = backend_cfg.get("engine_dir") or self.config.export_config.tensorrt_path + if explicit_path: + fallback_artifact = Artifact(path=explicit_path, multi_file=os.path.isdir(explicit_path)) + return fallback_artifact, fallback_artifact.exists() - return model_path, is_valid + return None, False def get_models_to_evaluate(self) -> List[ModelSpec]: """ Get list of models to evaluate from config. Returns: - List of tuples (backend_name, model_path, device) + List of `ModelSpec` instances describing models to evaluate. """ backends = self.config.get_evaluation_backends() models_to_evaluate: List[ModelSpec] = [] @@ -445,29 +338,23 @@ def get_models_to_evaluate(self) -> List[ModelSpec]: if not backend_cfg.get("enabled", False): continue - device = backend_cfg.get("device", "cpu") - model_path = None + device = str(backend_cfg.get("device", "cpu") or "cpu") + artifact: Optional[Artifact] = None is_valid = False if backend_name == "pytorch": - model_path, is_valid = self._resolve_pytorch_model(backend_cfg) + artifact, is_valid = self._resolve_pytorch_artifact(backend_cfg) elif backend_name == "onnx": - model_path, is_valid = self._resolve_onnx_model(backend_cfg) + artifact, is_valid = self._resolve_onnx_artifact(backend_cfg) elif backend_name == "tensorrt": - model_path, is_valid = self._resolve_tensorrt_model(backend_cfg) - - if is_valid and model_path: - normalized_device = str(device or "cpu") - models_to_evaluate.append( - ModelSpec( - backend=backend_name, - device=normalized_device, - path=model_path, - ) - ) - self.logger.info(f" - {backend_name}: {model_path} (device: {normalized_device})") - elif model_path: - self.logger.warning(f" - {backend_name}: {model_path} (not found or invalid, skipping)") + artifact, is_valid = self._resolve_tensorrt_artifact(backend_cfg) + + if is_valid and artifact: + spec = self._build_model_spec(backend_name, artifact, device) + models_to_evaluate.append(spec) + self.logger.info(f" - {backend_name}: {artifact.path} (device: {device})") + elif artifact is not None: + self.logger.warning(f" - {backend_name}: {artifact.path} (not found or invalid, skipping)") return models_to_evaluate @@ -558,19 +445,26 @@ def run_verification( ref_path = pytorch_checkpoint elif ref_backend == "onnx": ref_path = onnx_path + elif ref_backend == "tensorrt": + ref_path = tensorrt_path if test_backend == "onnx": test_path = onnx_path elif test_backend == "tensorrt": test_path = tensorrt_path + elif test_backend == "pytorch": + test_path = pytorch_checkpoint if not ref_path or not test_path: self.logger.warning(f" Skipping: missing paths (ref={ref_path}, test={test_path})") continue + ref_artifact = self._artifact_from_path(ref_backend, ref_path) + test_artifact = self._artifact_from_path(test_backend, test_path) + # Use policy-based verification interface - reference_spec = ModelSpec(backend=ref_backend, device=ref_device, path=ref_path) - test_spec = ModelSpec(backend=test_backend, device=test_device, path=test_path) + reference_spec = self._build_model_spec(ref_backend, ref_artifact, ref_device) + test_spec = self._build_model_spec(test_backend, test_artifact, test_device) verification_results = self.evaluator.verify( reference=reference_spec, @@ -593,16 +487,16 @@ def run_verification( total_failed += failed if failed == 0: - self.logger.info(f" ✅ Policy {i+1} passed ({passed} comparisons)") + self.logger.info(f"Policy {i+1} passed ({passed} comparisons)") else: - self.logger.warning(f" ⚠️ Policy {i+1} failed ({failed}/{passed+failed} comparisons)") + self.logger.warning(f"Policy {i+1} failed ({failed}/{passed+failed} comparisons)") # Overall summary self.logger.info("\n" + "=" * 80) if total_failed == 0: - self.logger.info(f"✅ All verifications passed! ({total_passed} total)") + 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.warning(f"{total_failed}/{total_passed + total_failed} verifications failed") self.logger.info("=" * 80) all_results["summary"] = { @@ -647,32 +541,11 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: all_results: Dict[str, Any] = {} - # TODO(vividf): a bit ungly here, need to refactor for spec in models_to_evaluate: backend = spec.backend - backend_device = spec.device - model_path = spec.path - - if backend in ("pytorch", "onnx"): - if backend_device not in ("cpu",) and not str(backend_device).startswith("cuda"): - self.logger.warning( - f"Unsupported device '{backend_device}' for backend '{backend}'. Falling back to CPU." - ) - backend_device = "cpu" - elif backend == "tensorrt": - if not backend_device or backend_device == "cpu": - backend_device = self.config.export_config.cuda_device or "cuda:0" - if not str(backend_device).startswith("cuda"): - self.logger.warning( - f"TensorRT evaluation requires CUDA device. Overriding device from '{backend_device}' to 'cuda:0'." - ) - backend_device = "cuda:0" - - normalized_spec = ModelSpec( - backend=backend, - device=backend_device or "cpu", - path=model_path, - ) + backend_device = self._normalize_device_for_backend(backend, spec.device) + + normalized_spec = self._build_model_spec(backend, spec.artifact, backend_device) results = self.evaluator.evaluate( model=normalized_spec, @@ -780,6 +653,7 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] try: pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) results["pytorch_model"] = pytorch_model + self.artifacts["pytorch"] = Artifact(path=checkpoint_path) # Single-direction injection: write model to evaluator via setter (never read from it) if hasattr(self.evaluator, "set_pytorch_model"): @@ -799,6 +673,7 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] try: pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) results["pytorch_model"] = pytorch_model + self.artifacts["pytorch"] = Artifact(path=checkpoint_path) # Single-direction injection: write model to evaluator via setter (never read from it) if hasattr(self.evaluator, "set_pytorch_model"): @@ -809,24 +684,28 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] return results try: - onnx_path = self.export_onnx(pytorch_model, **kwargs) - results["onnx_path"] = onnx_path + onnx_artifact = self.export_onnx(pytorch_model, **kwargs) + if onnx_artifact: + results["onnx_path"] = onnx_artifact.path except Exception as e: self.logger.error(f"Failed to export ONNX: {e}") # Export TensorRT if requested if should_export_trt: - onnx_source = results["onnx_path"] or external_onnx_path - if not onnx_source: + onnx_path = results["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 results else: - results["onnx_path"] = onnx_source # Ensure verification/evaluation can use this path + results["onnx_path"] = onnx_path # Ensure verification/evaluation can use this path + if onnx_path and os.path.exists(onnx_path): + self.artifacts["onnx"] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) try: - tensorrt_path = self.export_tensorrt(onnx_source, **kwargs) - results["tensorrt_path"] = tensorrt_path + tensorrt_artifact = self.export_tensorrt(onnx_path, **kwargs) + if tensorrt_artifact: + results["tensorrt_path"] = tensorrt_artifact.path except Exception as e: self.logger.error(f"Failed to export TensorRT: {e}") @@ -837,12 +716,14 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] onnx_path = eval_models.get("onnx") if onnx_path and os.path.exists(onnx_path): results["onnx_path"] = onnx_path + self.artifacts["onnx"] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) elif onnx_path: self.logger.warning(f"ONNX file from config does not exist: {onnx_path}") if not results["tensorrt_path"]: tensorrt_path = eval_models.get("tensorrt") if tensorrt_path and os.path.exists(tensorrt_path): results["tensorrt_path"] = tensorrt_path + self.artifacts["tensorrt"] = Artifact(path=tensorrt_path, multi_file=os.path.isdir(tensorrt_path)) elif tensorrt_path: self.logger.warning(f"TensorRT engine from config does not exist: {tensorrt_path}") From 431791864cfd8402071a82400eeb7699ced5b108 Mon Sep 17 00:00:00 2001 From: vividf Date: Wed, 19 Nov 2025 18:18:19 +0900 Subject: [PATCH 10/62] chore: clean extract pipeline config Signed-off-by: vividf --- deployment/core/preprocessing_builder.py | 29 +++++++----------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/deployment/core/preprocessing_builder.py b/deployment/core/preprocessing_builder.py index 054bcf047..57d5eb915 100644 --- a/deployment/core/preprocessing_builder.py +++ b/deployment/core/preprocessing_builder.py @@ -247,26 +247,13 @@ def _extract_pipeline_config(model_cfg: Config) -> List[TransformConfig]: Raises: ValueError: If no valid pipeline found """ - pipeline_paths = [ - ("test_dataloader", "dataset", "pipeline"), - ("test_dataloader", "dataset", "dataset", "pipeline"), # nested dataset wrappers - ("test_pipeline",), - ] - - for path in pipeline_paths: - cfg = model_cfg - try: - for key in path: - cfg = cfg[key] - except (KeyError, TypeError): - continue + 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 cfg: - logger.info("Found test pipeline at: %s", ".".join(path)) - return cfg + if not pipeline_cfg: + raise ValueError("test_pipeline is defined but empty. Please provide a valid test pipeline.") - raise ValueError( - "No test pipeline found in config. " - "Expected pipeline at one of: test_dataloader.dataset.pipeline, " - "test_dataloader.dataset.dataset.pipeline, test_pipeline." - ) + logger.info("Found test pipeline at: test_pipeline") + return pipeline_cfg From 900f9745760daa28e62c64583d1c8e97b6acb847 Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 20 Nov 2025 12:16:41 +0900 Subject: [PATCH 11/62] chore: refactor dataloader, evaluator, exporter Signed-off-by: vividf --- deployment/README.md | 89 +++++- deployment/core/__init__.py | 10 + deployment/core/backend.py | 43 +++ deployment/core/base_config.py | 226 ++++++++++--- deployment/core/base_data_loader.py | 6 +- deployment/core/base_evaluator.py | 9 +- deployment/core/preprocessing_builder.py | 2 +- deployment/exporters/__init__.py | 39 +-- deployment/exporters/base/base_exporter.py | 12 +- deployment/exporters/base/configs.py | 155 +++++++++ deployment/exporters/base/model_wrappers.py | 2 +- deployment/exporters/base/onnx_exporter.py | 156 ++++++--- .../exporters/base/tensorrt_exporter.py | 298 ++++++++++++++---- deployment/pipelines/base/base_pipeline.py | 6 +- deployment/runners/__init__.py | 15 +- deployment/runners/deployment_runner.py | 172 ++++++---- 16 files changed, 952 insertions(+), 288 deletions(-) create mode 100644 deployment/core/backend.py create mode 100644 deployment/exporters/base/configs.py diff --git a/deployment/README.md b/deployment/README.md index 43a97b9bc..be5ba373f 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -85,16 +85,18 @@ The AWML Deployment Framework provides a standardized approach to model deployme Exporters receive their corresponding `model_wrapper` during construction. Runners never implicitly create exporters/wrappers—everything is injected for clarity and testability. -#### 2. **Base Classes** +#### 2. **Core Components** (in `core/`) - **`BaseDeploymentConfig`**: Configuration container for deployment settings +- **`Backend`**: Enum for supported backends (PyTorch, ONNX, TensorRT) +- **`Artifact`**: Dataclass representing deployment artifacts (ONNX/TensorRT outputs) - **`BaseEvaluator`**: Abstract interface for task-specific evaluation - **`BaseDataLoader`**: Abstract interface for data loading +- **`build_preprocessing_pipeline`**: Utility to extract preprocessing pipelines from MMDet/MMDet3D configs - **`BaseDeploymentPipeline`**: Abstract pipeline for inference (in `pipelines/base/`) - **`Detection2DPipeline`**: Base pipeline for 2D detection tasks - **`Detection3DPipeline`**: Base pipeline for 3D detection tasks - **`ClassificationPipeline`**: Base pipeline for classification tasks -- **`build_preprocessing_pipeline`**: Utility to extract preprocessing pipelines from MMDet/MMDet3D configs #### 3. **Exporters** @@ -104,10 +106,16 @@ Exporters receive their corresponding `model_wrapper` during construction. Runne - `{model}/model_wrappers.py`: Project-specific model wrapper - **Base Exporters** (in `exporters/base/`): + - **`BaseExporter`**: Abstract base class for all exporters - **`ONNXExporter`**: Standard ONNX export with model wrapping support - **`TensorRTExporter`**: TensorRT engine building with precision policies - **`BaseModelWrapper`**: Abstract base class for model wrappers - **`IdentityWrapper`**: Provided wrapper that doesn't modify model output + - **`configs.py`**: Typed configuration classes: + - **`ONNXExportConfig`**: Typed schema for ONNX exporter configuration + - **`TensorRTExportConfig`**: Typed schema for TensorRT exporter configuration + - **`TensorRTModelInputConfig`**: Configuration for TensorRT input shapes + - **`TensorRTProfileConfig`**: Optimization profile configuration for dynamic shapes - **Project-Specific Exporters**: - **YOLOX** (`exporters/yolox/`): @@ -213,20 +221,17 @@ Support for different TensorRT precision modes: # CenterPoint deployment python projects/CenterPoint/deploy/main.py \ configs/deploy_config.py \ - configs/model_config.py \ - checkpoint.pth + configs/model_config.py # YOLOX deployment python projects/YOLOX_opt_elan/deploy/main.py \ configs/deploy_config.py \ - configs/model_config.py \ - checkpoint.pth + configs/model_config.py # Calibration deployment python projects/CalibrationStatusClassification/deploy/main.py \ configs/deploy_config.py \ - configs/model_config.py \ - checkpoint.pth + configs/model_config.py ``` ### Creating a Project Runner @@ -401,6 +406,67 @@ evaluation = dict( ) ``` +#### Backend Enum + +To avoid backend name typos, `deployment.core.Backend` enumerates the supported values: + +```python +from deployment.core import Backend + +evaluation = dict( + backends={ + Backend.PYTORCH: {"enabled": True, "device": "cpu"}, + Backend.ONNX: {"enabled": True, "device": "cpu"}, + Backend.TENSORRT: {"enabled": True, "device": "cuda:0"}, + } +) +``` + +Configuration dictionaries accept either raw strings or `Backend` enum members, so teams can adopt the enum incrementally without breaking existing configs. + +#### Typed Exporter Configurations + +The framework provides typed configuration classes in `deployment.exporters.base.configs` for better type safety and validation: + +```python +from deployment.exporters.base.configs import ( + ONNXExportConfig, + TensorRTExportConfig, + TensorRTModelInputConfig, + TensorRTProfileConfig, +) + +# ONNX configuration with typed schema +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, +) + +# TensorRT configuration with typed schema +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), + ) + } + ), + ), +) +``` + +These typed configs can be created from dictionaries using `from_mapping()` or `from_dict()` class methods, providing a bridge between configuration files and type-safe code. + ### Configuration Examples See project-specific configs: @@ -655,8 +721,10 @@ evaluation = dict( ``` deployment/ -├── core/ # Core base classes -│ ├── base_config.py # Configuration management +├── core/ # Core base classes and utilities +│ ├── artifacts.py # Artifact descriptors (ONNX/TensorRT outputs) +│ ├── backend.py # Backend enum (PyTorch, ONNX, TensorRT) +│ ├── base_config.py # Configuration management │ ├── base_data_loader.py # Data loader interface │ ├── base_evaluator.py # Evaluator interface │ └── preprocessing_builder.py # Preprocessing pipeline builder @@ -664,6 +732,7 @@ deployment/ ├── exporters/ # Model exporters (unified structure) │ ├── base/ # Base exporter classes │ │ ├── base_exporter.py # Exporter base class +│ │ ├── configs.py # Typed configuration classes (ONNXExportConfig, TensorRTExportConfig) │ │ ├── onnx_exporter.py # ONNX exporter base class │ │ ├── tensorrt_exporter.py # TensorRT exporter base class │ │ └── model_wrappers.py # Base model wrappers (BaseModelWrapper, IdentityWrapper) diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py index 60db08aa3..87fd86cba 100644 --- a/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -1,11 +1,16 @@ """Core components for deployment framework.""" from deployment.core.artifacts import Artifact +from deployment.core.backend import Backend from deployment.core.base_config import ( BackendConfig, BaseDeploymentConfig, + EvaluationConfig, ExportConfig, + ExportMode, RuntimeConfig, + VerificationConfig, + VerificationScenario, parse_base_args, setup_logging, ) @@ -19,10 +24,15 @@ from deployment.core.preprocessing_builder import build_preprocessing_pipeline __all__ = [ + "Backend", "BaseDeploymentConfig", "ExportConfig", + "ExportMode", "RuntimeConfig", "BackendConfig", + "EvaluationConfig", + "VerificationConfig", + "VerificationScenario", "setup_logging", "parse_base_args", "BaseDataLoader", diff --git a/deployment/core/backend.py b/deployment/core/backend.py new file mode 100644 index 000000000..584a15d7a --- /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/base_config.py b/deployment/core/base_config.py index 8aa517419..06d0b328f 100644 --- a/deployment/core/base_config.py +++ b/deployment/core/base_config.py @@ -9,15 +9,28 @@ import logging from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional +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.base.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.""" @@ -28,6 +41,29 @@ class PrecisionPolicy(str, Enum): STRONGLY_TYPED = "strongly_typed" +class ExportMode(str, Enum): + """Export workflow 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 @@ -38,11 +74,11 @@ class PrecisionPolicy(str, Enum): } -@dataclass +@dataclass(frozen=True) class ExportConfig: """Configuration for model export settings.""" - mode: str = "both" + mode: ExportMode = ExportMode.BOTH work_dir: str = "work_dirs" checkpoint_path: Optional[str] = None onnx_path: Optional[str] = None @@ -50,13 +86,13 @@ class ExportConfig: cuda_device: str = "cuda:0" def __post_init__(self) -> None: - self.cuda_device = self._parse_cuda_device(self.cuda_device) + object.__setattr__(self, "cuda_device", self._parse_cuda_device(self.cuda_device)) @classmethod def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": """Create ExportConfig from dict.""" return cls( - mode=config_dict.get("mode", cls.mode), + mode=ExportMode.from_value(config_dict.get("mode", ExportMode.BOTH)), work_dir=config_dict.get("work_dir", cls.work_dir), checkpoint_path=config_dict.get("checkpoint_path"), onnx_path=config_dict.get("onnx_path"), @@ -66,11 +102,11 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": def should_export_onnx(self) -> bool: """Check if ONNX export is requested.""" - return self.mode in ["onnx", "both"] + return self.mode in (ExportMode.ONNX, ExportMode.BOTH) def should_export_tensorrt(self) -> bool: """Check if TensorRT export is requested.""" - return self.mode in ["trt", "both"] + return self.mode in (ExportMode.TRT, ExportMode.BOTH) @staticmethod def _parse_cuda_device(device: Optional[str]) -> str: @@ -112,15 +148,15 @@ def get_cuda_device_index(self) -> int: return int(self.cuda_device.split(":", 1)[1]) -@dataclass +@dataclass(frozen=True) class RuntimeConfig: """Configuration for runtime I/O settings.""" - data: Dict[str, Any] + data: Mapping[str, Any] @classmethod def from_dict(cls, config_dict: Dict[str, Any]) -> "RuntimeConfig": - return cls(config_dict) + return cls(MappingProxyType(dict(config_dict))) def get(self, key: str, default: Any = None) -> Any: """Get a runtime configuration value.""" @@ -131,18 +167,23 @@ def __getitem__(self, key: str) -> Any: return self.data[key] -@dataclass +@dataclass(frozen=True) class BackendConfig: """Configuration for backend-specific settings.""" - common_config: Dict[str, Any] = field(default_factory=dict) - model_inputs: List[Dict[str, Any]] = field(default_factory=list) + 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: Dict[str, Any]) -> "BackendConfig": + common_config = dict(config_dict.get("common_config", {})) + model_inputs_raw: Iterable[Dict[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=config_dict.get("common_config", {}), - model_inputs=config_dict.get("model_inputs", []), + common_config=MappingProxyType(common_config), + model_inputs=model_inputs, ) def get_precision_policy(self) -> str: @@ -159,6 +200,87 @@ def get_max_workspace_size(self) -> int: 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: Dict[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 +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: Dict[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: Dict[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. @@ -181,6 +303,8 @@ def __init__(self, deploy_cfg: Config): 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() @@ -193,10 +317,10 @@ def _validate_config(self) -> None: ) # Validate export mode - valid_modes = ["onnx", "trt", "both", "none"] - mode = self.deploy_cfg.get("export", {}).get("mode", "both") - if mode not in valid_modes: - raise ValueError(f"Invalid export mode '{mode}'. Must be one of {valid_modes}") + 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", {}) @@ -232,25 +356,25 @@ def _needs_cuda_device(self) -> bool: if self.export_config.should_export_tensorrt(): return True - evaluation_cfg = self.deploy_cfg.get("evaluation", {}) - backends_cfg = evaluation_cfg.get("backends", {}) - tensorrt_backend = backends_cfg.get("tensorrt", {}) - if tensorrt_backend.get("enabled", False): + 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.deploy_cfg.get("verification", {}) - scenarios_cfg = verification_cfg.get("scenarios", {}) - for scenario_list in scenarios_cfg.values(): + verification_cfg = self.verification_config + + for scenario_list in verification_cfg.scenarios.values(): for scenario in scenario_list: - if scenario.get("ref_backend") == "tensorrt" or scenario.get("test_backend") == "tensorrt": + if Backend.TENSORRT in (scenario.ref_backend, scenario.test_backend): return True return False @property - def evaluation_config(self) -> Dict: + def evaluation_config(self) -> EvaluationConfig: """Get evaluation configuration.""" - return self.deploy_cfg.get("evaluation", {}) + return self._evaluation_config @property def onnx_config(self) -> Dict: @@ -258,9 +382,9 @@ def onnx_config(self) -> Dict: return self.deploy_cfg.get("onnx_config", {}) @property - def verification_config(self) -> Dict: + def verification_config(self) -> VerificationConfig: """Get verification configuration.""" - return self.deploy_cfg.get("verification", {}) + return self._verification_config def get_evaluation_backends(self) -> Dict[str, Dict[str, Any]]: """ @@ -269,34 +393,31 @@ def get_evaluation_backends(self) -> Dict[str, Dict[str, Any]]: Returns: Dictionary mapping backend names to their configuration """ - eval_config = self.evaluation_config - return eval_config.get("backends", {}) + return self.evaluation_config.backends - def get_verification_scenarios(self, export_mode: str) -> List[Dict[str, str]]: + def get_verification_scenarios(self, export_mode: ExportMode) -> Tuple[VerificationScenario, ...]: """ Get verification scenarios for the given export mode. Args: - export_mode: Export mode ('onnx', 'trt', 'both', 'none') + export_mode: Export mode (`ExportMode`) Returns: - List of verification scenarios dictionaries + Tuple of verification scenarios """ - verification_cfg = self.verification_config - scenarios = verification_cfg.get("scenarios", {}) - return scenarios.get(export_mode, []) + 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) -> Dict[str, Any]: + def get_onnx_settings(self) -> ONNXExportConfig: """ Get ONNX export settings. Returns: - Dictionary containing ONNX export parameters + ONNXExportConfig instance containing ONNX export parameters """ onnx_config = self.onnx_config model_io = self.deploy_cfg.get("model_io", {}) @@ -325,38 +446,39 @@ def get_onnx_settings(self) -> Dict[str, Any]: if isinstance(additional_output, str): output_names.append(additional_output) - settings = { + settings_dict = { "opset_version": onnx_config.get("opset_version", 16), "do_constant_folding": onnx_config.get("do_constant_folding", True), - "input_names": input_names, - "output_names": output_names, + "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"), - "decode_in_inference": onnx_config.get("decode_in_inference", True), "batch_size": batch_size, } - # Include model_wrapper config if present in onnx_config - if "model_wrapper" in onnx_config: - settings["model_wrapper"] = onnx_config["model_wrapper"] + # Note: simplify is typically True by default, but can be overridden + if "simplify" in onnx_config: + settings_dict["simplify"] = onnx_config["simplify"] - return settings + return ONNXExportConfig.from_mapping(settings_dict) - def get_tensorrt_settings(self) -> Dict[str, Any]: + def get_tensorrt_settings(self) -> TensorRTExportConfig: """ Get TensorRT export settings with precision policy support. Returns: - Dictionary containing TensorRT export parameters + TensorRTExportConfig instance containing TensorRT export parameters """ - return { + 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: diff --git a/deployment/core/base_data_loader.py b/deployment/core/base_data_loader.py index 80713c597..5e8adc910 100644 --- a/deployment/core/base_data_loader.py +++ b/deployment/core/base_data_loader.py @@ -63,7 +63,7 @@ def load_sample(self, index: int) -> SampleData: IndexError: If index is out of range FileNotFoundError: If sample data files don't exist """ - pass + raise NotImplementedError @abstractmethod def preprocess(self, sample: SampleData) -> torch.Tensor: @@ -80,7 +80,7 @@ def preprocess(self, sample: SampleData) -> torch.Tensor: Raises: ValueError: If sample format is invalid """ - pass + raise NotImplementedError @abstractmethod def get_num_samples(self) -> int: @@ -90,4 +90,4 @@ def get_num_samples(self) -> int: Returns: Total number of samples available """ - pass + raise NotImplementedError diff --git a/deployment/core/base_evaluator.py b/deployment/core/base_evaluator.py index 8d712e959..29da28067 100644 --- a/deployment/core/base_evaluator.py +++ b/deployment/core/base_evaluator.py @@ -12,6 +12,7 @@ import numpy as np from deployment.core.artifacts import Artifact +from deployment.core.backend import Backend from deployment.core.base_data_loader import BaseDataLoader @@ -59,7 +60,7 @@ class ModelSpec: artifact: Filesystem representation of the produced model. """ - backend: str + backend: Backend device: str artifact: Artifact @@ -137,7 +138,7 @@ def evaluate( "avg_latency_ms": 15.3, } """ - pass + raise NotImplementedError @abstractmethod def print_results(self, results: EvalResultDict) -> None: @@ -147,7 +148,7 @@ def print_results(self, results: EvalResultDict) -> None: Args: results: Results dictionary returned by evaluate() """ - pass + raise NotImplementedError @abstractmethod def verify( @@ -177,7 +178,7 @@ def verify( Returns: Verification results with pass/fail summary and per-sample outcomes. """ - pass + raise NotImplementedError def compute_latency_stats(self, latencies: list) -> Dict[str, float]: """ diff --git a/deployment/core/preprocessing_builder.py b/deployment/core/preprocessing_builder.py index 57d5eb915..52912b8bf 100644 --- a/deployment/core/preprocessing_builder.py +++ b/deployment/core/preprocessing_builder.py @@ -153,7 +153,7 @@ def build_preprocessing_pipeline( Args: model_cfg: Model configuration containing test pipeline definition. - - model_cfg.test_dataloader.dataset.pipeline + 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``. diff --git a/deployment/exporters/__init__.py b/deployment/exporters/__init__.py index e76b0e521..562b5a2bf 100644 --- a/deployment/exporters/__init__.py +++ b/deployment/exporters/__init__.py @@ -1,13 +1,14 @@ """Model exporters for different backends.""" from deployment.exporters.base.base_exporter import BaseExporter +from deployment.exporters.base.configs import ONNXExportConfig, TensorRTExportConfig from deployment.exporters.base.model_wrappers import ( BaseModelWrapper, IdentityWrapper, ) +from deployment.exporters.base.onnx_exporter import ONNXExporter +from deployment.exporters.base.tensorrt_exporter import TensorRTExporter -# from deployment.exporters.base.onnx_exporter import ONNXExporter -# from deployment.exporters.base.tensorrt_exporter import TensorRTExporter # from deployment.exporters.calibration.model_wrappers import CalibrationONNXWrapper # from deployment.exporters.calibration.onnx_exporter import CalibrationONNXExporter # from deployment.exporters.calibration.tensorrt_exporter import CalibrationTensorRTExporter @@ -18,19 +19,21 @@ # from deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter # from deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter -# __all__ = [ -# "BaseExporter", -# "ONNXExporter", -# "TensorRTExporter", -# "CenterPointONNXExporter", -# "CenterPointTensorRTExporter", -# "CenterPointONNXWrapper", -# "YOLOXONNXExporter", -# "YOLOXTensorRTExporter", -# "YOLOXONNXWrapper", -# "CalibrationONNXExporter", -# "CalibrationTensorRTExporter", -# "CalibrationONNXWrapper", -# "BaseModelWrapper", -# "IdentityWrapper", -# ] +__all__ = [ + "BaseExporter", + "ONNXExportConfig", + "TensorRTExportConfig", + "ONNXExporter", + "TensorRTExporter", + # "CenterPointONNXExporter", + # "CenterPointTensorRTExporter", + # "CenterPointONNXWrapper", + # "YOLOXONNXExporter", + # "YOLOXTensorRTExporter", + # "YOLOXONNXWrapper", + # "CalibrationONNXExporter", + # "CalibrationTensorRTExporter", + # "CalibrationONNXWrapper", + "BaseModelWrapper", + "IdentityWrapper", +] diff --git a/deployment/exporters/base/base_exporter.py b/deployment/exporters/base/base_exporter.py index ecff38ee0..c68e4cb84 100644 --- a/deployment/exporters/base/base_exporter.py +++ b/deployment/exporters/base/base_exporter.py @@ -6,10 +6,11 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Mapping, Optional +from typing import Any, Optional import torch +from deployment.exporters.base.configs import BaseExporterConfig from deployment.exporters.base.model_wrappers import BaseModelWrapper @@ -28,7 +29,7 @@ class BaseExporter(ABC): def __init__( self, - config: Mapping[str, Any], + config: BaseExporterConfig, model_wrapper: Optional[BaseModelWrapper] = None, logger: Optional[logging.Logger] = None, ): @@ -36,13 +37,14 @@ def __init__( Initialize exporter. Args: - config: Configuration dictionary for export settings + 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 = config + self.config: BaseExporterConfig = config self.logger = logger or logging.getLogger(__name__) self._model_wrapper = model_wrapper @@ -77,4 +79,4 @@ def export(self, model: torch.nn.Module, sample_input: Any, output_path: str, ** Raises: RuntimeError: If export fails """ - pass + raise NotImplementedError diff --git a/deployment/exporters/base/configs.py b/deployment/exporters/base/configs.py new file mode 100644 index 000000000..364e28ed3 --- /dev/null +++ b/deployment/exporters/base/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/base/model_wrappers.py b/deployment/exporters/base/model_wrappers.py index c1c215536..24b798ba3 100644 --- a/deployment/exporters/base/model_wrappers.py +++ b/deployment/exporters/base/model_wrappers.py @@ -46,7 +46,7 @@ def forward(self, *args, **kwargs): Must be implemented by subclasses to define ONNX-specific output format. """ - pass + raise NotImplementedError def get_config(self) -> Dict[str, Any]: """Get wrapper configuration.""" diff --git a/deployment/exporters/base/onnx_exporter.py b/deployment/exporters/base/onnx_exporter.py index 46ec8447d..905960241 100644 --- a/deployment/exporters/base/onnx_exporter.py +++ b/deployment/exporters/base/onnx_exporter.py @@ -2,13 +2,15 @@ import logging import os -from typing import Any, Dict, List, Optional +from dataclasses import replace +from typing import Any, Optional import onnx import onnxsim import torch from deployment.exporters.base.base_exporter import BaseExporter +from deployment.exporters.base.configs import ONNXExportConfig class ONNXExporter(BaseExporter): @@ -24,7 +26,7 @@ class ONNXExporter(BaseExporter): def __init__( self, - config: Dict[str, Any], + config: ONNXExportConfig, model_wrapper: Optional[Any] = None, logger: logging.Logger = None, ): @@ -32,11 +34,37 @@ def __init__( Initialize ONNX exporter. Args: - config: ONNX export configuration + config: ONNX export configuration dataclass instance. model_wrapper: Optional model wrapper class (e.g., YOLOXONNXWrapper) 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, @@ -44,14 +72,7 @@ def export( sample_input: Any, output_path: str, *, - input_names: Optional[List[str]] = None, - output_names: Optional[List[str]] = None, - dynamic_axes: Optional[Dict[str, Dict[int, str]]] = None, - simplify: Optional[bool] = None, - opset_version: Optional[int] = None, - export_params: Optional[bool] = None, - keep_initializers_as_inputs: Optional[bool] = None, - verbose: Optional[bool] = None, + config_override: Optional[ONNXExportConfig] = None, ) -> None: """ Export model to ONNX format. @@ -60,43 +81,82 @@ def export( model: PyTorch model to export sample_input: Sample input tensor output_path: Path to save ONNX model - input_names: Optional per-export input names - output_names: Optional per-export output names - dynamic_axes: Optional per-export dynamic axes mapping - simplify: Optional override for ONNX simplification flag - opset_version: Optional opset override - export_params: Optional export_params override - keep_initializers_as_inputs: Optional override for ONNX flag - verbose: Optional override for verbose flag + 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 """ - # Merge per-export overrides with base configuration - export_cfg: Dict[str, Any] = dict(self.config) - overrides = { - "input_names": input_names, - "output_names": output_names, - "dynamic_axes": dynamic_axes, - "simplify": simplify, - "opset_version": opset_version, - "export_params": export_params, - "keep_initializers_as_inputs": keep_initializers_as_inputs, - "verbose": verbose, - } - for key, value in overrides.items(): - if value is not None: - export_cfg[key] = value - - # Apply model wrapper if configured 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.get('opset_version', 16)}") + 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) @@ -107,22 +167,18 @@ def export( model, sample_input, output_path, - export_params=export_cfg.get("export_params", True), - keep_initializers_as_inputs=export_cfg.get("keep_initializers_as_inputs", False), - opset_version=export_cfg.get("opset_version", 16), - do_constant_folding=export_cfg.get("do_constant_folding", True), - input_names=export_cfg.get("input_names", ["input"]), - output_names=export_cfg.get("output_names", ["output"]), - dynamic_axes=export_cfg.get("dynamic_axes"), - verbose=export_cfg.get("verbose", False), + 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}") - # Optional model simplification - if export_cfg.get("simplify", True): - self._simplify_model(output_path) - except Exception as e: self.logger.error(f"ONNX export failed: {e}") import traceback @@ -142,7 +198,7 @@ def _simplify_model(self, onnx_path: str) -> None: model_simplified, success = onnxsim.simplify(onnx_path) if success: onnx.save(model_simplified, onnx_path) - self.logger.info(f"ONNX model simplified successfully") + self.logger.info("ONNX model simplified successfully") else: self.logger.warning("ONNX model simplification failed") except Exception as e: diff --git a/deployment/exporters/base/tensorrt_exporter.py b/deployment/exporters/base/tensorrt_exporter.py index d731f2ecc..165eb7829 100644 --- a/deployment/exporters/base/tensorrt_exporter.py +++ b/deployment/exporters/base/tensorrt_exporter.py @@ -1,12 +1,14 @@ """TensorRT model exporter.""" import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple import tensorrt as trt import torch +from deployment.core.artifacts import Artifact from deployment.exporters.base.base_exporter import BaseExporter +from deployment.exporters.base.configs import TensorRTExportConfig, TensorRTModelInputConfig, TensorRTProfileConfig class TensorRTExporter(BaseExporter): @@ -18,7 +20,7 @@ class TensorRTExporter(BaseExporter): def __init__( self, - config: Dict[str, Any], + config: TensorRTExportConfig, model_wrapper: Optional[Any] = None, logger: logging.Logger = None, ): @@ -26,7 +28,7 @@ def __init__( Initialize TensorRT exporter. Args: - config: TensorRT export configuration + config: TensorRT export configuration dataclass instance. model_wrapper: Optional model wrapper class (usually not needed for TensorRT) logger: Optional logger instance """ @@ -39,7 +41,7 @@ def export( sample_input: Any, output_path: str, onnx_path: str = None, - ) -> None: + ) -> Artifact: """ Export ONNX model to TensorRT engine. @@ -49,6 +51,9 @@ def export( 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 @@ -56,28 +61,80 @@ def export( if onnx_path is None: raise ValueError("onnx_path is required for TensorRT export") - precision_policy = self.config.get("precision_policy", "auto") - policy_flags = self.config.get("policy_flags", {}) - + 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._export_single_file(onnx_path, output_path, sample_input) + + def _export_single_file( + 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.get("max_workspace_size", 1 << 30) + 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) - if policy_flags.get("STRONGLY_TYPED"): + 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") @@ -91,39 +148,103 @@ def export( builder_config.set_flag(getattr(trt.BuilderFlag, flag_name)) self.logger.info(f"BuilderFlag.{flag_name} enabled") - # Parse ONNX model first to get network structure parser = trt.OnnxParser(network, trt_logger) - try: - 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") + return builder_config, network, parser - # Setup optimization profile after parsing ONNX to get actual input names - profile = builder.create_optimization_profile() - self._configure_input_shapes(profile, sample_input, network) - builder_config.add_optimization_profile(profile) + def _parse_onnx( + self, + parser: trt.OnnxParser, + network: trt.INetworkDefinition, + onnx_path: str, + ) -> None: + """ + Parse ONNX model into TensorRT network. - # Build engine - self.logger.info("Building TensorRT engine (this may take a while)...") - serialized_engine = builder.build_serialized_network(network, builder_config) + Args: + parser: TensorRT ONNX parser instance + network: TensorRT network definition + onnx_path: Path to ONNX model file - if serialized_engine is None: - self.logger.error("Failed to build TensorRT engine") - raise RuntimeError("TensorRT export failed: builder returned None") + 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") - # Save engine - with open(output_path, "wb") as f: - f.write(serialized_engine) + 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. - 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") + Creates an optimization profile and configures min/opt/max shapes for each input. + See `_configure_input_shapes` for details on shape configuration. - except Exception as e: - self.logger.error(f"TensorRT export failed: {e}") - raise RuntimeError("TensorRT export failed") from e + Args: + builder: TensorRT builder instance + builder_config: TensorRT builder config + network: TensorRT network definition + sample_input: Sample input for shape configuration + """ + 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, @@ -134,44 +255,85 @@ def _configure_input_shapes( """ Configure input shapes for TensorRT optimization profile. - Args: - profile: TensorRT optimization profile - sample_input: Sample input tensor - network: TensorRT network definition (optional, used to get actual input names) - """ - model_inputs = self.config.get("model_inputs", []) - - if model_inputs: - # VIVID(calibration classifier) - print("model inputs: ", model_inputs) - input_shapes = model_inputs[0].get("input_shapes", {}) - for input_name, shapes in input_shapes.items(): - min_shape = shapes.get("min_shape") - opt_shape = shapes.get("opt_shape") - max_shape = shapes.get("max_shape") - - if min_shape is None: - if sample_input is None: - raise ValueError(f"min_shape missing for {input_name} and sample_input is not provided") - min_shape = list(sample_input.shape) - - if opt_shape is None: - if sample_input is None: - raise ValueError(f"opt_shape missing for {input_name} and sample_input is not provided") - opt_shape = list(sample_input.shape) - - if max_shape is None: - if sample_input is None: - raise ValueError(f"max_shape missing for {input_name} and sample_input is not provided") - max_shape = list(sample_input.shape) - - 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) - else: + Note: + This is separate from ONNX `dynamic_axes`: + + - `dynamic_axes` controls symbolic dimensions in the ONNX graph. + - Here, `min/opt/max` shapes define TensorRT optimization profiles, + i.e., the allowed and optimized runtime shapes for each input. + + They are complementary but independent. + """ + model_inputs_cfg = self.config.model_inputs + + 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 = self._extract_input_shapes(first_entry) + + 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/pipelines/base/base_pipeline.py b/deployment/pipelines/base/base_pipeline.py index 1ac205bbb..2f43b8a29 100644 --- a/deployment/pipelines/base/base_pipeline.py +++ b/deployment/pipelines/base/base_pipeline.py @@ -72,7 +72,7 @@ def preprocess(self, input_data: Any, **kwargs) -> Any: Returns: Preprocessed data ready for model """ - pass + raise NotImplementedError @abstractmethod def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple]: @@ -88,7 +88,7 @@ def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple]: Returns: Model output (raw tensors or backend-specific format) """ - pass + raise NotImplementedError @abstractmethod def postprocess(self, model_output: Any, metadata: Dict = None) -> Any: @@ -105,7 +105,7 @@ def postprocess(self, model_output: Any, metadata: Dict = None) -> Any: Returns: Final predictions in standard format """ - pass + raise NotImplementedError def infer( self, input_data: Any, metadata: Optional[Dict] = None, return_raw_outputs: bool = False, **kwargs diff --git a/deployment/runners/__init__.py b/deployment/runners/__init__.py index b1d687e9f..a4865f31d 100644 --- a/deployment/runners/__init__.py +++ b/deployment/runners/__init__.py @@ -2,12 +2,13 @@ # from deployment.runners.calibration_runner import CalibrationDeploymentRunner # from deployment.runners.centerpoint_runner import CenterPointDeploymentRunner -# from deployment.runners.deployment_runner import BaseDeploymentRunner +from deployment.runners.deployment_runner import BaseDeploymentRunner + # from deployment.runners.yolox_runner import YOLOXDeploymentRunner -# __all__ = [ -# "BaseDeploymentRunner", -# "CenterPointDeploymentRunner", -# "YOLOXDeploymentRunner", -# "CalibrationDeploymentRunner", -# ] +__all__ = [ + "BaseDeploymentRunner", + # "CenterPointDeploymentRunner", + # "YOLOXDeploymentRunner", + # "CalibrationDeploymentRunner", +] diff --git a/deployment/runners/deployment_runner.py b/deployment/runners/deployment_runner.py index c375efd0c..f662034a8 100644 --- a/deployment/runners/deployment_runner.py +++ b/deployment/runners/deployment_runner.py @@ -7,12 +7,31 @@ import logging import os -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union import torch from mmengine.config import Config -from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig, BaseEvaluator, ModelSpec +from deployment.core import Artifact, Backend, BaseDataLoader, BaseDeploymentConfig, BaseEvaluator, ModelSpec + + +class DeploymentResultDict(TypedDict, total=False): + """ + Standardized structure returned by `BaseDeploymentRunner.run()`. + + Keys: + pytorch_model: In-memory model instance loaded from the checkpoint (if requested). + onnx_path: Filesystem path to the exported ONNX artifact (single file or directory). + tensorrt_path: Filesystem path to the exported TensorRT engine. + verification_results: Arbitrary dictionary produced by `BaseEvaluator.verify()`. + evaluation_results: Arbitrary dictionary produced by `BaseEvaluator.evaluate()`. + """ + + pytorch_model: Optional[Any] + onnx_path: Optional[str] + tensorrt_path: Optional[str] + verification_results: Dict[str, Any] + evaluation_results: Dict[str, Any] class BaseDeploymentRunner: @@ -72,6 +91,19 @@ def __init__( self._tensorrt_exporter = tensorrt_exporter self.artifacts: Dict[str, Artifact] = {} + @staticmethod + def _get_backend_entry(mapping: Optional[Dict[Any, Any]], backend: Backend) -> Any: + """ + Fetch a config value that may be keyed by either string literals or Backend enums. + """ + if not mapping: + return None + + if backend.value in mapping: + return mapping[backend.value] + + return mapping.get(backend) + def load_pytorch_model(self, checkpoint_path: str, **kwargs) -> Any: """ Load PyTorch model from checkpoint. @@ -116,7 +148,7 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[Artifact]: # Save to work_dir/onnx/ directory onnx_dir = os.path.join(self.config.export_config.work_dir, "onnx") os.makedirs(onnx_dir, exist_ok=True) - output_path = os.path.join(onnx_dir, onnx_settings["save_file"]) + output_path = os.path.join(onnx_dir, onnx_settings.save_file) # Get sample input sample_idx = self.config.runtime_config.get("sample_idx", 0) @@ -124,7 +156,7 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[Artifact]: single_input = self.data_loader.preprocess(sample) # Get batch size from configuration - batch_size = onnx_settings.get("batch_size", 1) + batch_size = onnx_settings.batch_size if batch_size is None: input_tensor = single_input self.logger.info("Using dynamic batch size") @@ -150,7 +182,7 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[Artifact]: 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.artifacts["onnx"] = artifact + self.artifacts[Backend.ONNX.value] = artifact self.logger.info(f"ONNX export successful: {artifact.path}") return artifact @@ -219,7 +251,7 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: raise artifact = Artifact(path=output_path, multi_file=False) - self.artifacts["tensorrt"] = artifact + self.artifacts[Backend.TENSORRT.value] = artifact self.logger.info(f"TensorRT export successful: {artifact.path}") return artifact @@ -234,7 +266,7 @@ def _resolve_pytorch_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Option Tuple of (artifact, is_valid). artifact is an `Artifact` instance if a path could be resolved, otherwise None. """ - artifact = self.artifacts.get("pytorch") + artifact = self.artifacts.get(Backend.PYTORCH.value) if artifact: return artifact, artifact.exists() @@ -245,36 +277,40 @@ def _resolve_pytorch_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Option artifact = Artifact(path=model_path, multi_file=False) return artifact, artifact.exists() - def _artifact_from_path(self, backend: str, path: str) -> Artifact: - existing = self.artifacts.get(backend) + def _artifact_from_path(self, backend: Union[str, Backend], path: str) -> Artifact: + backend_enum = Backend.from_value(backend) + existing = self.artifacts.get(backend_enum.value) if existing and existing.path == path: return existing multi_file = os.path.isdir(path) if path and os.path.exists(path) else False return Artifact(path=path, multi_file=multi_file) - def _build_model_spec(self, backend: str, artifact: Artifact, device: str) -> ModelSpec: + def _build_model_spec(self, backend: Union[str, Backend], artifact: Artifact, device: str) -> ModelSpec: + backend_enum = Backend.from_value(backend) return ModelSpec( - backend=backend, + backend=backend_enum, device=device, artifact=artifact, ) - def _normalize_device_for_backend(self, backend: str, device: Optional[str]) -> str: + def _normalize_device_for_backend(self, backend: Union[str, Backend], device: Optional[str]) -> str: + backend_enum = Backend.from_value(backend) normalized_device = str(device or "cpu") - if backend in ("pytorch", "onnx"): + if backend_enum 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}'. Falling back to CPU." + f"Unsupported device '{normalized_device}' for backend '{backend_enum.value}'. Falling back to CPU." ) normalized_device = "cpu" - elif backend == "tensorrt": + elif backend_enum is Backend.TENSORRT: if not normalized_device or normalized_device == "cpu": normalized_device = self.config.export_config.cuda_device or "cuda:0" if not normalized_device.startswith("cuda"): self.logger.warning( - f"TensorRT evaluation requires CUDA device. Overriding device from '{normalized_device}' to 'cuda:0'." + "TensorRT evaluation requires CUDA device. Overriding device " + f"from '{normalized_device}' to 'cuda:0'." ) normalized_device = "cuda:0" @@ -291,7 +327,7 @@ def _resolve_onnx_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[ Tuple of (artifact, is_valid). artifact is an `Artifact` instance if a path could be resolved, otherwise None. """ - artifact = self.artifacts.get("onnx") + artifact = self.artifacts.get(Backend.ONNX.value) if artifact: return artifact, artifact.exists() @@ -313,7 +349,7 @@ def _resolve_tensorrt_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optio Tuple of (artifact, is_valid). artifact is an `Artifact` instance if a path could be resolved, otherwise None. """ - artifact = self.artifacts.get("tensorrt") + artifact = self.artifacts.get(Backend.TENSORRT.value) if artifact: return artifact, artifact.exists() @@ -334,7 +370,8 @@ def get_models_to_evaluate(self) -> List[ModelSpec]: backends = self.config.get_evaluation_backends() models_to_evaluate: List[ModelSpec] = [] - for backend_name, backend_cfg in backends.items(): + for backend_key, backend_cfg in backends.items(): + backend_enum = Backend.from_value(backend_key) if not backend_cfg.get("enabled", False): continue @@ -342,19 +379,19 @@ def get_models_to_evaluate(self) -> List[ModelSpec]: artifact: Optional[Artifact] = None is_valid = False - if backend_name == "pytorch": + if backend_enum is Backend.PYTORCH: artifact, is_valid = self._resolve_pytorch_artifact(backend_cfg) - elif backend_name == "onnx": + elif backend_enum is Backend.ONNX: artifact, is_valid = self._resolve_onnx_artifact(backend_cfg) - elif backend_name == "tensorrt": + elif backend_enum is Backend.TENSORRT: artifact, is_valid = self._resolve_tensorrt_artifact(backend_cfg) if is_valid and artifact: - spec = self._build_model_spec(backend_name, artifact, device) + spec = self._build_model_spec(backend_enum, artifact, device) models_to_evaluate.append(spec) - self.logger.info(f" - {backend_name}: {artifact.path} (device: {device})") + self.logger.info(f" - {backend_enum.value}: {artifact.path} (device: {device})") elif artifact is not None: - self.logger.warning(f" - {backend_name}: {artifact.path} (not found or invalid, skipping)") + self.logger.warning(f" - {backend_enum.value}: {artifact.path} (not found or invalid, skipping)") return models_to_evaluate @@ -376,7 +413,7 @@ def run_verification( verification_cfg = self.config.verification_config # Check master switches - if not verification_cfg.get("enabled", True): + if not verification_cfg.enabled: self.logger.info("Verification disabled (verification.enabled=False), skipping...") return {} @@ -384,12 +421,12 @@ def run_verification( scenarios = self.config.get_verification_scenarios(export_mode) if not scenarios: - self.logger.info(f"No verification scenarios for export mode '{export_mode}', skipping...") + self.logger.info(f"No verification scenarios for export mode '{export_mode.value}', skipping...") return {} # Check if any scenario actually needs PyTorch checkpoint needs_pytorch = any( - policy.get("ref_backend") == "pytorch" or policy.get("test_backend") == "pytorch" for policy in scenarios + policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios ) if needs_pytorch and not pytorch_checkpoint: @@ -398,12 +435,12 @@ def run_verification( ) return {} - num_verify_samples = verification_cfg.get("num_verify_samples", 3) - tolerance = verification_cfg.get("tolerance", 0.1) - devices_map = verification_cfg.get("devices", {}) or {} + num_verify_samples = verification_cfg.num_verify_samples + tolerance = verification_cfg.tolerance + devices_map = verification_cfg.devices or {} self.logger.info("=" * 80) - self.logger.info(f"Running Verification (mode: {export_mode})") + self.logger.info(f"Running Verification (mode: {export_mode.value})") self.logger.info("=" * 80) all_results = {} @@ -411,12 +448,12 @@ def run_verification( total_failed = 0 for i, policy in enumerate(scenarios): - ref_backend = policy["ref_backend"] + ref_backend = policy.ref_backend # Resolve device using alias system: # - Scenarios use aliases (e.g., "cpu", "cuda") for flexibility # - Actual device strings are defined in verification["devices"] # - This allows easy device switching: change devices["cpu"] to affect all CPU verifications - ref_device_key = policy["ref_device"] + ref_device_key = policy.ref_device if ref_device_key in devices_map: ref_device = devices_map[ref_device_key] else: @@ -424,8 +461,8 @@ def run_verification( ref_device = ref_device_key self.logger.warning(f"Device alias '{ref_device_key}' not found in devices map, using as-is") - test_backend = policy["test_backend"] - test_device_key = policy["test_device"] + test_backend = policy.test_backend + test_device_key = policy.test_device if test_device_key in devices_map: test_device = devices_map[test_device_key] else: @@ -434,25 +471,26 @@ def run_verification( self.logger.warning(f"Device alias '{test_device_key}' not found in devices map, using as-is") self.logger.info( - f"\nScenarios {i+1}/{len(scenarios)}: {ref_backend}({ref_device}) vs {test_backend}({test_device})" + f"\nScenarios {i+1}/{len(scenarios)}: " + f"{ref_backend.value}({ref_device}) vs {test_backend.value}({test_device})" ) # Resolve model paths based on backend ref_path = None test_path = None - if ref_backend == "pytorch": + if ref_backend is Backend.PYTORCH: ref_path = pytorch_checkpoint - elif ref_backend == "onnx": + elif ref_backend is Backend.ONNX: ref_path = onnx_path - elif ref_backend == "tensorrt": + elif ref_backend is Backend.TENSORRT: ref_path = tensorrt_path - if test_backend == "onnx": + if test_backend is Backend.ONNX: test_path = onnx_path - elif test_backend == "tensorrt": + elif test_backend is Backend.TENSORRT: test_path = tensorrt_path - elif test_backend == "pytorch": + elif test_backend is Backend.PYTORCH: test_path = pytorch_checkpoint if not ref_path or not test_path: @@ -476,7 +514,7 @@ def run_verification( ) # Extract results for this specific comparison - policy_key = f"{ref_backend}_{ref_device}_vs_{test_backend}_{test_device}" + policy_key = f"{ref_backend.value}_{ref_device}_vs_{test_backend.value}_{test_device}" all_results[policy_key] = verification_results if "summary" in verification_results: @@ -519,7 +557,7 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: """ eval_config = self.config.evaluation_config - if not eval_config.get("enabled", False): + if not eval_config.enabled: self.logger.info("Evaluation disabled, skipping...") return {} @@ -533,11 +571,11 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: self.logger.warning("No models found for evaluation") return {} - num_samples = eval_config.get("num_samples", 10) + num_samples = eval_config.num_samples if num_samples == -1: num_samples = self.data_loader.get_num_samples() - verbose_mode = eval_config.get("verbose", False) + verbose_mode = eval_config.verbose all_results: Dict[str, Any] = {} @@ -554,9 +592,9 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: verbose=verbose_mode, ) - all_results[backend] = results + all_results[backend.value] = results - self.logger.info(f"\n{backend.upper()} Results:") + self.logger.info(f"\n{backend.value.upper()} Results:") self.evaluator.print_results(results) if len(all_results) > 1: @@ -564,8 +602,8 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: self.logger.info("Cross-Backend Comparison") self.logger.info("=" * 80) - for backend, results in all_results.items(): - self.logger.info(f"\n{backend.upper()}:") + 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}") @@ -582,7 +620,7 @@ def run_evaluation(self, **kwargs) -> Dict[str, Any]: return all_results - def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> DeploymentResultDict: """ Execute the complete deployment workflow. @@ -591,7 +629,7 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] **kwargs: Additional project-specific arguments Returns: - Dictionary containing deployment results + DeploymentResultDict: Structured summary of all deployment artifacts and reports. """ results = { "pytorch_model": None, @@ -620,19 +658,19 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] # Check if PyTorch evaluation is needed needs_pytorch_eval = False - if eval_config.get("enabled", False): - models_to_eval = eval_config.get("models", {}) - if models_to_eval.get("pytorch"): + if eval_config.enabled: + models_to_eval = eval_config.models + if self._get_backend_entry(models_to_eval, Backend.PYTORCH): needs_pytorch_eval = True # Check if PyTorch is needed for verification needs_pytorch_for_verification = False - if verification_cfg.get("enabled", False): + if verification_cfg.enabled: export_mode = self.config.export_config.mode scenarios = self.config.get_verification_scenarios(export_mode) if scenarios: needs_pytorch_for_verification = any( - policy.get("ref_backend") == "pytorch" or policy.get("test_backend") == "pytorch" + policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios ) @@ -653,7 +691,7 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] try: pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) results["pytorch_model"] = pytorch_model - self.artifacts["pytorch"] = Artifact(path=checkpoint_path) + self.artifacts[Backend.PYTORCH.value] = Artifact(path=checkpoint_path) # Single-direction injection: write model to evaluator via setter (never read from it) if hasattr(self.evaluator, "set_pytorch_model"): @@ -673,7 +711,7 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] try: pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) results["pytorch_model"] = pytorch_model - self.artifacts["pytorch"] = Artifact(path=checkpoint_path) + self.artifacts[Backend.PYTORCH.value] = Artifact(path=checkpoint_path) # Single-direction injection: write model to evaluator via setter (never read from it) if hasattr(self.evaluator, "set_pytorch_model"): @@ -701,7 +739,7 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] else: results["onnx_path"] = onnx_path # Ensure verification/evaluation can use this path if onnx_path and os.path.exists(onnx_path): - self.artifacts["onnx"] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) + self.artifacts[Backend.ONNX.value] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) try: tensorrt_artifact = self.export_tensorrt(onnx_path, **kwargs) if tensorrt_artifact: @@ -711,19 +749,21 @@ def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> Dict[str, Any] # Get model paths from evaluation config if not exported if not results["onnx_path"] or not results["tensorrt_path"]: - eval_models = self.config.evaluation_config.get("models", {}) + eval_models = self.config.evaluation_config.models if not results["onnx_path"]: - onnx_path = eval_models.get("onnx") + onnx_path = self._get_backend_entry(eval_models, Backend.ONNX) if onnx_path and os.path.exists(onnx_path): results["onnx_path"] = onnx_path - self.artifacts["onnx"] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) + self.artifacts[Backend.ONNX.value] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) elif onnx_path: self.logger.warning(f"ONNX file from config does not exist: {onnx_path}") if not results["tensorrt_path"]: - tensorrt_path = eval_models.get("tensorrt") + tensorrt_path = self._get_backend_entry(eval_models, Backend.TENSORRT) if tensorrt_path and os.path.exists(tensorrt_path): results["tensorrt_path"] = tensorrt_path - self.artifacts["tensorrt"] = Artifact(path=tensorrt_path, multi_file=os.path.isdir(tensorrt_path)) + self.artifacts[Backend.TENSORRT.value] = Artifact( + path=tensorrt_path, multi_file=os.path.isdir(tensorrt_path) + ) elif tensorrt_path: self.logger.warning(f"TensorRT engine from config does not exist: {tensorrt_path}") From 5cb28f35ea7e4bd6e95819db756c0ba8a7752152 Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 20 Nov 2025 15:36:34 +0900 Subject: [PATCH 12/62] chore: remove int8 Signed-off-by: vividf --- deployment/README.md | 4 ++-- deployment/core/base_config.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/deployment/README.md b/deployment/README.md index be5ba373f..f8a27ddfb 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -209,7 +209,7 @@ Support for different TensorRT precision modes: - `auto`: TensorRT decides automatically - `fp16`: FP16 precision - `fp32_tf32`: FP32 with TF32 acceleration -- `explicit_int8`: INT8 quantization +- `strongly_typed`: Enable strongly-typed TensorRT networks (all tensor dtypes must be explicitly defined; no implicit casting). --- @@ -371,7 +371,7 @@ onnx_config = dict( # Backend configuration backend_config = dict( common_config=dict( - precision_policy="auto", # "auto", "fp16", "fp32_tf32", "explicit_int8" + precision_policy="auto", # "auto", "fp16", "fp32_tf32", "strongly_typed" max_workspace_size=1 << 30, # 1 GB ), ) diff --git a/deployment/core/base_config.py b/deployment/core/base_config.py index 06d0b328f..f1eb3ed63 100644 --- a/deployment/core/base_config.py +++ b/deployment/core/base_config.py @@ -37,7 +37,6 @@ class PrecisionPolicy(str, Enum): AUTO = "auto" FP16 = "fp16" FP32_TF32 = "fp32_tf32" - EXPLICIT_INT8 = "explicit_int8" STRONGLY_TYPED = "strongly_typed" @@ -69,7 +68,6 @@ def from_value(cls, value: Optional[Union[str, "ExportMode"]]) -> "ExportMode": PrecisionPolicy.AUTO.value: {}, # No special flags, TensorRT decides PrecisionPolicy.FP16.value: {"FP16": True}, PrecisionPolicy.FP32_TF32.value: {"TF32": True}, # TF32 for FP32 operations - PrecisionPolicy.EXPLICIT_INT8.value: {"INT8": True}, PrecisionPolicy.STRONGLY_TYPED.value: {"STRONGLY_TYPED": True}, # Network creation flag } From 6325343b4e9a01b110ba0e55b68034bae440ad0c Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 21 Nov 2025 01:32:03 +0900 Subject: [PATCH 13/62] chore: refactor deployment runner Signed-off-by: vividf --- deployment/README.md | 193 ++++++++++-------------- deployment/exporters/__init__.py | 24 +-- deployment/exporters/base/factory.py | 49 ++++++ deployment/exporters/workflows/base.py | 54 +++++++ deployment/pipelines/base/__init__.py | 6 - deployment/runners/deployment_runner.py | 130 ++++++++++++---- 6 files changed, 282 insertions(+), 174 deletions(-) create mode 100644 deployment/exporters/base/factory.py create mode 100644 deployment/exporters/workflows/base.py diff --git a/deployment/README.md b/deployment/README.md index f8a27ddfb..57f160530 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -75,15 +75,15 @@ The AWML Deployment Framework provides a standardized approach to model deployme `BaseDeploymentRunner` orchestrates the complete deployment workflow, while each project provides a thin subclass (`CenterPointDeploymentRunner`, `YOLOXDeploymentRunner`, `CalibrationDeploymentRunner`) that plugs in model-specific logic. - **Model Loading**: Implemented by each project runner to load PyTorch checkpoints -- **Export**: Uses injected ONNX/TensorRT exporters that encapsulate wrapper logic +- **Export**: Uses lazily constructed ONNX/TensorRT exporters (via `ExporterFactory`) informed by wrapper classes and optional workflows - **Verification**: Scenario-based verification across backends - **Evaluation**: Performance metrics and latency statistics **Required Parameters:** -- `onnx_exporter`: Project-specific ONNX exporter instance (e.g., `YOLOXONNXExporter`, `CenterPointONNXExporter`) -- `tensorrt_exporter`: Project-specific TensorRT exporter instance (e.g., `YOLOXTensorRTExporter`, `CenterPointTensorRTExporter`) +- `onnx_wrapper_cls`: Optional model wrapper class for ONNX export (required unless a workflow performs the export) +- `onnx_workflow` / `tensorrt_workflow`: Optional workflow objects for specialized multi-file exports -Exporters receive their corresponding `model_wrapper` during construction. Runners never implicitly create exporters/wrappers—everything is injected for clarity and testability. +Runners own exporter initialization and reuse, ensuring consistent logging/configuration while keeping project entry points lightweight. #### 2. **Core Components** (in `core/`) @@ -98,12 +98,13 @@ Exporters receive their corresponding `model_wrapper` during construction. Runne - **`Detection3DPipeline`**: Base pipeline for 3D detection tasks - **`ClassificationPipeline`**: Base pipeline for classification tasks -#### 3. **Exporters** +#### 3. **Exporters & Workflows** -**Unified Architecture**: All projects follow a consistent structure with three files per model: -- `{model}/onnx_exporter.py`: Project-specific ONNX exporter -- `{model}/tensorrt_exporter.py`: Project-specific TensorRT exporter -- `{model}/model_wrappers.py`: Project-specific model wrapper +**Unified Architecture**: +- Exporters are created lazily by `ExporterFactory`, so project entry points only declare wrappers/workflows and never wire exporters manually. +- Base workflow interfaces live in `exporters/workflows/base.py`, enabling complex projects to orchestrate multi-stage exports without forking the base exporters. +- Simple projects rely directly on the base exporters with optional wrappers. +- Complex projects (CenterPoint) assemble workflows (`onnx_workflow.py`, `tensorrt_workflow.py`) that orchestrate multiple single-file exports using the base exporters via composition. - **Base Exporters** (in `exporters/base/`): - **`BaseExporter`**: Abstract base class for all exporters @@ -117,23 +118,23 @@ Exporters receive their corresponding `model_wrapper` during construction. Runne - **`TensorRTModelInputConfig`**: Configuration for TensorRT input shapes - **`TensorRTProfileConfig`**: Optimization profile configuration for dynamic shapes -- **Project-Specific Exporters**: +- **Factory & Workflow Interfaces**: + - **`ExporterFactory`** (`exporters/base/factory.py`): Builds `ONNXExporter`/`TensorRTExporter` instances using `BaseDeploymentConfig` settings, ensuring consistent logging and configuration. + - **`OnnxExportWorkflow` / `TensorRTExportWorkflow`** (`exporters/workflows/base.py`): Abstract contracts for orchestrating complex, multi-artifact exports. + +- **Project-Specific Wrappers & Workflows**: - **YOLOX** (`exporters/yolox/`): - - **`YOLOXONNXExporter`**: Inherits base ONNX exporter (requires `YOLOXONNXWrapper`) - - **`YOLOXTensorRTExporter`**: Inherits base TensorRT exporter - - **`YOLOXONNXWrapper`**: Transforms YOLOX output to Tier4-compatible format + - **`YOLOXONNXWrapper`**: Transforms YOLOX output to Tier4-compatible format; paired with base exporters created by the factory. - **CenterPoint** (`exporters/centerpoint/`): - - **`CenterPointONNXExporter`**: Extends base exporter for multi-file ONNX export - - **`CenterPointTensorRTExporter`**: Extends base exporter for multi-file TensorRT export + - **`CenterPointONNXExportWorkflow`**: Composes the generic `ONNXExporter` to emit multiple ONNX files + - **`CenterPointTensorRTExportWorkflow`**: Composes the generic `TensorRTExporter` to build multiple engines - **`CenterPointONNXWrapper`**: Identity wrapper (no transformation needed) - **Calibration** (`exporters/calibration/`): - - **`CalibrationONNXExporter`**: Inherits base ONNX exporter (requires `IdentityWrapper`) - - **`CalibrationTensorRTExporter`**: Inherits base TensorRT exporter - - **`CalibrationONNXWrapper`**: Identity wrapper (no transformation needed) + - **`CalibrationONNXWrapper`**: Identity wrapper (no transformation needed); paired with the base exporters from the factory **Architecture Pattern**: -- **Simple models** (YOLOX, Calibration): Inherit base exporters, use custom wrappers if needed -- **Complex models** (CenterPoint): Extend base exporters for special logic (e.g., multi-file export), use IdentityWrapper +- **Simple models** (YOLOX, Calibration): Instantiate the generic base exporters via `ExporterFactory` and supply custom wrappers when needed; no subclassing required. +- **Complex models** (CenterPoint): Keep base exporters generic and layer workflows for multi-file orchestration, still using wrappers as needed. #### 4. **Pipelines** @@ -236,28 +237,11 @@ python projects/CalibrationStatusClassification/deploy/main.py \ ### Creating a Project Runner -All projects follow the dependency injection pattern: explicitly create exporters (with their wrappers) and pass them to a project-specific runner subclass of `BaseDeploymentRunner`. Example (YOLOX): +Projects now pass lightweight configuration objects (wrapper classes and optional workflows) into the runner. The runner owns exporter construction through `ExporterFactory` and creates the exporters lazily. Example (YOLOX): ```python -from deployment.runners import YOLOXDeploymentRunner -from deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter -from deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper - -# Create project-specific exporters -onnx_settings = config.get_onnx_settings() -trt_settings = config.get_tensorrt_settings() - -onnx_exporter = YOLOXONNXExporter( - onnx_settings, - model_wrapper=YOLOXONNXWrapper, - logger=logger, -) -tensorrt_exporter = YOLOXTensorRTExporter( - trt_settings, - model_wrapper=YOLOXONNXWrapper, - logger=logger, -) +from deployment.runners import YOLOXDeploymentRunner # Instantiate the project runner runner = YOLOXDeploymentRunner( @@ -266,16 +250,15 @@ runner = YOLOXDeploymentRunner( config=config, model_cfg=model_cfg, logger=logger, - onnx_exporter=onnx_exporter, # Required - tensorrt_exporter=tensorrt_exporter, # Required + onnx_wrapper_cls=YOLOXONNXWrapper, ) ``` **Key Points:** -- Exporters (and their wrappers) must be explicitly created in the entry point -- `onnx_exporter` and `tensorrt_exporter` are **required** arguments for every runner -- Each project uses its own specific exporter, wrapper, data loader, evaluator, and runner class -- This explicit wiring keeps dependencies clear and improves testability +- Pass wrapper classes (and optional workflows) instead of exporter instances +- Exporters are constructed lazily inside `BaseDeploymentRunner` via `ExporterFactory` +- Projects still control model-specific behavior by choosing wrappers/workflows +- Entry points remain simple while keeping dependencies explicit ### Command-Line Arguments @@ -481,21 +464,21 @@ See project-specific configs: ### CenterPoint (3D Detection) **Features:** -- Multi-file ONNX export (voxel encoder + backbone/head) +- Multi-file ONNX export (voxel encoder + backbone/head) orchestrated via workflows - ONNX-compatible model configuration -- Custom exporters for complex model structure +- Composed exporters for complex model structure -**Exporter and Wrapper:** -- `CenterPointONNXExporter`: Extends base exporter for multi-file ONNX export -- `CenterPointTensorRTExporter`: Extends base exporter for multi-file TensorRT export +**Workflows and Wrapper:** +- `CenterPointONNXExportWorkflow`: Drives multiple ONNX exports using the generic `ONNXExporter` +- `CenterPointTensorRTExportWorkflow`: Converts each ONNX file with the generic `TensorRTExporter` - `CenterPointONNXWrapper`: Identity wrapper (no output transformation) **Key Files:** - `projects/CenterPoint/deploy/main.py` - `projects/CenterPoint/deploy/evaluator.py` - `deployment/pipelines/centerpoint/` -- `deployment/exporters/centerpoint/onnx_exporter.py` -- `deployment/exporters/centerpoint/tensorrt_exporter.py` +- `deployment/exporters/centerpoint/onnx_workflow.py` +- `deployment/exporters/centerpoint/tensorrt_workflow.py` - `deployment/exporters/centerpoint/model_wrappers.py` **Pipeline Structure:** @@ -511,17 +494,15 @@ run_backbone_head() → postprocess() - Model wrapper for ONNX-compatible output format - ReLU6 → ReLU replacement for ONNX compatibility -**Exporter and Wrapper:** -- `YOLOXONNXExporter`: Inherits base ONNX exporter and requires explicit `YOLOXONNXWrapper` -- `YOLOXTensorRTExporter`: Inherits base TensorRT exporter +**Export + Wrapper:** +- `ONNXExporter`: Generic exporter instantiated with `YOLOXONNXWrapper` +- `TensorRTExporter`: Generic exporter instantiated with the same wrapper - `YOLOXONNXWrapper`: Transforms output from `(1, 8, 120, 120)` to `(1, 18900, 13)` format **Key Files:** - `projects/YOLOX_opt_elan/deploy/main.py` - `projects/YOLOX_opt_elan/deploy/evaluator.py` - `deployment/pipelines/yolox/` -- `deployment/exporters/yolox/onnx_exporter.py` -- `deployment/exporters/yolox/tensorrt_exporter.py` - `deployment/exporters/yolox/model_wrappers.py` **Pipeline Structure:** @@ -536,17 +517,15 @@ preprocess() → run_model() → postprocess() - Simple single-file ONNX export - Calibrated/miscalibrated data loader variants -**Exporter and Wrapper:** -- `CalibrationONNXExporter`: Inherits base ONNX exporter, uses `IdentityWrapper` by default -- `CalibrationTensorRTExporter`: Inherits base TensorRT exporter +**Export + Wrapper:** +- `ONNXExporter`: Generic exporter instantiated with `CalibrationONNXWrapper` +- `TensorRTExporter`: Generic exporter instantiated with the same wrapper - `CalibrationONNXWrapper`: Identity wrapper (no output transformation) **Key Files:** - `projects/CalibrationStatusClassification/deploy/main.py` - `projects/CalibrationStatusClassification/deploy/evaluator.py` - `deployment/pipelines/calibration/` -- `deployment/exporters/calibration/onnx_exporter.py` -- `deployment/exporters/calibration/tensorrt_exporter.py` - `deployment/exporters/calibration/model_wrappers.py` **Pipeline Structure:** @@ -733,20 +712,19 @@ deployment/ │ ├── base/ # Base exporter classes │ │ ├── base_exporter.py # Exporter base class │ │ ├── configs.py # Typed configuration classes (ONNXExportConfig, TensorRTExportConfig) +│ │ ├── factory.py # ExporterFactory that builds ONNX/TensorRT exporters │ │ ├── onnx_exporter.py # ONNX exporter base class │ │ ├── tensorrt_exporter.py # TensorRT exporter base class │ │ └── model_wrappers.py # Base model wrappers (BaseModelWrapper, IdentityWrapper) -│ ├── centerpoint/ # CenterPoint exporters (extends base) -│ │ ├── onnx_exporter.py # CenterPoint ONNX exporter (multi-file export) -│ │ ├── tensorrt_exporter.py # CenterPoint TensorRT exporter (multi-file export) -│ │ └── model_wrappers.py # CenterPoint model wrappers (IdentityWrapper) -│ ├── yolox/ # YOLOX exporters (inherits base) -│ │ ├── onnx_exporter.py # YOLOX ONNX exporter (inherits base) -│ │ ├── tensorrt_exporter.py # YOLOX TensorRT exporter (inherits base) +│ ├── workflows/ # Workflow interfaces +│ │ └── base.py # OnnxExportWorkflow & TensorRTExportWorkflow ABCs +│ ├── centerpoint/ # CenterPoint-specific helpers (compose base exporters) +│ │ ├── model_wrappers.py # CenterPoint model wrappers (IdentityWrapper) +│ │ ├── onnx_workflow.py # CenterPoint multi-file ONNX workflow +│ │ └── tensorrt_workflow.py # CenterPoint multi-file TensorRT workflow +│ ├── yolox/ # YOLOX wrappers (paired with base exporters) │ │ └── model_wrappers.py # YOLOX model wrappers (YOLOXONNXWrapper) -│ └── calibration/ # CalibrationStatusClassification exporters (inherits base) -│ ├── onnx_exporter.py # Calibration ONNX exporter (inherits base) -│ ├── tensorrt_exporter.py # Calibration TensorRT exporter (inherits base) +│ └── calibration/ # CalibrationStatusClassification wrappers │ └── model_wrappers.py # Calibration model wrappers (IdentityWrapper) │ ├── pipelines/ # Task-specific pipelines @@ -812,74 +790,59 @@ projects/ ### 2. Model Export -- Always explicitly create project-specific exporters in `main.py` -- Always provide required `model_wrapper` parameter when constructing exporters -- Use project-specific wrapper classes (e.g., `YOLOXONNXWrapper`, `CenterPointONNXWrapper`) -- Follow the unified architecture pattern: each model has `onnx_exporter.py`, `tensorrt_exporter.py`, and `model_wrappers.py` -- Simple models: inherit base exporters, use custom wrappers if needed -- Complex models: extend base exporters for special logic, use IdentityWrapper if no transformation needed -- Always verify ONNX export before TensorRT conversion -- Use appropriate precision policies for TensorRT -- Test with multiple samples during export +- Pass wrapper classes (and optional workflows) into project runners; `ExporterFactory` constructs ONNX/TensorRT exporters using deployment configs. +- Keep wrapper definitions in `exporters/{model}/model_wrappers.py`; reuse `IdentityWrapper` when no transformation is needed. +- Introduce workflow modules (`exporters/{model}/onnx_workflow.py`, `tensorrt_workflow.py`) only when orchestration beyond single-file export is required. +- Simple models: rely on generic base exporters + wrappers; no subclassing or custom exporters. +- Complex models: implement workflow classes that drive multiple calls into the generic exporters while keeping exporter logic centralized. +- Always verify ONNX export before TensorRT conversion and prefer multiple samples to validate stability. +- Use appropriate precision policies for TensorRT (auto/fp16/fp32_tf32/strongly_typed) based on deployment constraints. ### 2.1. Unified Architecture Pattern -All projects follow a unified structure with three files per model: +All projects follow a unified structure, with simple models sticking to exporter modules and complex models layering workflows on top: ``` exporters/{model}/ -├── onnx_exporter.py # Project-specific ONNX exporter -├── tensorrt_exporter.py # Project-specific TensorRT exporter -└── model_wrappers.py # Project-specific model wrapper +├── model_wrappers.py # Project-specific model wrapper +├── [optional] onnx_workflow.py # Workflow orchestrating base exporter calls +├── [optional] tensorrt_workflow.py # Workflow orchestrating base exporter calls ``` **Pattern 1: Simple Models** (YOLOX, Calibration) -- Inherit base exporters (no special logic needed) +- Instantiate the generic base exporters (no subclassing needed) - Use custom wrappers if output format transformation is required -- Example: `YOLOXONNXExporter` inherits `ONNXExporter`, requires `YOLOXONNXWrapper` +- Example: YOLOX uses `ONNXExporter` + `YOLOXONNXWrapper` **Pattern 2: Complex Models** (CenterPoint) -- Extend base exporters for special requirements (e.g., multi-file export) +- Keep base exporters generic but introduce workflow classes for special requirements (e.g., multi-file export) - Use IdentityWrapper if no output transformation needed -- Example: `CenterPointONNXExporter` extends `ONNXExporter` for multi-file export +- Example: `CenterPointONNXExportWorkflow` composes `ONNXExporter` to produce multiple ONNX files ### 2.2. Dependency Injection Pattern All projects should follow this pattern: ```python -# 1. Import project-specific exporters, wrappers, and runner -from deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter -from deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter +# 1. Import wrappers/workflows and runner from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper from deployment.runners import YOLOXDeploymentRunner -# 2. Create exporters with settings -onnx_exporter = YOLOXONNXExporter( - onnx_settings, - model_wrapper=YOLOXONNXWrapper, - logger=logger, -) -tensorrt_exporter = YOLOXTensorRTExporter( - trt_settings, - model_wrapper=YOLOXONNXWrapper, - logger=logger, -) - -# 3. Pass exporters to the project runner (all required) +# 2. Instantiate the runner with wrapper classes (TensorRT uses base exporter directly) runner = YOLOXDeploymentRunner( ..., - onnx_exporter=onnx_exporter, # Required - tensorrt_exporter=tensorrt_exporter, # Required + onnx_wrapper_cls=YOLOXONNXWrapper, ) ``` +Complex projects (e.g., CenterPoint) can additionally provide workflow instances, which the runner will use before falling back to the standard exporter flow. + **Benefits:** -- Clear dependencies: All components are visible in `main.py` -- Type safety: IDE can provide better type hints +- Clear dependencies: All components and hooks are visible in `main.py` +- Lazy exporter creation: Avoids redundant exporter wiring across projects - No hidden dependencies: No global registry or string-based lookups -- Easy testing: Can inject mock objects for testing -- Unified structure: All models follow the same architectural pattern +- Easy testing: Provide mock wrappers/workflows if needed +- Unified structure: All models follow the same architectural pattern while supporting workflows ### 3. Verification @@ -946,11 +909,9 @@ When adding a new project: - Implement `BaseDataLoader` for data loading 2. **Create exporters following unified architecture pattern** - - Create `exporters/{model}/onnx_exporter.py` (inherit or extend `ONNXExporter`) - - Create `exporters/{model}/tensorrt_exporter.py` (inherit or extend `TensorRTExporter`) - - Create `exporters/{model}/model_wrappers.py` (use `IdentityWrapper` or implement custom wrapper) - - **Simple models**: Inherit base exporters, use custom wrapper if output transformation needed - - **Complex models**: Extend base exporters for special logic (e.g., multi-file export) + - Add `exporters/{model}/model_wrappers.py` (reuse `IdentityWrapper` or implement custom wrapper) + - Introduce `exporters/{model}/onnx_workflow.py` / `tensorrt_workflow.py` only if you need multi-stage orchestration; otherwise rely on the base exporters created by `ExporterFactory` + - Prefer composition over inheritance—extend the workflows, not the base exporters, unless a new backend capability is required 3. **Implement task-specific pipeline** (if needed) - Inherit from appropriate base pipeline (`Detection2DPipeline`, `Detection3DPipeline`, `ClassificationPipeline`) diff --git a/deployment/exporters/__init__.py b/deployment/exporters/__init__.py index 562b5a2bf..6196df918 100644 --- a/deployment/exporters/__init__.py +++ b/deployment/exporters/__init__.py @@ -2,38 +2,16 @@ from deployment.exporters.base.base_exporter import BaseExporter from deployment.exporters.base.configs import ONNXExportConfig, TensorRTExportConfig -from deployment.exporters.base.model_wrappers import ( - BaseModelWrapper, - IdentityWrapper, -) +from deployment.exporters.base.model_wrappers import BaseModelWrapper, IdentityWrapper from deployment.exporters.base.onnx_exporter import ONNXExporter from deployment.exporters.base.tensorrt_exporter import TensorRTExporter -# from deployment.exporters.calibration.model_wrappers import CalibrationONNXWrapper -# from deployment.exporters.calibration.onnx_exporter import CalibrationONNXExporter -# from deployment.exporters.calibration.tensorrt_exporter import CalibrationTensorRTExporter -# from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper -# from deployment.exporters.centerpoint.onnx_exporter import CenterPointONNXExporter -# from deployment.exporters.centerpoint.tensorrt_exporter import CenterPointTensorRTExporter -# from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper -# from deployment.exporters.yolox.onnx_exporter import YOLOXONNXExporter -# from deployment.exporters.yolox.tensorrt_exporter import YOLOXTensorRTExporter - __all__ = [ "BaseExporter", "ONNXExportConfig", "TensorRTExportConfig", "ONNXExporter", "TensorRTExporter", - # "CenterPointONNXExporter", - # "CenterPointTensorRTExporter", - # "CenterPointONNXWrapper", - # "YOLOXONNXExporter", - # "YOLOXTensorRTExporter", - # "YOLOXONNXWrapper", - # "CalibrationONNXExporter", - # "CalibrationTensorRTExporter", - # "CalibrationONNXWrapper", "BaseModelWrapper", "IdentityWrapper", ] diff --git a/deployment/exporters/base/factory.py b/deployment/exporters/base/factory.py new file mode 100644 index 000000000..4aeed055a --- /dev/null +++ b/deployment/exporters/base/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.base.model_wrappers import BaseModelWrapper +from deployment.exporters.base.onnx_exporter import ONNXExporter +from deployment.exporters.base.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/workflows/base.py b/deployment/exporters/workflows/base.py new file mode 100644 index 000000000..b4a678ade --- /dev/null +++ b/deployment/exporters/workflows/base.py @@ -0,0 +1,54 @@ +""" +Base workflow 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.base_config import BaseDeploymentConfig +from deployment.core.base_data_loader import BaseDataLoader + + +class OnnxExportWorkflow(ABC): + """ + Base interface for ONNX export workflows. + """ + + @abstractmethod + def export( + self, + *, + model: Any, + data_loader: BaseDataLoader, + output_dir: str, + config: BaseDeploymentConfig, + sample_idx: int = 0, + **kwargs: Any, + ) -> Artifact: + """ + Execute the ONNX export workflow and return the produced artifact. + """ + + +class TensorRTExportWorkflow(ABC): + """ + Base interface for TensorRT export workflows. + """ + + @abstractmethod + def export( + self, + *, + onnx_path: str, + output_dir: str, + config: BaseDeploymentConfig, + device: str, + data_loader: BaseDataLoader, + **kwargs: Any, + ) -> Artifact: + """ + Execute the TensorRT export workflow and return the produced artifact. + """ diff --git a/deployment/pipelines/base/__init__.py b/deployment/pipelines/base/__init__.py index 844a7da27..1cbc34102 100644 --- a/deployment/pipelines/base/__init__.py +++ b/deployment/pipelines/base/__init__.py @@ -6,13 +6,7 @@ """ from deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline -from deployment.pipelines.base.classification_pipeline import ClassificationPipeline -from deployment.pipelines.base.detection_2d_pipeline import Detection2DPipeline -from deployment.pipelines.base.detection_3d_pipeline import Detection3DPipeline __all__ = [ "BaseDeploymentPipeline", - "ClassificationPipeline", - "Detection2DPipeline", - "Detection3DPipeline", ] diff --git a/deployment/runners/deployment_runner.py b/deployment/runners/deployment_runner.py index f662034a8..d037595c3 100644 --- a/deployment/runners/deployment_runner.py +++ b/deployment/runners/deployment_runner.py @@ -7,12 +7,17 @@ import logging import os -from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union +from typing import Any, Dict, List, Optional, Tuple, Type, TypedDict, Union import torch from mmengine.config import Config from deployment.core import Artifact, Backend, BaseDataLoader, BaseDeploymentConfig, BaseEvaluator, ModelSpec +from deployment.exporters.base.factory import ExporterFactory +from deployment.exporters.base.model_wrappers import BaseModelWrapper +from deployment.exporters.base.onnx_exporter import ONNXExporter +from deployment.exporters.base.tensorrt_exporter import TensorRTExporter +from deployment.exporters.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow class DeploymentResultDict(TypedDict, total=False): @@ -58,8 +63,9 @@ def __init__( config: BaseDeploymentConfig, model_cfg: Config, logger: logging.Logger, - onnx_exporter: Any = None, - tensorrt_exporter: Any = None, + onnx_wrapper_cls: Optional[Type[BaseModelWrapper]] = None, + onnx_workflow: Optional[OnnxExportWorkflow] = None, + tensorrt_workflow: Optional[TensorRTExportWorkflow] = None, ): """ Initialize base deployment runner. @@ -70,27 +76,49 @@ def __init__( config: Deployment configuration model_cfg: Model configuration logger: Logger instance - onnx_exporter: Required ONNX exporter instance (e.g., CenterPointONNXExporter, YOLOXONNXExporter) - tensorrt_exporter: Required TensorRT exporter instance (e.g., CenterPointTensorRTExporter, YOLOXTensorRTExporter) - - Raises: - ValueError: If onnx_exporter or tensorrt_exporter is None + onnx_wrapper_cls: Optional ONNX model wrapper class for exporter creation + onnx_workflow: Optional specialized ONNX workflow + tensorrt_workflow: Optional specialized TensorRT workflow """ - # Validate required exporters - if onnx_exporter is None: - raise ValueError("onnx_exporter is required and cannot be None") - if tensorrt_exporter is None: - raise ValueError("tensorrt_exporter is required and cannot be None") - self.data_loader = data_loader self.evaluator = evaluator self.config = config self.model_cfg = model_cfg self.logger = logger - self._onnx_exporter = onnx_exporter - self._tensorrt_exporter = tensorrt_exporter + self._onnx_wrapper_cls = onnx_wrapper_cls + self._onnx_exporter: Optional[ONNXExporter] = None + self._tensorrt_exporter: Optional[TensorRTExporter] = None + self._onnx_workflow = onnx_workflow + self._tensorrt_workflow = tensorrt_workflow self.artifacts: Dict[str, Artifact] = {} + def _get_onnx_exporter(self) -> ONNXExporter: + """ + Lazily instantiate and return the ONNX exporter. + """ + + 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: + """ + Lazily instantiate and return the TensorRT exporter. + """ + + if self._tensorrt_exporter is None: + self._tensorrt_exporter = ExporterFactory.create_tensorrt_exporter( + config=self.config, + logger=self.logger, + ) + return self._tensorrt_exporter + @staticmethod def _get_backend_entry(mapping: Optional[Dict[Any, Any]], backend: Backend) -> Any: """ @@ -126,7 +154,7 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[Artifact]: """ Export model to ONNX format. - Uses the provided ONNX exporter instance. + Uses either a specialized workflow or the standard ONNX exporter. Args: pytorch_model: PyTorch model to export @@ -138,20 +166,44 @@ def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[Artifact]: if not self.config.export_config.should_export_onnx(): return None - onnx_settings = self.config.get_onnx_settings() + if self._onnx_workflow is None and self._onnx_wrapper_cls is None: + raise RuntimeError("ONNX export requested but no wrapper class or workflow provided.") - exporter = self._onnx_exporter - self.logger.info("=" * 80) - self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") - self.logger.info("=" * 80) + onnx_settings = self.config.get_onnx_settings() + sample_idx = self.config.runtime_config.get("sample_idx", 0) # Save to work_dir/onnx/ directory onnx_dir = os.path.join(self.config.export_config.work_dir, "onnx") os.makedirs(onnx_dir, exist_ok=True) output_path = os.path.join(onnx_dir, onnx_settings.save_file) + if self._onnx_workflow is not None: + self.logger.info("=" * 80) + self.logger.info(f"Exporting to ONNX via workflow ({type(self._onnx_workflow).__name__})") + self.logger.info("=" * 80) + try: + artifact = self._onnx_workflow.export( + model=pytorch_model, + data_loader=self.data_loader, + output_dir=onnx_dir, + config=self.config, + sample_idx=sample_idx, + **kwargs, + ) + except Exception: + self.logger.error("ONNX export workflow failed") + raise + + self.artifacts[Backend.ONNX.value] = 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) + # Get sample input - sample_idx = self.config.runtime_config.get("sample_idx", 0) sample = self.data_loader.load_sample(sample_idx) single_input = self.data_loader.preprocess(sample) @@ -190,7 +242,7 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: """ Export ONNX model to TensorRT engine. - Uses the provided TensorRT exporter instance. + Uses either a specialized workflow or the standard TensorRT exporter. Args: onnx_path: Path to ONNX model file/directory @@ -206,9 +258,12 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: self.logger.warning("ONNX path not available, skipping TensorRT export") return None - exporter = self._tensorrt_exporter + exporter_label = None if self._tensorrt_workflow else type(self._get_tensorrt_exporter()).__name__ self.logger.info("=" * 80) - self.logger.info(f"Exporting to TensorRT (Using {type(exporter).__name__})") + if self._tensorrt_workflow: + self.logger.info(f"Exporting to TensorRT via workflow ({type(self._tensorrt_workflow).__name__})") + else: + self.logger.info(f"Exporting to TensorRT (Using {exporter_label})") self.logger.info("=" * 80) # Save to work_dir/tensorrt/ directory @@ -237,10 +292,28 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: if isinstance(sample_input, (list, tuple)): sample_input = sample_input[0] # Use first input for shape - exporter = self._tensorrt_exporter + if self._tensorrt_workflow is not None: + try: + artifact = self._tensorrt_workflow.export( + onnx_path=onnx_path, + output_dir=tensorrt_dir, + config=self.config, + device=cuda_device, + data_loader=self.data_loader, + **kwargs, + ) + except Exception: + self.logger.error("TensorRT export workflow failed") + raise + + self.artifacts[Backend.TENSORRT.value] = artifact + self.logger.info(f"TensorRT export successful: {artifact.path}") + return artifact + + exporter = self._get_tensorrt_exporter() try: - exporter.export( + artifact = exporter.export( model=None, sample_input=sample_input, output_path=output_path, @@ -250,7 +323,6 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: self.logger.error("TensorRT export failed") raise - artifact = Artifact(path=output_path, multi_file=False) self.artifacts[Backend.TENSORRT.value] = artifact self.logger.info(f"TensorRT export successful: {artifact.path}") return artifact From 16acc21ba62a376b49ca5d45e1e18ccc06cb68a8 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 21 Nov 2025 01:37:02 +0900 Subject: [PATCH 14/62] chore: fix words Signed-off-by: vividf --- deployment/runners/deployment_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deployment/runners/deployment_runner.py b/deployment/runners/deployment_runner.py index d037595c3..20af15344 100644 --- a/deployment/runners/deployment_runner.py +++ b/deployment/runners/deployment_runner.py @@ -278,7 +278,7 @@ def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: engine_filename = onnx_filename.replace(".onnx", ".engine") output_path = os.path.join(tensorrt_dir, engine_filename) - # Set CUDA device for TensorRT exportd + # Set CUDA device for TensorRT export cuda_device = self.config.export_config.cuda_device device_id = self.config.export_config.get_cuda_device_index() torch.cuda.set_device(device_id) @@ -543,7 +543,7 @@ def run_verification( self.logger.warning(f"Device alias '{test_device_key}' not found in devices map, using as-is") self.logger.info( - f"\nScenarios {i+1}/{len(scenarios)}: " + f"\nScenario {i+1}/{len(scenarios)}: " f"{ref_backend.value}({ref_device}) vs {test_backend.value}({test_device})" ) From 287413bcc92907495e1e6ae0c14c67b540a3484d Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 27 Nov 2025 16:47:07 +0900 Subject: [PATCH 15/62] refactor architecture, clean up readme Signed-off-by: vividf --- deployment/README.md | 956 +----------------- deployment/__init__.py | 8 +- deployment/core/__init__.py | 104 +- deployment/core/base_evaluator.py | 226 ----- deployment/core/config/__init__.py | 54 + deployment/core/{ => config}/base_config.py | 26 +- deployment/core/config/constants.py | 51 + deployment/core/config/runtime_config.py | 63 ++ deployment/core/config/task_config.py | 105 ++ deployment/core/contexts.py | 164 +++ deployment/core/evaluation/__init__.py | 38 + deployment/core/evaluation/base_evaluator.py | 297 ++++++ deployment/core/evaluation/evaluator_types.py | 71 ++ deployment/core/evaluation/results.py | 273 +++++ .../core/evaluation/verification_mixin.py | 481 +++++++++ deployment/core/io/__init__.py | 9 + deployment/core/{ => io}/base_data_loader.py | 33 + .../core/{ => io}/preprocessing_builder.py | 0 deployment/core/metrics/__init__.py | 75 ++ .../core/metrics/base_metrics_adapter.py | 113 +++ .../core/metrics/classification_metrics.py | 331 ++++++ .../core/metrics/detection_2d_metrics.py | 479 +++++++++ .../core/metrics/detection_3d_metrics.py | 495 +++++++++ deployment/docs/README.md | 13 + deployment/docs/architecture.md | 77 ++ deployment/docs/best_practices.md | 84 ++ deployment/docs/configuration.md | 134 +++ deployment/docs/contributing.md | 31 + deployment/docs/core_contract.md | 57 ++ deployment/docs/export_workflow.md | 50 + deployment/docs/overview.md | 59 ++ deployment/docs/projects.md | 79 ++ deployment/docs/usage.md | 123 +++ deployment/docs/verification_evaluation.md | 65 ++ deployment/exporters/__init__.py | 10 +- .../{base => common}/base_exporter.py | 4 +- .../exporters/{base => common}/configs.py | 0 .../exporters/{base => common}/factory.py | 6 +- .../{base => common}/model_wrappers.py | 0 .../{base => common}/onnx_exporter.py | 6 +- .../{base => common}/tensorrt_exporter.py | 79 +- deployment/exporters/workflows/__init__.py | 16 + deployment/exporters/workflows/base.py | 36 +- deployment/exporters/workflows/interfaces.py | 66 ++ deployment/pipelines/__init__.py | 82 +- deployment/pipelines/base/__init__.py | 12 - deployment/pipelines/common/__init__.py | 11 + .../{base => common}/base_pipeline.py | 51 +- .../pipelines/common/gpu_resource_mixin.py | 238 +++++ deployment/pipelines/factory.py | 208 ++++ deployment/runners/__init__.py | 24 +- deployment/runners/common/__init__.py | 16 + deployment/runners/common/artifact_manager.py | 163 +++ .../runners/common/deployment_runner.py | 218 ++++ .../runners/common/evaluation_orchestrator.py | 215 ++++ .../runners/common/export_orchestrator.py | 518 ++++++++++ .../common/verification_orchestrator.py | 234 +++++ deployment/runners/deployment_runner.py | 859 ---------------- 58 files changed, 6201 insertions(+), 2095 deletions(-) delete mode 100644 deployment/core/base_evaluator.py create mode 100644 deployment/core/config/__init__.py rename deployment/core/{ => config}/base_config.py (95%) create mode 100644 deployment/core/config/constants.py create mode 100644 deployment/core/config/runtime_config.py create mode 100644 deployment/core/config/task_config.py create mode 100644 deployment/core/contexts.py create mode 100644 deployment/core/evaluation/__init__.py create mode 100644 deployment/core/evaluation/base_evaluator.py create mode 100644 deployment/core/evaluation/evaluator_types.py create mode 100644 deployment/core/evaluation/results.py create mode 100644 deployment/core/evaluation/verification_mixin.py create mode 100644 deployment/core/io/__init__.py rename deployment/core/{ => io}/base_data_loader.py (65%) rename deployment/core/{ => io}/preprocessing_builder.py (100%) create mode 100644 deployment/core/metrics/__init__.py create mode 100644 deployment/core/metrics/base_metrics_adapter.py create mode 100644 deployment/core/metrics/classification_metrics.py create mode 100644 deployment/core/metrics/detection_2d_metrics.py create mode 100644 deployment/core/metrics/detection_3d_metrics.py create mode 100644 deployment/docs/README.md create mode 100644 deployment/docs/architecture.md create mode 100644 deployment/docs/best_practices.md create mode 100644 deployment/docs/configuration.md create mode 100644 deployment/docs/contributing.md create mode 100644 deployment/docs/core_contract.md create mode 100644 deployment/docs/export_workflow.md create mode 100644 deployment/docs/overview.md create mode 100644 deployment/docs/projects.md create mode 100644 deployment/docs/usage.md create mode 100644 deployment/docs/verification_evaluation.md rename deployment/exporters/{base => common}/base_exporter.py (94%) rename deployment/exporters/{base => common}/configs.py (100%) rename deployment/exporters/{base => common}/factory.py (83%) rename deployment/exporters/{base => common}/model_wrappers.py (100%) rename deployment/exporters/{base => common}/onnx_exporter.py (97%) rename deployment/exporters/{base => common}/tensorrt_exporter.py (74%) create mode 100644 deployment/exporters/workflows/__init__.py create mode 100644 deployment/exporters/workflows/interfaces.py delete mode 100644 deployment/pipelines/base/__init__.py create mode 100644 deployment/pipelines/common/__init__.py rename deployment/pipelines/{base => common}/base_pipeline.py (81%) create mode 100644 deployment/pipelines/common/gpu_resource_mixin.py create mode 100644 deployment/pipelines/factory.py create mode 100644 deployment/runners/common/__init__.py create mode 100644 deployment/runners/common/artifact_manager.py create mode 100644 deployment/runners/common/deployment_runner.py create mode 100644 deployment/runners/common/evaluation_orchestrator.py create mode 100644 deployment/runners/common/export_orchestrator.py create mode 100644 deployment/runners/common/verification_orchestrator.py delete mode 100644 deployment/runners/deployment_runner.py diff --git a/deployment/README.md b/deployment/README.md index 57f160530..189a7cdb2 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -1,937 +1,85 @@ # AWML Deployment Framework -A unified, task-agnostic deployment framework for exporting PyTorch models to ONNX and TensorRT, with comprehensive verification and evaluation capabilities. +AWML ships a unified, task-agnostic deployment stack that turns trained PyTorch +checkpoints into production-ready ONNX and TensorRT artifacts. The same typed +verification and evaluation toolchain runs across every backend so numerical +parity and metrics stay consistent from project to project. -## Table of Contents +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. -- [Overview](#overview) -- [Architecture](#architecture) -- [Key Features](#key-features) -- [Usage](#usage) -- [Configuration](#configuration) -- [Project-Specific Implementations](#project-specific-implementations) -- [Pipeline Architecture](#pipeline-architecture) -- [Export Workflow](#export-workflow) -- [Verification & Evaluation](#verification--evaluation) ---- - -## Overview - -The AWML Deployment Framework provides a standardized approach to model deployment across different projects (CenterPoint, YOLOX, CalibrationStatusClassification). It abstracts common deployment workflows while allowing project-specific customizations. - -### Design Principles - -1. **Unified Interface**: Shared base runner (`BaseDeploymentRunner`) with project-specific subclasses -2. **Task-Agnostic Core**: Base classes that work across detection, classification, and segmentation -3. **Backend Flexibility**: Support for PyTorch, ONNX, and TensorRT backends -4. **Pipeline Architecture**: Shared preprocessing/postprocessing with backend-specific inference -5. **Configuration-Driven**: All settings controlled via config files -6. **Dependency Injection**: Explicit creation and injection of exporters and wrappers for better clarity and type safety - ---- - -## Architecture - -### High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Project Entry Points │ -│ (projects/*/deploy/main.py) │ -│ - CenterPoint, YOLOX-ELAN, Calibration │ -└──────────────────┬──────────────────────────────────────┘ - │ -┌──────────────────▼──────────────────────────────────────┐ -│ BaseDeploymentRunner + Project Runners │ -│ - Coordinates export → verification → evaluation │ -│ - Each project extends the base class for custom logic│ -└──────────────────┬──────────────────────────────────────┘ - │ - ┌──────────┴──────────┐ - │ │ -┌───────▼────────┐ ┌────────▼────────┐ -│ Exporters │ │ Evaluators │ -│ - ONNX │ │ - Task-specific│ -│ - TensorRT │ │ - Metrics │ -│ - Wrappers │ │ │ -│ (Unified │ │ │ -│ structure) │ │ │ -└────────────────┘ └─────────────────┘ - │ │ - └──────────┬──────────┘ - │ -┌──────────────────▼──────────────────────────────────────┐ -│ Pipeline Architecture │ -│ - BaseDeploymentPipeline │ -│ - Task-specific pipelines (Detection2D/3D, Classify) │ -│ - Backend-specific implementations │ -└──────────────────────────────────────────────────────────┘ -``` - -### Core Components - -#### 1. **BaseDeploymentRunner & Project Runners** -`BaseDeploymentRunner` orchestrates the complete deployment workflow, while each project provides a thin subclass (`CenterPointDeploymentRunner`, `YOLOXDeploymentRunner`, `CalibrationDeploymentRunner`) that plugs in model-specific logic. - -- **Model Loading**: Implemented by each project runner to load PyTorch checkpoints -- **Export**: Uses lazily constructed ONNX/TensorRT exporters (via `ExporterFactory`) informed by wrapper classes and optional workflows -- **Verification**: Scenario-based verification across backends -- **Evaluation**: Performance metrics and latency statistics - -**Required Parameters:** -- `onnx_wrapper_cls`: Optional model wrapper class for ONNX export (required unless a workflow performs the export) -- `onnx_workflow` / `tensorrt_workflow`: Optional workflow objects for specialized multi-file exports - -Runners own exporter initialization and reuse, ensuring consistent logging/configuration while keeping project entry points lightweight. - -#### 2. **Core Components** (in `core/`) - -- **`BaseDeploymentConfig`**: Configuration container for deployment settings -- **`Backend`**: Enum for supported backends (PyTorch, ONNX, TensorRT) -- **`Artifact`**: Dataclass representing deployment artifacts (ONNX/TensorRT outputs) -- **`BaseEvaluator`**: Abstract interface for task-specific evaluation -- **`BaseDataLoader`**: Abstract interface for data loading -- **`build_preprocessing_pipeline`**: Utility to extract preprocessing pipelines from MMDet/MMDet3D configs -- **`BaseDeploymentPipeline`**: Abstract pipeline for inference (in `pipelines/base/`) -- **`Detection2DPipeline`**: Base pipeline for 2D detection tasks -- **`Detection3DPipeline`**: Base pipeline for 3D detection tasks -- **`ClassificationPipeline`**: Base pipeline for classification tasks - -#### 3. **Exporters & Workflows** - -**Unified Architecture**: -- Exporters are created lazily by `ExporterFactory`, so project entry points only declare wrappers/workflows and never wire exporters manually. -- Base workflow interfaces live in `exporters/workflows/base.py`, enabling complex projects to orchestrate multi-stage exports without forking the base exporters. -- Simple projects rely directly on the base exporters with optional wrappers. -- Complex projects (CenterPoint) assemble workflows (`onnx_workflow.py`, `tensorrt_workflow.py`) that orchestrate multiple single-file exports using the base exporters via composition. - -- **Base Exporters** (in `exporters/base/`): - - **`BaseExporter`**: Abstract base class for all exporters - - **`ONNXExporter`**: Standard ONNX export with model wrapping support - - **`TensorRTExporter`**: TensorRT engine building with precision policies - - **`BaseModelWrapper`**: Abstract base class for model wrappers - - **`IdentityWrapper`**: Provided wrapper that doesn't modify model output - - **`configs.py`**: Typed configuration classes: - - **`ONNXExportConfig`**: Typed schema for ONNX exporter configuration - - **`TensorRTExportConfig`**: Typed schema for TensorRT exporter configuration - - **`TensorRTModelInputConfig`**: Configuration for TensorRT input shapes - - **`TensorRTProfileConfig`**: Optimization profile configuration for dynamic shapes - -- **Factory & Workflow Interfaces**: - - **`ExporterFactory`** (`exporters/base/factory.py`): Builds `ONNXExporter`/`TensorRTExporter` instances using `BaseDeploymentConfig` settings, ensuring consistent logging and configuration. - - **`OnnxExportWorkflow` / `TensorRTExportWorkflow`** (`exporters/workflows/base.py`): Abstract contracts for orchestrating complex, multi-artifact exports. - -- **Project-Specific Wrappers & Workflows**: - - **YOLOX** (`exporters/yolox/`): - - **`YOLOXONNXWrapper`**: Transforms YOLOX output to Tier4-compatible format; paired with base exporters created by the factory. - - **CenterPoint** (`exporters/centerpoint/`): - - **`CenterPointONNXExportWorkflow`**: Composes the generic `ONNXExporter` to emit multiple ONNX files - - **`CenterPointTensorRTExportWorkflow`**: Composes the generic `TensorRTExporter` to build multiple engines - - **`CenterPointONNXWrapper`**: Identity wrapper (no transformation needed) - - **Calibration** (`exporters/calibration/`): - - **`CalibrationONNXWrapper`**: Identity wrapper (no transformation needed); paired with the base exporters from the factory - -**Architecture Pattern**: -- **Simple models** (YOLOX, Calibration): Instantiate the generic base exporters via `ExporterFactory` and supply custom wrappers when needed; no subclassing required. -- **Complex models** (CenterPoint): Keep base exporters generic and layer workflows for multi-file orchestration, still using wrappers as needed. - -#### 4. **Pipelines** - -- **`BaseDeploymentPipeline`**: Abstract base with `preprocess() → run_model() → postprocess()` -- **Task-specific pipelines**: `Detection2DPipeline`, `Detection3DPipeline`, `ClassificationPipeline` -- **Backend implementations**: PyTorch, ONNX, TensorRT variants for each pipeline - ---- - -## Key Features - -### 1. Unified Deployment Workflow - -All projects follow the same workflow: -``` -Load Model → Export ONNX → Export TensorRT → Verify → Evaluate -``` - -### 2. Scenario-Based Verification - -Flexible verification system that compares outputs between backends: - -```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"}, - ] - } -) -``` - -### 3. Multi-Backend Evaluation - -Evaluate models across multiple backends with consistent metrics: - -```python -evaluation = dict( - enabled=True, - backends={ - "pytorch": {"enabled": True, "device": "cpu"}, - "onnx": {"enabled": True, "device": "cpu"}, - "tensorrt": {"enabled": True, "device": "cuda:0"}, - } -) -``` - -### 4. Pipeline Architecture - -Shared preprocessing/postprocessing with backend-specific inference: - -- **Preprocessing**: Image resize, normalization, voxelization (shared) - - Can be built from MMDet/MMDet3D configs using `build_preprocessing_pipeline` - - Used in data loaders to prepare input data -- **Model Inference**: Backend-specific (PyTorch/ONNX/TensorRT) -- **Postprocessing**: NMS, coordinate transform, decoding (shared) - -### 5. Flexible Export Modes - -- `mode="onnx"`: Export PyTorch → ONNX only -- `mode="trt"`: Build TensorRT from existing ONNX -- `mode="both"`: Full export pipeline -- `mode="none"`: Skip export (evaluation only) - -### 6. Precision Policies for TensorRT - -Support for different TensorRT precision modes: - -- `auto`: TensorRT decides automatically -- `fp16`: FP16 precision -- `fp32_tf32`: FP32 with TF32 acceleration -- `strongly_typed`: Enable strongly-typed TensorRT networks (all tensor dtypes must be explicitly defined; no implicit casting). - ---- - -## Usage - -### Basic Usage +## Quick Start ```bash # CenterPoint deployment -python projects/CenterPoint/deploy/main.py \ - configs/deploy_config.py \ - configs/model_config.py +python projects/CenterPoint/deploy/main.py configs/deploy_config.py configs/model_config.py # YOLOX deployment -python projects/YOLOX_opt_elan/deploy/main.py \ - configs/deploy_config.py \ - configs/model_config.py +python projects/YOLOX_opt_elan/deploy/main.py configs/deploy_config.py configs/model_config.py # Calibration deployment -python projects/CalibrationStatusClassification/deploy/main.py \ - configs/deploy_config.py \ - configs/model_config.py -``` - -### Creating a Project Runner - -Projects now pass lightweight configuration objects (wrapper classes and optional workflows) into the runner. The runner owns exporter construction through `ExporterFactory` and creates the exporters lazily. Example (YOLOX): - -```python -from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper -from deployment.runners import YOLOXDeploymentRunner - -# Instantiate the project runner -runner = YOLOXDeploymentRunner( - data_loader=data_loader, - evaluator=evaluator, - config=config, - model_cfg=model_cfg, - logger=logger, - onnx_wrapper_cls=YOLOXONNXWrapper, -) -``` - -**Key Points:** -- Pass wrapper classes (and optional workflows) instead of exporter instances -- Exporters are constructed lazily inside `BaseDeploymentRunner` via `ExporterFactory` -- Projects still control model-specific behavior by choosing wrappers/workflows -- Entry points remain simple while keeping dependencies explicit - -### Command-Line Arguments - -```bash -python deploy/main.py \ - \ # Deployment configuration file - \ # Model configuration file - [checkpoint] \ # Optional: checkpoint path (can be in config) - --work-dir \ # Optional: override work directory - --device \ # Optional: override device - --log-level # Optional: logging level (DEBUG, INFO, WARNING, ERROR) -``` - -### Export Modes - -#### Export ONNX Only -```python -export = dict( - mode="onnx", - checkpoint_path="model.pth", - work_dir="work_dirs/deployment", -) -``` - -#### Build 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 -export = dict( - mode="both", - checkpoint_path="model.pth", - work_dir="work_dirs/deployment", -) -``` - -#### Evaluation Only (No Export) -```python -export = dict( - mode="none", - work_dir="work_dirs/deployment", -) -``` - ---- - -## Configuration - -### Configuration Structure - -```python -# Task type -task_type = "detection3d" # or "detection2d", "classification" - -# Export configuration -export = dict( - mode="both", # "onnx", "trt", "both", "none" - work_dir="work_dirs/deployment", - checkpoint_path="model.pth", - onnx_path=None, # Optional: for mode="trt" -) - -# Runtime I/O settings -runtime_io = dict( - info_file="data/info.pkl", # Dataset info file - sample_idx=0, # Sample index for export -) - -# Model I/O configuration -model_io = dict( - input_name="input", - input_shape=(3, 960, 960), # (C, H, W) - input_dtype="float32", - output_name="output", - batch_size=1, # or None for dynamic - dynamic_axes={...}, # When batch_size=None -) - -# ONNX configuration -onnx_config = dict( - opset_version=16, - do_constant_folding=True, - save_file="model.onnx", - multi_file=False, # True for multi-file ONNX (e.g., CenterPoint) -) - -# Backend configuration -backend_config = dict( - common_config=dict( - precision_policy="auto", # "auto", "fp16", "fp32_tf32", "strongly_typed" - max_workspace_size=1 << 30, # 1 GB - ), -) - -# Verification configuration -verification = dict( - enabled=True, - num_verify_samples=3, - tolerance=0.1, - devices={ - "cpu": "cpu", - "cuda": "cuda:0", - }, - scenarios={ - "both": [ - {"ref_backend": "pytorch", "ref_device": "cpu", - "test_backend": "onnx", "test_device": "cpu"}, - ] - } -) - -# Evaluation configuration -evaluation = dict( - enabled=True, - num_samples=100, # or -1 for all samples - verbose=False, - backends={ - "pytorch": {"enabled": True, "device": "cpu"}, - "onnx": {"enabled": True, "device": "cpu"}, - "tensorrt": {"enabled": True, "device": "cuda:0"}, - } -) -``` - -#### Backend Enum - -To avoid backend name typos, `deployment.core.Backend` enumerates the supported values: - -```python -from deployment.core import Backend - -evaluation = dict( - backends={ - Backend.PYTORCH: {"enabled": True, "device": "cpu"}, - Backend.ONNX: {"enabled": True, "device": "cpu"}, - Backend.TENSORRT: {"enabled": True, "device": "cuda:0"}, - } -) -``` - -Configuration dictionaries accept either raw strings or `Backend` enum members, so teams can adopt the enum incrementally without breaking existing configs. - -#### Typed Exporter Configurations - -The framework provides typed configuration classes in `deployment.exporters.base.configs` for better type safety and validation: - -```python -from deployment.exporters.base.configs import ( - ONNXExportConfig, - TensorRTExportConfig, - TensorRTModelInputConfig, - TensorRTProfileConfig, -) - -# ONNX configuration with typed schema -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, -) - -# TensorRT configuration with typed schema -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), - ) - } - ), - ), -) -``` - -These typed configs can be created from dictionaries using `from_mapping()` or `from_dict()` class methods, providing a bridge between configuration files and type-safe code. - -### Configuration Examples - -See project-specific configs: -- `projects/CenterPoint/deploy/configs/deploy_config.py` -- `projects/YOLOX_opt_elan/deploy/configs/deploy_config.py` -- `projects/CalibrationStatusClassification/deploy/configs/deploy_config.py` - ---- - -## Project-Specific Implementations - -### CenterPoint (3D Detection) - -**Features:** -- Multi-file ONNX export (voxel encoder + backbone/head) orchestrated via workflows -- ONNX-compatible model configuration -- Composed exporters for complex model structure - -**Workflows and Wrapper:** -- `CenterPointONNXExportWorkflow`: Drives multiple ONNX exports using the generic `ONNXExporter` -- `CenterPointTensorRTExportWorkflow`: Converts each ONNX file with the generic `TensorRTExporter` -- `CenterPointONNXWrapper`: Identity wrapper (no output transformation) - -**Key Files:** -- `projects/CenterPoint/deploy/main.py` -- `projects/CenterPoint/deploy/evaluator.py` -- `deployment/pipelines/centerpoint/` -- `deployment/exporters/centerpoint/onnx_workflow.py` -- `deployment/exporters/centerpoint/tensorrt_workflow.py` -- `deployment/exporters/centerpoint/model_wrappers.py` - -**Pipeline Structure:** +python projects/CalibrationStatusClassification/deploy/main.py configs/deploy_config.py configs/model_config.py ``` -preprocess() → run_voxel_encoder() → process_middle_encoder() → -run_backbone_head() → postprocess() -``` - -### YOLOX (2D Detection) - -**Features:** -- Standard single-file ONNX export -- Model wrapper for ONNX-compatible output format -- ReLU6 → ReLU replacement for ONNX compatibility - -**Export + Wrapper:** -- `ONNXExporter`: Generic exporter instantiated with `YOLOXONNXWrapper` -- `TensorRTExporter`: Generic exporter instantiated with the same wrapper -- `YOLOXONNXWrapper`: Transforms output from `(1, 8, 120, 120)` to `(1, 18900, 13)` format - -**Key Files:** -- `projects/YOLOX_opt_elan/deploy/main.py` -- `projects/YOLOX_opt_elan/deploy/evaluator.py` -- `deployment/pipelines/yolox/` -- `deployment/exporters/yolox/model_wrappers.py` - -**Pipeline Structure:** -``` -preprocess() → run_model() → postprocess() -``` - -### CalibrationStatusClassification - -**Features:** -- Binary classification task -- Simple single-file ONNX export -- Calibrated/miscalibrated data loader variants - -**Export + Wrapper:** -- `ONNXExporter`: Generic exporter instantiated with `CalibrationONNXWrapper` -- `TensorRTExporter`: Generic exporter instantiated with the same wrapper -- `CalibrationONNXWrapper`: Identity wrapper (no output transformation) - -**Key Files:** -- `projects/CalibrationStatusClassification/deploy/main.py` -- `projects/CalibrationStatusClassification/deploy/evaluator.py` -- `deployment/pipelines/calibration/` -- `deployment/exporters/calibration/model_wrappers.py` - -**Pipeline Structure:** -``` -preprocess() → run_model() → postprocess() -``` - ---- - -## Pipeline Architecture - -### Base Pipeline - -All pipelines inherit from `BaseDeploymentPipeline` (located in `pipelines/base/base_pipeline.py`): - -```python -class BaseDeploymentPipeline(ABC): - @abstractmethod - def preprocess(self, input_data, **kwargs) -> Any: - """Preprocess input data""" - pass - - @abstractmethod - def run_model(self, preprocessed_input) -> Any: - """Backend-specific model inference""" - pass - - @abstractmethod - def postprocess(self, model_output, metadata) -> Any: - """Postprocess model output""" - pass - - def infer(self, input_data, **kwargs): - """Complete inference pipeline""" - preprocessed = self.preprocess(input_data, **kwargs) - model_output = self.run_model(preprocessed) - predictions = self.postprocess(model_output, metadata) - return predictions -``` - -### Task-Specific Base Pipelines - -Located in `pipelines/base/`, these provide task-specific abstractions: - -#### Detection2DPipeline (`pipelines/base/detection_2d_pipeline.py`) -- Shared preprocessing: image resize, normalization, padding -- Shared postprocessing: bbox decoding, NMS, coordinate transform -- Backend-specific: model inference - -#### Detection3DPipeline (`pipelines/base/detection_3d_pipeline.py`) -- Shared preprocessing: voxelization, feature extraction -- Shared postprocessing: 3D bbox decoding, NMS -- Backend-specific: voxel encoder, backbone/head inference - -#### ClassificationPipeline (`pipelines/base/classification_pipeline.py`) -- Shared preprocessing: image normalization -- Shared postprocessing: softmax, top-k selection -- Backend-specific: model inference - -### Backend Implementations - -Each pipeline has three backend implementations: - -1. **PyTorch Pipeline**: Direct PyTorch model inference -2. **ONNX Pipeline**: ONNX Runtime inference -3. **TensorRT Pipeline**: TensorRT engine inference - -Example for YOLOX: -- `YOLOXPyTorchPipeline`: Uses PyTorch model directly -- `YOLOXONNXPipeline`: Uses ONNX Runtime -- `YOLOXTensorRTPipeline`: Uses TensorRT engine - ---- - -## Export Workflow - -### ONNX Export - -1. **Model Preparation**: Load PyTorch model, apply model wrapper if needed -2. **Input Preparation**: Get sample input from data loader -3. **Export**: Call `torch.onnx.export()` with configured settings -4. **Simplification**: Optional ONNX model simplification -5. **Save**: Save to `work_dir/onnx/` - -### TensorRT Export - -1. **ONNX Validation**: Verify ONNX model exists -2. **Network Creation**: Parse ONNX, create TensorRT network -3. **Precision Configuration**: Apply precision policy flags -4. **Optimization Profile**: Configure input shape ranges -5. **Engine Building**: Build and save TensorRT engine -6. **Save**: Save to `work_dir/tensorrt/` - -### Multi-File Export (CenterPoint) - -CenterPoint uses a multi-file ONNX structure: -- `voxel_encoder.onnx`: Voxel feature extraction -- `backbone_head.onnx`: Backbone and detection head - -The exporter handles: -- Sequential export of each component -- Proper input/output linking -- Directory-based organization - ---- - -## Verification & Evaluation - -### Verification - -Policy-based verification compares outputs between backends: - -```python -# Verification scenarios example -verification = dict( - enabled=True, - scenarios={ - "both": [ - { - "ref_backend": "pytorch", - "ref_device": "cpu", - "test_backend": "onnx", - "test_device": "cpu" - } - ] - }, - tolerance=0.1, # Maximum allowed difference - num_verify_samples=3 -) -``` - -**Verification Process:** -1. Load reference model (PyTorch or ONNX) -2. Load test model (ONNX or TensorRT) -3. Run inference on same samples -4. Compare outputs with tolerance -5. Report pass/fail for each sample - -### Evaluation - -Task-specific evaluation with consistent metrics: - -**Detection Tasks:** -- mAP (mean Average Precision) -- Per-class AP -- Latency statistics - -**Classification Tasks:** -- Accuracy -- Precision, Recall -- Per-class metrics -- Confusion matrix -- Latency statistics - -**Evaluation Configuration:** -```python -evaluation = dict( - enabled=True, - num_samples=100, # or -1 for all - verbose=False, - backends={ - "pytorch": {"enabled": True, "device": "cpu"}, - "onnx": {"enabled": True, "device": "cpu"}, - "tensorrt": {"enabled": True, "device": "cuda:0"}, - } -) -``` - ---- - -## File Structure - -``` -deployment/ -├── core/ # Core base classes and utilities -│ ├── artifacts.py # Artifact descriptors (ONNX/TensorRT outputs) -│ ├── backend.py # Backend enum (PyTorch, ONNX, TensorRT) -│ ├── base_config.py # Configuration management -│ ├── base_data_loader.py # Data loader interface -│ ├── base_evaluator.py # Evaluator interface -│ └── preprocessing_builder.py # Preprocessing pipeline builder -│ -├── exporters/ # Model exporters (unified structure) -│ ├── base/ # Base exporter classes -│ │ ├── base_exporter.py # Exporter base class -│ │ ├── configs.py # Typed configuration classes (ONNXExportConfig, TensorRTExportConfig) -│ │ ├── factory.py # ExporterFactory that builds ONNX/TensorRT exporters -│ │ ├── onnx_exporter.py # ONNX exporter base class -│ │ ├── tensorrt_exporter.py # TensorRT exporter base class -│ │ └── model_wrappers.py # Base model wrappers (BaseModelWrapper, IdentityWrapper) -│ ├── workflows/ # Workflow interfaces -│ │ └── base.py # OnnxExportWorkflow & TensorRTExportWorkflow ABCs -│ ├── centerpoint/ # CenterPoint-specific helpers (compose base exporters) -│ │ ├── model_wrappers.py # CenterPoint model wrappers (IdentityWrapper) -│ │ ├── onnx_workflow.py # CenterPoint multi-file ONNX workflow -│ │ └── tensorrt_workflow.py # CenterPoint multi-file TensorRT workflow -│ ├── yolox/ # YOLOX wrappers (paired with base exporters) -│ │ └── model_wrappers.py # YOLOX model wrappers (YOLOXONNXWrapper) -│ └── calibration/ # CalibrationStatusClassification wrappers -│ └── model_wrappers.py # Calibration model wrappers (IdentityWrapper) -│ -├── pipelines/ # Task-specific pipelines -│ ├── base/ # Base pipeline classes -│ │ ├── base_pipeline.py # Pipeline base class -│ │ ├── detection_2d_pipeline.py # 2D detection pipeline -│ │ ├── detection_3d_pipeline.py # 3D detection pipeline -│ │ └── classification_pipeline.py # Classification pipeline -│ ├── centerpoint/ # CenterPoint pipelines -│ │ ├── centerpoint_pipeline.py -│ │ ├── centerpoint_pytorch.py -│ │ ├── centerpoint_onnx.py -│ │ └── centerpoint_tensorrt.py -│ ├── yolox/ # YOLOX pipelines -│ │ ├── yolox_pipeline.py -│ │ ├── yolox_pytorch.py -│ │ ├── yolox_onnx.py -│ │ └── yolox_tensorrt.py -│ └── calibration/ # Calibration pipelines -│ ├── calibration_pipeline.py -│ ├── calibration_pytorch.py -│ ├── calibration_onnx.py -│ └── calibration_tensorrt.py -│ -└── runners/ # Deployment runners - ├── deployment_runner.py # BaseDeploymentRunner - ├── centerpoint_runner.py # CenterPointDeploymentRunner - ├── yolox_runner.py # YOLOXDeploymentRunner - └── calibration_runner.py # CalibrationDeploymentRunner - -projects/ -├── CenterPoint/deploy/ -│ ├── main.py # Entry point -│ ├── evaluator.py # CenterPoint evaluator -│ ├── data_loader.py # CenterPoint data loader -│ └── configs/ -│ └── deploy_config.py # Deployment configuration -│ -├── YOLOX_opt_elan/deploy/ -│ ├── main.py -│ ├── evaluator.py -│ ├── data_loader.py -│ └── configs/ -│ └── deploy_config.py -│ -└── CalibrationStatusClassification/deploy/ - ├── main.py - ├── evaluator.py - ├── data_loader.py - └── configs/ - └── deploy_config.py -``` - ---- - -## Best Practices - -### 1. Configuration Management - -- Keep deployment configs separate from model configs -- Use relative paths for data files -- Document all configuration options - -### 2. Model Export - -- Pass wrapper classes (and optional workflows) into project runners; `ExporterFactory` constructs ONNX/TensorRT exporters using deployment configs. -- Keep wrapper definitions in `exporters/{model}/model_wrappers.py`; reuse `IdentityWrapper` when no transformation is needed. -- Introduce workflow modules (`exporters/{model}/onnx_workflow.py`, `tensorrt_workflow.py`) only when orchestration beyond single-file export is required. -- Simple models: rely on generic base exporters + wrappers; no subclassing or custom exporters. -- Complex models: implement workflow classes that drive multiple calls into the generic exporters while keeping exporter logic centralized. -- Always verify ONNX export before TensorRT conversion and prefer multiple samples to validate stability. -- Use appropriate precision policies for TensorRT (auto/fp16/fp32_tf32/strongly_typed) based on deployment constraints. - -### 2.1. Unified Architecture Pattern - -All projects follow a unified structure, with simple models sticking to exporter modules and complex models layering workflows on top: - -``` -exporters/{model}/ -├── model_wrappers.py # Project-specific model wrapper -├── [optional] onnx_workflow.py # Workflow orchestrating base exporter calls -├── [optional] tensorrt_workflow.py # Workflow orchestrating base exporter calls -``` - -**Pattern 1: Simple Models** (YOLOX, Calibration) -- Instantiate the generic base exporters (no subclassing needed) -- Use custom wrappers if output format transformation is required -- Example: YOLOX uses `ONNXExporter` + `YOLOXONNXWrapper` - -**Pattern 2: Complex Models** (CenterPoint) -- Keep base exporters generic but introduce workflow classes for special requirements (e.g., multi-file export) -- Use IdentityWrapper if no output transformation needed -- Example: `CenterPointONNXExportWorkflow` composes `ONNXExporter` to produce multiple ONNX files - -### 2.2. Dependency Injection Pattern - -All projects should follow this pattern: - -```python -# 1. Import wrappers/workflows and runner -from deployment.exporters.yolox.model_wrappers import YOLOXONNXWrapper -from deployment.runners import YOLOXDeploymentRunner - -# 2. Instantiate the runner with wrapper classes (TensorRT uses base exporter directly) -runner = YOLOXDeploymentRunner( - ..., - onnx_wrapper_cls=YOLOXONNXWrapper, -) -``` - -Complex projects (e.g., CenterPoint) can additionally provide workflow instances, which the runner will use before falling back to the standard exporter flow. - -**Benefits:** -- Clear dependencies: All components and hooks are visible in `main.py` -- Lazy exporter creation: Avoids redundant exporter wiring across projects -- No hidden dependencies: No global registry or string-based lookups -- Easy testing: Provide mock wrappers/workflows if needed -- Unified structure: All models follow the same architectural pattern while supporting workflows - -### 3. Verification - -- Start with small tolerance (0.01) and increase if needed -- Verify on representative samples -- Check both accuracy and numerical differences - -### 4. Evaluation - -- Use consistent evaluation settings across backends -- Report latency statistics (mean, std, min, max) -- Compare metrics across backends - -### 5. Pipeline Development - -- Inherit from appropriate base pipeline -- Share preprocessing/postprocessing logic -- Keep backend-specific code minimal - ---- - -## Troubleshooting - -### Common Issues -1. **ONNX Export Fails** - - Check model compatibility (unsupported ops) - - Verify input shapes match model expectations - - Try different opset versions +Command-line flags (`--work-dir`, `--device`, `--log-level`, optional `checkpoint`) are consistent across projects. Inject wrapper classes and optional workflows when instantiating a runner; exporters are created lazily inside `BaseDeploymentRunner`. -2. **TensorRT Build Fails** - - Verify ONNX model is valid - - Check input shape configuration - - Reduce workspace size if memory issues +## Documentation Map -3. **Verification Fails** - - Check tolerance settings - - Verify same preprocessing for all backends - - Check device compatibility +| 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_workflow.md`](docs/export_workflow.md) | ONNX/TRT export steps and workflow 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. | -4. **Evaluation Errors** - - Verify data loader paths - - Check model output format - - Ensure correct task type in config +Refer to `deployment/docs/README.md` for the same index. ---- +## Architecture Snapshot -## Future Enhancements +- **Entry points** (`projects/*/deploy/main.py`) instantiate project runners with data loaders, evaluators, wrappers, and optional workflows. +- **Runners** coordinate load → export → verify → evaluate while delegating to shared Artifact/Verification/Evaluation orchestrators. +- **Exporters** live under `exporters/common/` with typed config classes; project wrappers/workflows compose the base exporters as needed. +- **Pipelines** (`pipelines/common/*`, `pipelines/{task}/`) provide consistent preprocessing/postprocessing with backend-specific inference implementations resolved via `PipelineFactory`. +- **Core package** (`core/`) supplies typed configs, runtime contexts, task definitions, and shared verification utilities. -- [ ] Support for more task types (segmentation, etc.) -- [ ] Automatic precision tuning for TensorRT -- [ ] Distributed evaluation support -- [ ] Integration with MLOps pipelines -- [ ] Performance profiling tools +See [`docs/architecture.md`](docs/architecture.md) for diagrams and component details. ---- +## Export & Verification Flow -## Contributing +1. Load the PyTorch checkpoint and run ONNX export (single or multi-file) using the injected wrappers/workflows. +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. -When adding a new project: +Implementation details live in [`docs/export_workflow.md`](docs/export_workflow.md) and [`docs/verification_evaluation.md`](docs/verification_evaluation.md). -1. **Create project-specific evaluator and data loader** - - Implement `BaseEvaluator` for task-specific metrics - - Implement `BaseDataLoader` for data loading +## Project Coverage -2. **Create exporters following unified architecture pattern** - - Add `exporters/{model}/model_wrappers.py` (reuse `IdentityWrapper` or implement custom wrapper) - - Introduce `exporters/{model}/onnx_workflow.py` / `tensorrt_workflow.py` only if you need multi-stage orchestration; otherwise rely on the base exporters created by `ExporterFactory` - - Prefer composition over inheritance—extend the workflows, not the base exporters, unless a new backend capability is required +- **CenterPoint** – multi-file export orchestrated by dedicated ONNX/TRT workflows; 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. -3. **Implement task-specific pipeline** (if needed) - - Inherit from appropriate base pipeline (`Detection2DPipeline`, `Detection3DPipeline`, `ClassificationPipeline`) - - Implement backend-specific variants (PyTorch, ONNX, TensorRT) +Each project ships its own `deploy_config.py`, evaluator, and data loader under `projects/{Project}/deploy/`. -4. **Create deployment configuration** - - Add `projects/{project}/deploy/configs/deploy_config.py` - - Configure export, verification, and evaluation settings +## Core Contract -5. **Add entry point script** - - Create `projects/{project}/deploy/main.py` - - Follow dependency injection pattern: explicitly create exporters and wrappers - - Pass exporters to the appropriate project runner (inherits `BaseDeploymentRunner`) +[`core_contract.md`](docs/core_contract.md) defines the boundaries between runners, orchestrators, evaluators, pipelines, and metrics adapters. Follow the contract when introducing new logic to keep refactors safe and dependencies explicit. -6. **Update documentation** - - Add project to README's "Project-Specific Implementations" section - - Document any special requirements or configurations +## 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 file in project root. +See LICENSE at the repository root. diff --git a/deployment/__init__.py b/deployment/__init__.py index 99a205797..32cc291ee 100644 --- a/deployment/__init__.py +++ b/deployment/__init__.py @@ -7,10 +7,10 @@ TensorRT). """ -from deployment.core.base_config import BaseDeploymentConfig -from deployment.core.base_data_loader import BaseDataLoader -from deployment.core.base_evaluator import BaseEvaluator -from deployment.core.preprocessing_builder import build_preprocessing_pipeline +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.runners import BaseDeploymentRunner __all__ = [ diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py index 87fd86cba..3346a6d08 100644 --- a/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -2,7 +2,7 @@ from deployment.core.artifacts import Artifact from deployment.core.backend import Backend -from deployment.core.base_config import ( +from deployment.core.config.base_config import ( BackendConfig, BaseDeploymentConfig, EvaluationConfig, @@ -14,17 +14,73 @@ parse_base_args, setup_logging, ) -from deployment.core.base_data_loader import BaseDataLoader -from deployment.core.base_evaluator import ( +from deployment.core.config.constants import ( + EVALUATION_DEFAULTS, + EXPORT_DEFAULTS, + TASK_DEFAULTS, + EvaluationDefaults, + ExportDefaults, + TaskDefaults, +) +from deployment.core.config.runtime_config import ( + BaseRuntimeConfig, + ClassificationRuntimeConfig, + Detection2DRuntimeConfig, + Detection3DRuntimeConfig, +) +from deployment.core.config.task_config import TaskConfig, TaskType +from deployment.core.contexts import ( + CalibrationExportContext, + CenterPointExportContext, + ExportContext, + ExportContextType, + PreprocessContext, + YOLOXExportContext, + create_export_context, +) +from deployment.core.evaluation.base_evaluator import ( BaseEvaluator, EvalResultDict, ModelSpec, + TaskProfile, VerifyResultDict, ) -from deployment.core.preprocessing_builder import build_preprocessing_pipeline +from deployment.core.evaluation.results import ( + ClassificationEvaluationMetrics, + ClassificationResult, + Detection2DEvaluationMetrics, + Detection2DResult, + Detection3DEvaluationMetrics, + Detection3DResult, + EvaluationMetrics, + LatencyStats, + StageLatencyBreakdown, +) +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 ( + BaseMetricsAdapter, + BaseMetricsConfig, + ClassificationMetricsAdapter, + ClassificationMetricsConfig, + Detection2DMetricsAdapter, + Detection2DMetricsConfig, + Detection3DMetricsAdapter, + Detection3DMetricsConfig, +) __all__ = [ + # Backend and configuration "Backend", + # Typed contexts + "ExportContext", + "ExportContextType", + "YOLOXExportContext", + "CenterPointExportContext", + "CalibrationExportContext", + "PreprocessContext", + "create_export_context", "BaseDeploymentConfig", "ExportConfig", "ExportMode", @@ -35,11 +91,51 @@ "VerificationScenario", "setup_logging", "parse_base_args", + # Task configuration + "TaskConfig", + "TaskType", + # Runtime configurations (typed) + "BaseRuntimeConfig", + "Detection3DRuntimeConfig", + "Detection2DRuntimeConfig", + "ClassificationRuntimeConfig", + # Constants + "EVALUATION_DEFAULTS", + "EXPORT_DEFAULTS", + "TASK_DEFAULTS", + "EvaluationDefaults", + "ExportDefaults", + "TaskDefaults", + # Data loading "BaseDataLoader", + # Evaluation "BaseEvaluator", + "TaskProfile", "EvalResultDict", "VerifyResultDict", + "VerificationMixin", + # Artifacts "Artifact", "ModelSpec", + # Results (typed) + "Detection3DResult", + "Detection2DResult", + "ClassificationResult", + "LatencyStats", + "StageLatencyBreakdown", + "EvaluationMetrics", + "Detection3DEvaluationMetrics", + "Detection2DEvaluationMetrics", + "ClassificationEvaluationMetrics", + # Preprocessing "build_preprocessing_pipeline", + # Metrics adapters (using autoware_perception_evaluation) + "BaseMetricsAdapter", + "BaseMetricsConfig", + "Detection3DMetricsAdapter", + "Detection3DMetricsConfig", + "Detection2DMetricsAdapter", + "Detection2DMetricsConfig", + "ClassificationMetricsAdapter", + "ClassificationMetricsConfig", ] diff --git a/deployment/core/base_evaluator.py b/deployment/core/base_evaluator.py deleted file mode 100644 index 29da28067..000000000 --- a/deployment/core/base_evaluator.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Abstract base class for model evaluation in deployment. - -Each task (classification, detection, segmentation, etc.) must implement -a concrete Evaluator that extends this base class to compute task-specific metrics. -""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any, Dict, TypedDict - -import numpy as np - -from deployment.core.artifacts import Artifact -from deployment.core.backend import Backend -from deployment.core.base_data_loader import BaseDataLoader - - -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 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 - - -class BaseEvaluator(ABC): - """ - Abstract base class for task-specific evaluators. - - This class defines the interface that all task-specific evaluators - must implement. It handles running inference on a dataset and computing - evaluation metrics appropriate for the task. - """ - - def __init__(self, config: Dict[str, Any]): - """ - Initialize evaluator. - - Args: - config: Configuration dictionary containing evaluation settings - """ - self.config = config - - @abstractmethod - def evaluate( - self, - model: ModelSpec, - data_loader: BaseDataLoader, - num_samples: int, - verbose: bool = False, - ) -> EvalResultDict: - """ - Run full evaluation on a model. - - Args: - model: Specification of the artifact/backend/device triplet to evaluate - data_loader: DataLoader for loading samples - num_samples: Number of samples to evaluate - verbose: Whether to print detailed progress - - Returns: - Dictionary containing evaluation metrics. The exact metrics - depend on the task, but should include: - - Primary metric(s) for the task - - Per-class metrics (if applicable) - - Inference latency statistics - - Any other relevant metrics - - Example: - For classification: - { - "accuracy": 0.95, - "precision": 0.94, - "recall": 0.96, - "per_class_accuracy": {...}, - "confusion_matrix": [...], - "avg_latency_ms": 5.2, - } - - For detection: - { - "mAP": 0.72, - "mAP_50": 0.85, - "mAP_75": 0.68, - "per_class_ap": {...}, - "avg_latency_ms": 15.3, - } - """ - raise NotImplementedError - - @abstractmethod - def print_results(self, results: EvalResultDict) -> None: - """ - Pretty print evaluation results. - - Args: - results: Results dictionary returned by evaluate() - """ - raise NotImplementedError - - @abstractmethod - 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 scenario-based verification. - - This method compares outputs from a reference backend against a test backend - as specified by the verification scenarios. This is a more flexible approach - than the legacy verify() method which compares all available backends. - - Args: - reference: Specification of backend/device/path for the reference model - test: Specification for the backend/device/path under test - data_loader: Data loader for test samples - num_samples: Number of samples to verify - tolerance: Maximum allowed difference for verification to pass - verbose: Whether to print detailed output - - Returns: - Verification results with pass/fail summary and per-sample outcomes. - """ - raise NotImplementedError - - def compute_latency_stats(self, latencies: list) -> Dict[str, float]: - """ - Compute latency statistics from a list of latency measurements. - - Args: - latencies: List of latency values in milliseconds - - Returns: - Dictionary with latency statistics - """ - if not latencies: - return { - "mean_ms": 0.0, - "std_ms": 0.0, - "min_ms": 0.0, - "max_ms": 0.0, - "median_ms": 0.0, - } - - latencies_array = np.array(latencies) - - return { - "mean_ms": float(np.mean(latencies_array)), - "std_ms": float(np.std(latencies_array)), - "min_ms": float(np.min(latencies_array)), - "max_ms": float(np.max(latencies_array)), - "median_ms": float(np.median(latencies_array)), - } - - def format_latency_stats(self, stats: Dict[str, float]) -> str: - """ - Format latency statistics as a readable string. - - Args: - stats: Latency statistics dictionary - - Returns: - Formatted string - """ - return ( - f"Latency: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms " - f"(min: {stats['min_ms']:.2f}, max: {stats['max_ms']:.2f}, " - f"median: {stats['median_ms']:.2f})" - ) diff --git a/deployment/core/config/__init__.py b/deployment/core/config/__init__.py new file mode 100644 index 000000000..34a533184 --- /dev/null +++ b/deployment/core/config/__init__.py @@ -0,0 +1,54 @@ +"""Configuration subpackage for deployment core.""" + +from deployment.core.config.base_config import ( + BackendConfig, + BaseDeploymentConfig, + EvaluationConfig, + ExportConfig, + ExportMode, + PrecisionPolicy, + VerificationConfig, + VerificationScenario, + parse_base_args, + setup_logging, +) +from deployment.core.config.constants import ( + EVALUATION_DEFAULTS, + EXPORT_DEFAULTS, + TASK_DEFAULTS, + EvaluationDefaults, + ExportDefaults, + TaskDefaults, +) +from deployment.core.config.runtime_config import ( + BaseRuntimeConfig, + ClassificationRuntimeConfig, + Detection2DRuntimeConfig, + Detection3DRuntimeConfig, +) +from deployment.core.config.task_config import TaskConfig, TaskType + +__all__ = [ + "BackendConfig", + "BaseDeploymentConfig", + "EvaluationConfig", + "ExportConfig", + "ExportMode", + "PrecisionPolicy", + "VerificationConfig", + "VerificationScenario", + "parse_base_args", + "setup_logging", + "EVALUATION_DEFAULTS", + "EXPORT_DEFAULTS", + "TASK_DEFAULTS", + "EvaluationDefaults", + "ExportDefaults", + "TaskDefaults", + "BaseRuntimeConfig", + "ClassificationRuntimeConfig", + "Detection2DRuntimeConfig", + "Detection3DRuntimeConfig", + "TaskConfig", + "TaskType", +] diff --git a/deployment/core/base_config.py b/deployment/core/config/base_config.py similarity index 95% rename from deployment/core/base_config.py rename to deployment/core/config/base_config.py index f1eb3ed63..a5086855d 100644 --- a/deployment/core/base_config.py +++ b/deployment/core/config/base_config.py @@ -16,7 +16,7 @@ from mmengine.config import Config from deployment.core.backend import Backend -from deployment.exporters.base.configs import ( +from deployment.exporters.common.configs import ( ONNXExportConfig, TensorRTExportConfig, TensorRTModelInputConfig, @@ -78,7 +78,6 @@ class ExportConfig: mode: ExportMode = ExportMode.BOTH work_dir: str = "work_dirs" - checkpoint_path: Optional[str] = None onnx_path: Optional[str] = None tensorrt_path: Optional[str] = None cuda_device: str = "cuda:0" @@ -92,7 +91,6 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": return cls( mode=ExportMode.from_value(config_dict.get("mode", ExportMode.BOTH)), work_dir=config_dict.get("work_dir", cls.work_dir), - checkpoint_path=config_dict.get("checkpoint_path"), onnx_path=config_dict.get("onnx_path"), tensorrt_path=config_dict.get("tensorrt_path"), cuda_device=config_dict.get("cuda_device", cls.cuda_device), @@ -285,6 +283,11 @@ class BaseDeploymentConfig: 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): @@ -297,6 +300,8 @@ def __init__(self, deploy_cfg: Config): self.deploy_cfg = deploy_cfg self._validate_config() + self._checkpoint_path: Optional[str] = deploy_cfg.get("checkpoint_path") + # Initialize config sections self.export_config = ExportConfig.from_dict(deploy_cfg.get("export", {})) self.runtime_config = RuntimeConfig.from_dict(deploy_cfg.get("runtime_io", {})) @@ -369,6 +374,21 @@ def _needs_cuda_device(self) -> bool: 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 workflow: 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.""" diff --git a/deployment/core/config/constants.py b/deployment/core/config/constants.py new file mode 100644 index 000000000..09e9e936d --- /dev/null +++ b/deployment/core/config/constants.py @@ -0,0 +1,51 @@ +""" +Centralized constants for the deployment framework. + +This module consolidates magic numbers and constants that were scattered +across multiple files into a single, configurable location. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class EvaluationDefaults: + """Default values for evaluation settings.""" + + LOG_INTERVAL: int = 50 + GPU_CLEANUP_INTERVAL: int = 10 + VERIFICATION_TOLERANCE: float = 0.1 + DEFAULT_NUM_SAMPLES: int = 10 + DEFAULT_NUM_VERIFY_SAMPLES: int = 3 + + +@dataclass(frozen=True) +class ExportDefaults: + """Default values for export settings.""" + + ONNX_DIR_NAME: str = "onnx" + TENSORRT_DIR_NAME: str = "tensorrt" + DEFAULT_ENGINE_FILENAME: str = "model.engine" + DEFAULT_ONNX_FILENAME: str = "model.onnx" + DEFAULT_OPSET_VERSION: int = 16 + DEFAULT_WORKSPACE_SIZE: int = 1 << 30 # 1 GB + + +@dataclass(frozen=True) +class TaskDefaults: + """Default values for task-specific settings.""" + + # Default class names for T4Dataset + DETECTION_3D_CLASSES: tuple = ("car", "truck", "bus", "bicycle", "pedestrian") + DETECTION_2D_CLASSES: tuple = ("unknown", "car", "truck", "bus", "trailer", "motorcycle", "pedestrian", "bicycle") + CLASSIFICATION_CLASSES: tuple = ("miscalibrated", "calibrated") + + # Default input sizes + DEFAULT_2D_INPUT_SIZE: tuple = (960, 960) + DEFAULT_CLASSIFICATION_INPUT_SIZE: tuple = (224, 224) + + +# Singleton instances for easy import +EVALUATION_DEFAULTS = EvaluationDefaults() +EXPORT_DEFAULTS = ExportDefaults() +TASK_DEFAULTS = TaskDefaults() diff --git a/deployment/core/config/runtime_config.py b/deployment/core/config/runtime_config.py new file mode 100644 index 000000000..baa627d1a --- /dev/null +++ b/deployment/core/config/runtime_config.py @@ -0,0 +1,63 @@ +""" +Typed runtime configurations for different task types. + +This module provides task-specific typed configurations for runtime_io, +replacing the weakly-typed Dict[str, Any] access pattern. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class BaseRuntimeConfig: + """Base configuration for all runtime settings.""" + + sample_idx: int = 0 + + +@dataclass(frozen=True) +class Detection3DRuntimeConfig(BaseRuntimeConfig): + """Runtime configuration for 3D detection tasks (e.g., CenterPoint).""" + + info_file: str = "" + + @classmethod + def from_dict(cls, config_dict: dict) -> "Detection3DRuntimeConfig": + """Create config from dictionary.""" + return cls( + sample_idx=config_dict.get("sample_idx", 0), + info_file=config_dict.get("info_file", ""), + ) + + +@dataclass(frozen=True) +class Detection2DRuntimeConfig(BaseRuntimeConfig): + """Runtime configuration for 2D detection tasks (e.g., YOLOX).""" + + ann_file: str = "" + img_prefix: str = "" + + @classmethod + def from_dict(cls, config_dict: dict) -> "Detection2DRuntimeConfig": + """Create config from dictionary.""" + return cls( + sample_idx=config_dict.get("sample_idx", 0), + ann_file=config_dict.get("ann_file", ""), + img_prefix=config_dict.get("img_prefix", ""), + ) + + +@dataclass(frozen=True) +class ClassificationRuntimeConfig(BaseRuntimeConfig): + """Runtime configuration for classification tasks.""" + + info_pkl: str = "" + + @classmethod + def from_dict(cls, config_dict: dict) -> "ClassificationRuntimeConfig": + """Create config from dictionary.""" + return cls( + sample_idx=config_dict.get("sample_idx", 0), + info_pkl=config_dict.get("info_pkl", ""), + ) diff --git a/deployment/core/config/task_config.py b/deployment/core/config/task_config.py new file mode 100644 index 000000000..012228364 --- /dev/null +++ b/deployment/core/config/task_config.py @@ -0,0 +1,105 @@ +""" +Task configuration for unified pipeline configuration. + +This module provides task-specific configuration that can be passed to pipelines, +enabling a more unified approach while still supporting task-specific parameters. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional, Tuple + + +class TaskType(str, Enum): + """Supported task types.""" + + CLASSIFICATION = "classification" + DETECTION_2D = "detection2d" + DETECTION_3D = "detection3d" + + @classmethod + def from_value(cls, value: str) -> "TaskType": + """Parse string to TaskType.""" + normalized = value.strip().lower() + for member in cls: + if member.value == normalized: + return member + raise ValueError(f"Invalid task type '{value}'. Must be one of {[m.value for m in cls]}.") + + +@dataclass(frozen=True) +class TaskConfig: + """ + Task-specific configuration for deployment pipelines (immutable). + + This configuration encapsulates all task-specific parameters needed + by pipelines, enabling a more unified approach while still supporting + task-specific requirements. + """ + + task_type: TaskType + num_classes: int + class_names: Tuple[str, ...] + + # 2D Detection specific + input_size: Optional[Tuple[int, int]] = None + + # 3D Detection specific + point_cloud_range: Optional[Tuple[float, ...]] = None + voxel_size: Optional[Tuple[float, ...]] = None + + # Optional additional parameters + score_threshold: float = 0.01 + nms_threshold: float = 0.65 + max_detections: int = 300 + + @classmethod + def for_classification( + cls, + num_classes: int, + class_names: List[str], + ) -> "TaskConfig": + """Create configuration for classification tasks.""" + return cls( + task_type=TaskType.CLASSIFICATION, + num_classes=num_classes, + class_names=tuple(class_names), + ) + + @classmethod + def for_detection_2d( + cls, + num_classes: int, + class_names: List[str], + input_size: Tuple[int, int] = (960, 960), + score_threshold: float = 0.01, + nms_threshold: float = 0.65, + max_detections: int = 300, + ) -> "TaskConfig": + """Create configuration for 2D detection tasks.""" + return cls( + task_type=TaskType.DETECTION_2D, + num_classes=num_classes, + class_names=tuple(class_names), + input_size=input_size, + score_threshold=score_threshold, + nms_threshold=nms_threshold, + max_detections=max_detections, + ) + + @classmethod + def for_detection_3d( + cls, + num_classes: int, + class_names: List[str], + point_cloud_range: Optional[List[float]] = None, + voxel_size: Optional[List[float]] = None, + ) -> "TaskConfig": + """Create configuration for 3D detection tasks.""" + return cls( + task_type=TaskType.DETECTION_3D, + num_classes=num_classes, + class_names=tuple(class_names), + point_cloud_range=tuple(point_cloud_range) if point_cloud_range else None, + voxel_size=tuple(voxel_size) if voxel_size else None, + ) diff --git a/deployment/core/contexts.py b/deployment/core/contexts.py new file mode 100644 index 000000000..414c021a9 --- /dev/null +++ b/deployment/core/contexts.py @@ -0,0 +1,164 @@ +""" +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 typing import Any, Dict, 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: Dict[str, Any] = field(default_factory=dict) + + 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_path: 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 + + +# Type alias for context types +ExportContextType = ExportContext | YOLOXExportContext | CenterPointExportContext | CalibrationExportContext + + +@dataclass(frozen=True) +class PreprocessContext: + """ + Context for preprocessing operations. + + This context carries metadata and parameters needed during preprocessing + in deployment pipelines. + + Attributes: + img_info: Image metadata dictionary (height, width, scale_factor, etc.) + Required for 2D detection pipelines. + extra: Dictionary for additional preprocessing parameters. + """ + + img_info: Optional[Dict[str, Any]] = None + extra: Dict[str, Any] = field(default_factory=dict) + + def get(self, key: str, default: Any = None) -> Any: + """Get a value from extra dict with a default.""" + return self.extra.get(key, default) + + +# Factory functions for convenience +def create_export_context( + project_type: str = "base", + sample_idx: int = 0, + **kwargs: Any, +) -> ExportContext: + """ + Factory function to create the appropriate export context. + + Args: + project_type: One of "base", "yolox", "centerpoint", "calibration" + sample_idx: Sample index for tracing + **kwargs: Project-specific parameters + + Returns: + Appropriate ExportContext subclass instance + + Example: + ctx = create_export_context("yolox", model_cfg_path="/path/to/config.py") + ctx = create_export_context("centerpoint", rot_y_axis_reference=True) + """ + project_type = project_type.lower() + + if project_type == "yolox": + return YOLOXExportContext( + sample_idx=sample_idx, + model_cfg_path=kwargs.pop("model_cfg_path", None), + extra=kwargs, + ) + elif project_type == "centerpoint": + return CenterPointExportContext( + sample_idx=sample_idx, + rot_y_axis_reference=kwargs.pop("rot_y_axis_reference", False), + extra=kwargs, + ) + elif project_type == "calibration": + return CalibrationExportContext( + sample_idx=sample_idx, + extra=kwargs, + ) + else: + return ExportContext( + sample_idx=sample_idx, + extra=kwargs, + ) diff --git a/deployment/core/evaluation/__init__.py b/deployment/core/evaluation/__init__.py new file mode 100644 index 000000000..5238783c9 --- /dev/null +++ b/deployment/core/evaluation/__init__.py @@ -0,0 +1,38 @@ +"""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.results import ( + ClassificationEvaluationMetrics, + ClassificationResult, + Detection2DEvaluationMetrics, + Detection2DResult, + Detection3DEvaluationMetrics, + Detection3DResult, + EvaluationMetrics, + LatencyStats, + StageLatencyBreakdown, +) +from deployment.core.evaluation.verification_mixin import VerificationMixin + +__all__ = [ + "BaseEvaluator", + "TaskProfile", + "EvalResultDict", + "ModelSpec", + "VerifyResultDict", + "ClassificationResult", + "Detection2DResult", + "Detection3DResult", + "ClassificationEvaluationMetrics", + "Detection2DEvaluationMetrics", + "Detection3DEvaluationMetrics", + "EvaluationMetrics", + "LatencyStats", + "StageLatencyBreakdown", + "VerificationMixin", +] diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py new file mode 100644 index 000000000..3f4ef64ea --- /dev/null +++ b/deployment/core/evaluation/base_evaluator.py @@ -0,0 +1,297 @@ +""" +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. +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import torch + +from deployment.core.backend import Backend +from deployment.core.config.constants import EVALUATION_DEFAULTS +from deployment.core.evaluation.evaluator_types import EvalResultDict, ModelSpec, VerifyResultDict +from deployment.core.evaluation.verification_mixin import VerificationMixin +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.core.metrics import BaseMetricsAdapter + +# Re-export types +__all__ = [ + "EvalResultDict", + "VerifyResultDict", + "ModelSpec", + "TaskProfile", + "BaseEvaluator", +] + +logger = logging.getLogger(__name__) + + +@dataclass +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: + 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_adapter(): Feed a single frame to the metrics adapter + - _build_results(): Construct final results dict from adapter metrics + - print_results(): Format and display results + """ + + def __init__( + self, + metrics_adapter: BaseMetricsAdapter, + task_profile: TaskProfile, + model_cfg: Any, + ): + """ + Initialize evaluator. + + Args: + metrics_adapter: Metrics adapter for computing task-specific metrics + task_profile: Profile describing the task + model_cfg: Model configuration (MMEngine Config or similar) + """ + self.metrics_adapter = metrics_adapter + 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: Dict[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: Dict[str, Any]) -> Any: + """Extract ground truth from sample data.""" + raise NotImplementedError + + @abstractmethod + def _add_to_adapter(self, predictions: Any, ground_truths: Any) -> None: + """Add a single frame to the metrics adapter.""" + 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 adapter 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_adapter.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) + + raw_output, latency, breakdown = pipeline.infer(input_data, **infer_kwargs) + latencies.append(latency) + if breakdown: + latency_breakdowns.append(breakdown) + + predictions = self._parse_predictions(raw_output) + self._add_to_adapter(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]) -> Dict[str, float]: + """Compute latency statistics from a list of measurements.""" + if not latencies: + return {"mean_ms": 0.0, "std_ms": 0.0, "min_ms": 0.0, "max_ms": 0.0, "median_ms": 0.0} + + arr = np.array(latencies) + return { + "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]], + ) -> Dict[str, Dict[str, float]]: + """Compute statistics for each latency stage.""" + if not latency_breakdowns: + return {} + + all_stages = set() + for breakdown in latency_breakdowns: + all_stages.update(breakdown.keys()) + + return { + stage: self.compute_latency_stats([bd.get(stage, 0.0) for bd in latency_breakdowns if stage in bd]) + for stage in sorted(all_stages) + } + + def format_latency_stats(self, stats: Dict[str, float]) -> str: + """Format latency statistics as a readable string.""" + return ( + f"Latency: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms " + f"(min: {stats['min_ms']:.2f}, max: {stats['max_ms']:.2f}, " + f"median: {stats['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..b889f00cd --- /dev/null +++ b/deployment/core/evaluation/evaluator_types.py @@ -0,0 +1,71 @@ +""" +Type definitions for model evaluation in deployment. + +This module contains the shared type definitions used by evaluators, +runners, and orchestrators. +""" + +from dataclasses import dataclass +from typing import Any, Dict, 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 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/results.py b/deployment/core/evaluation/results.py new file mode 100644 index 000000000..eceac2d03 --- /dev/null +++ b/deployment/core/evaluation/results.py @@ -0,0 +1,273 @@ +""" +Typed result classes for deployment framework. + +This module provides strongly-typed result classes instead of Dict[str, Any], +enabling better IDE support and catching errors at development time. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + + +@dataclass(frozen=True) +class Detection3DResult: + """Result for a single 3D detection (immutable).""" + + bbox_3d: Tuple[float, ...] # [x, y, z, l, w, h, yaw] or with velocity [x, y, z, l, w, h, yaw, vx, vy] + score: float + label: int + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary format.""" + return { + "bbox_3d": list(self.bbox_3d), + "score": self.score, + "label": self.label, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Detection3DResult": + """Create from dictionary.""" + return cls( + bbox_3d=tuple(data["bbox_3d"]), + score=data["score"], + label=data["label"], + ) + + +@dataclass(frozen=True) +class Detection2DResult: + """Result for a single 2D detection (immutable).""" + + bbox: Tuple[float, ...] # [x1, y1, x2, y2] + score: float + class_id: int + class_name: str = "" + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary format.""" + return { + "bbox": list(self.bbox), + "score": self.score, + "class_id": self.class_id, + "class_name": self.class_name, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Detection2DResult": + """Create from dictionary.""" + return cls( + bbox=tuple(data["bbox"]), + score=data["score"], + class_id=data.get("class_id", data.get("label", 0)), + class_name=data.get("class_name", ""), + ) + + +@dataclass +class ClassificationResult: + """Result for a classification prediction.""" + + class_id: int + class_name: str + confidence: float + probabilities: np.ndarray + top_k: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary format.""" + return { + "class_id": self.class_id, + "class_name": self.class_name, + "confidence": self.confidence, + "probabilities": self.probabilities, + "top_k": self.top_k, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ClassificationResult": + """Create from dictionary.""" + return cls( + class_id=data["class_id"], + class_name=data["class_name"], + confidence=data["confidence"], + probabilities=data["probabilities"], + top_k=data.get("top_k", []), + ) + + +@dataclass(frozen=True) +class LatencyStats: + """Latency statistics (immutable).""" + + mean_ms: float + std_ms: float + min_ms: float + max_ms: float + median_ms: float + + def to_dict(self) -> Dict[str, float]: + """Convert to dictionary format.""" + return { + "mean_ms": self.mean_ms, + "std_ms": self.std_ms, + "min_ms": self.min_ms, + "max_ms": self.max_ms, + "median_ms": self.median_ms, + } + + @classmethod + def from_latencies(cls, latencies: List[float]) -> "LatencyStats": + """Compute statistics from a list of latency values.""" + if not latencies: + return cls(0.0, 0.0, 0.0, 0.0, 0.0) + + arr = np.array(latencies) + return cls( + 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)), + ) + + +@dataclass(frozen=True) +class StageLatencyBreakdown: + """Latency breakdown by inference stage (immutable).""" + + preprocessing_ms: Optional[LatencyStats] = None + voxel_encoder_ms: Optional[LatencyStats] = None + middle_encoder_ms: Optional[LatencyStats] = None + backbone_head_ms: Optional[LatencyStats] = None + postprocessing_ms: Optional[LatencyStats] = None + model_ms: Optional[LatencyStats] = None + + def to_dict(self) -> Dict[str, Dict[str, float]]: + """Convert to dictionary format.""" + result = {} + if self.preprocessing_ms: + result["preprocessing_ms"] = self.preprocessing_ms.to_dict() + if self.voxel_encoder_ms: + result["voxel_encoder_ms"] = self.voxel_encoder_ms.to_dict() + if self.middle_encoder_ms: + result["middle_encoder_ms"] = self.middle_encoder_ms.to_dict() + if self.backbone_head_ms: + result["backbone_head_ms"] = self.backbone_head_ms.to_dict() + if self.postprocessing_ms: + result["postprocessing_ms"] = self.postprocessing_ms.to_dict() + if self.model_ms: + result["model_ms"] = self.model_ms.to_dict() + return result + + +@dataclass +class EvaluationMetrics: + """Base class for evaluation metrics.""" + + num_samples: int + latency: LatencyStats + latency_breakdown: Optional[StageLatencyBreakdown] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary format.""" + result = { + "num_samples": self.num_samples, + "latency": self.latency.to_dict(), + } + if self.latency_breakdown: + result["latency_breakdown"] = self.latency_breakdown.to_dict() + return result + + +@dataclass +class Detection3DEvaluationMetrics(EvaluationMetrics): + """Evaluation metrics for 3D detection.""" + + mAP: float = 0.0 + mAPH: float = 0.0 + per_class_ap: Dict[str, float] = field(default_factory=dict) + total_predictions: int = 0 + total_ground_truths: int = 0 + per_class_predictions: Dict[int, int] = field(default_factory=dict) + per_class_ground_truths: Dict[int, int] = field(default_factory=dict) + detailed_metrics: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary format.""" + result = super().to_dict() + result.update( + { + "mAP": self.mAP, + "mAPH": self.mAPH, + "per_class_ap": self.per_class_ap, + "total_predictions": self.total_predictions, + "total_ground_truths": self.total_ground_truths, + "per_class_predictions": self.per_class_predictions, + "per_class_ground_truths": self.per_class_ground_truths, + "detailed_metrics": self.detailed_metrics, + } + ) + return result + + +@dataclass +class Detection2DEvaluationMetrics(EvaluationMetrics): + """Evaluation metrics for 2D detection.""" + + mAP: float = 0.0 + mAP_50: float = 0.0 + mAP_75: float = 0.0 + per_class_ap: Dict[str, float] = field(default_factory=dict) + detailed_metrics: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary format.""" + result = super().to_dict() + result.update( + { + "mAP": self.mAP, + "mAP_50": self.mAP_50, + "mAP_75": self.mAP_75, + "per_class_ap": self.per_class_ap, + "detailed_metrics": self.detailed_metrics, + } + ) + return result + + +@dataclass +class ClassificationEvaluationMetrics(EvaluationMetrics): + """Evaluation metrics for classification.""" + + accuracy: float = 0.0 + precision: float = 0.0 + recall: float = 0.0 + f1score: float = 0.0 + correct_predictions: int = 0 + total_samples: int = 0 + per_class_accuracy: Dict[str, float] = field(default_factory=dict) + per_class_count: Dict[int, int] = field(default_factory=dict) + confusion_matrix: List[List[int]] = field(default_factory=list) + detailed_metrics: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary format.""" + result = super().to_dict() + result.update( + { + "accuracy": self.accuracy, + "precision": self.precision, + "recall": self.recall, + "f1score": self.f1score, + "correct_predictions": self.correct_predictions, + "total_samples": self.total_samples, + "per_class_accuracy": self.per_class_accuracy, + "per_class_count": self.per_class_count, + "confusion_matrix": self.confusion_matrix, + "detailed_metrics": self.detailed_metrics, + } + ) + return result diff --git a/deployment/core/evaluation/verification_mixin.py b/deployment/core/evaluation/verification_mixin.py new file mode 100644 index 000000000..05afb7533 --- /dev/null +++ b/deployment/core/evaluation/verification_mixin.py @@ -0,0 +1,481 @@ +""" +Verification mixin providing shared verification logic for evaluators. + +This mixin extracts the common verification workflow that was duplicated +across CenterPointEvaluator, YOLOXOptElanEvaluator, and ClassificationEvaluator. +""" + +import logging +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, List, 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: Dict[str, Any], + test: Dict[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_output, ref_latency, _ = ref_pipeline.infer(input_data, metadata, return_raw_outputs=True) + logger.info(f" {ref_name} latency: {ref_latency:.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_output, test_latency, _ = test_pipeline.infer(test_input, metadata, return_raw_outputs=True) + logger.info(f" {test_name} latency: {test_latency:.2f} ms") + + passed, _ = self._compare_backend_outputs(ref_output, test_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/base_data_loader.py b/deployment/core/io/base_data_loader.py similarity index 65% rename from deployment/core/base_data_loader.py rename to deployment/core/io/base_data_loader.py index 5e8adc910..9dcdbe9dc 100644 --- a/deployment/core/base_data_loader.py +++ b/deployment/core/io/base_data_loader.py @@ -91,3 +91,36 @@ def get_num_samples(self) -> int: Total number of samples available """ 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/preprocessing_builder.py b/deployment/core/io/preprocessing_builder.py similarity index 100% rename from deployment/core/preprocessing_builder.py rename to deployment/core/io/preprocessing_builder.py diff --git a/deployment/core/metrics/__init__.py b/deployment/core/metrics/__init__.py new file mode 100644 index 000000000..baf178b67 --- /dev/null +++ b/deployment/core/metrics/__init__.py @@ -0,0 +1,75 @@ +""" +Unified Metrics Adapters for AWML Deployment Framework. + +This module provides task-specific metric adapters 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 → Detection3DMetricsAdapter (mAP, mAPH using autoware_perception_eval) + 2. 2D Detection → Detection2DMetricsAdapter (mAP using autoware_perception_eval, 2D mode) + 3. Classification → ClassificationMetricsAdapter (accuracy, precision, recall, F1) + +Usage: + # For 3D detection (CenterPoint, etc.) + from deployment.core.metrics import Detection3DMetricsAdapter, Detection3DMetricsConfig + + config = Detection3DMetricsConfig( + class_names=["car", "truck", "bus", "bicycle", "pedestrian"], + ) + adapter = Detection3DMetricsAdapter(config) + adapter.add_frame(predictions, ground_truths) + metrics = adapter.compute_metrics() + + # For 2D detection (YOLOX, etc.) + from deployment.core.metrics import Detection2DMetricsAdapter, Detection2DMetricsConfig + + config = Detection2DMetricsConfig( + class_names=["car", "truck", "bus", ...], + ) + adapter = Detection2DMetricsAdapter(config) + adapter.add_frame(predictions, ground_truths) + metrics = adapter.compute_metrics() + + # For classification (Calibration, etc.) + from deployment.core.metrics import ClassificationMetricsAdapter, ClassificationMetricsConfig + + config = ClassificationMetricsConfig( + class_names=["miscalibrated", "calibrated"], + ) + adapter = ClassificationMetricsAdapter(config) + adapter.add_frame(prediction_label, ground_truth_label, probabilities) + metrics = adapter.compute_metrics() +""" + +from deployment.core.metrics.base_metrics_adapter import ( + BaseMetricsAdapter, + BaseMetricsConfig, +) +from deployment.core.metrics.classification_metrics import ( + ClassificationMetricsAdapter, + ClassificationMetricsConfig, +) +from deployment.core.metrics.detection_2d_metrics import ( + Detection2DMetricsAdapter, + Detection2DMetricsConfig, +) +from deployment.core.metrics.detection_3d_metrics import ( + Detection3DMetricsAdapter, + Detection3DMetricsConfig, +) + +__all__ = [ + # Base classes + "BaseMetricsAdapter", + "BaseMetricsConfig", + # 3D Detection + "Detection3DMetricsAdapter", + "Detection3DMetricsConfig", + # 2D Detection + "Detection2DMetricsAdapter", + "Detection2DMetricsConfig", + # Classification + "ClassificationMetricsAdapter", + "ClassificationMetricsConfig", +] diff --git a/deployment/core/metrics/base_metrics_adapter.py b/deployment/core/metrics/base_metrics_adapter.py new file mode 100644 index 000000000..c198224eb --- /dev/null +++ b/deployment/core/metrics/base_metrics_adapter.py @@ -0,0 +1,113 @@ +""" +Base Metrics Adapter for unified metric computation. + +This module provides the abstract base class that all task-specific metrics adapters +must implement. It ensures a consistent interface across 3D detection, 2D detection, +and classification tasks. + +All metric adapters 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 +class BaseMetricsConfig: + """Base configuration for all metrics adapters. + + 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" + + +class BaseMetricsAdapter(ABC): + """ + Abstract base class for all task-specific metrics adapters. + + This class defines the common interface that all metric adapters must implement. + Each adapter wraps autoware_perception_evaluation to compute metrics consistent + with training evaluation (T4MetricV2). + + The workflow is: + 1. Create adapter 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: + adapter = SomeMetricsAdapter(config) + adapter.reset() + for pred, gt in data: + adapter.add_frame(pred, gt) + metrics = adapter.compute_metrics() + """ + + def __init__(self, config: BaseMetricsConfig): + """ + Initialize the metrics adapter. + + Args: + config: Configuration for the metrics adapter. + """ + 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 adapter 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) -> Dict[str, 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..0b0124449 --- /dev/null +++ b/deployment/core/metrics/classification_metrics.py @@ -0,0 +1,331 @@ +""" +Classification Metrics Adapter using autoware_perception_evaluation. + +This module provides an adapter 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"], + ) + adapter = ClassificationMetricsAdapter(config) + + # Add frames + for pred_label, gt_label, probs in zip(predictions, ground_truths, probabilities): + adapter.add_frame( + prediction=pred_label, # int (class index) + ground_truth=gt_label, # int (class index) + probabilities=probs, # List[float] (optional) + ) + + # Compute metrics + metrics = adapter.compute_metrics() + # Returns: {"accuracy": 0.95, "precision": 0.94, "recall": 0.96, "f1score": 0.95, ...} +""" + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig + +logger = logging.getLogger(__name__) + + +@dataclass +class ClassificationMetricsConfig(BaseMetricsConfig): + """Configuration for classification metrics. + + Attributes: + class_names: List of class names for evaluation (e.g., ["miscalibrated", "calibrated"]). + frame_id: Frame ID for evaluation (not used for classification but kept for consistency). + """ + + # Override default frame_id for classification (not actually used but kept for interface consistency) + frame_id: str = "classification" + + +class ClassificationMetricsAdapter(BaseMetricsAdapter): + """ + Adapter for computing classification metrics. + + This adapter provides a simplified interface for the deployment framework to + compute accuracy, precision, recall, F1, and per-class metrics for classification + tasks (e.g., calibration status classification). + + The adapter accumulates predictions and ground truths, then computes metrics + using formulas consistent with autoware_perception_evaluation's ClassificationMetricsScore. + + 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 + + Example usage: + config = ClassificationMetricsConfig( + class_names=["miscalibrated", "calibrated"], + ) + adapter = ClassificationMetricsAdapter(config) + + # Add frames + for pred_label, gt_label, probs in zip(predictions, ground_truths, probabilities): + adapter.add_frame( + prediction=pred_label, + ground_truth=gt_label, + probabilities=probs, + ) + + # Compute metrics + metrics = adapter.compute_metrics() + """ + + def __init__(self, config: ClassificationMetricsConfig): + """ + Initialize the classification metrics adapter. + + Args: + config: Configuration for classification metrics. + """ + super().__init__(config) + self.config: ClassificationMetricsConfig = config + self.num_classes = len(config.class_names) + + # Storage for accumulated results + self._predictions: List[int] = [] + self._ground_truths: List[int] = [] + self._probabilities: List[List[float]] = [] + + def reset(self) -> None: + """Reset the adapter for a new evaluation session.""" + self._predictions = [] + self._ground_truths = [] + self._probabilities = [] + self._frame_count = 0 + + 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 (not used but kept for consistency). + """ + self._predictions.append(prediction) + self._ground_truths.append(ground_truth) + if probabilities is not None: + self._probabilities.append(probabilities) + self._frame_count += 1 + + def compute_metrics(self) -> Dict[str, float]: + """Compute metrics from all added predictions. + + Returns: + Dictionary of metrics including: + - accuracy: Overall accuracy + - precision: Overall precision + - recall: Overall recall + - f1score: Overall F1 score + - {class_name}_accuracy: Per-class accuracy + - {class_name}_precision: Per-class precision + - {class_name}_recall: Per-class recall + - {class_name}_f1score: Per-class F1 score + """ + if self._frame_count == 0: + logger.warning("No samples to evaluate") + return {} + + predictions = np.array(self._predictions) + ground_truths = np.array(self._ground_truths) + + metrics = {} + + # Compute overall metrics + overall_accuracy, overall_precision, overall_recall, overall_f1 = self._compute_overall_metrics( + predictions, ground_truths + ) + metrics["accuracy"] = overall_accuracy + metrics["precision"] = overall_precision + metrics["recall"] = overall_recall + metrics["f1score"] = overall_f1 + + # Compute per-class metrics + for class_idx, class_name in enumerate(self.class_names): + class_metrics = self._compute_class_metrics(predictions, ground_truths, class_idx) + metrics[f"{class_name}_accuracy"] = class_metrics["accuracy"] + metrics[f"{class_name}_precision"] = class_metrics["precision"] + metrics[f"{class_name}_recall"] = class_metrics["recall"] + metrics[f"{class_name}_f1score"] = class_metrics["f1score"] + metrics[f"{class_name}_tp"] = class_metrics["tp"] + metrics[f"{class_name}_fp"] = class_metrics["fp"] + metrics[f"{class_name}_fn"] = class_metrics["fn"] + metrics[f"{class_name}_num_gt"] = class_metrics["num_gt"] + + # Add total counts + metrics["total_samples"] = len(predictions) + metrics["correct_predictions"] = int((predictions == ground_truths).sum()) + + return metrics + + def _compute_overall_metrics( + self, + predictions: np.ndarray, + ground_truths: np.ndarray, + ) -> Tuple[float, float, float, float]: + """Compute overall metrics following autoware_perception_evaluation formulas. + + The formulas follow ClassificationMetricsScore._summarize() from + autoware_perception_evaluation. + + Args: + predictions: Array of predicted class indices. + ground_truths: Array of ground truth class indices. + + Returns: + Tuple of (accuracy, precision, recall, f1score). + """ + num_est = len(predictions) + num_gt = len(ground_truths) + + # Count TP (correct predictions) and FP (incorrect predictions) + num_tp = int((predictions == ground_truths).sum()) + num_fp = num_est - num_tp + + # Accuracy formula from autoware_perception_evaluation: + # accuracy = num_tp / (num_est + num_gt - num_tp) + # This is equivalent to Jaccard index / IoU + denominator = num_est + num_gt - num_tp + accuracy = num_tp / denominator if denominator != 0 else 0.0 + + # Precision = TP / (TP + FP) + precision = num_tp / (num_tp + num_fp) if (num_tp + num_fp) != 0 else 0.0 + + # Recall = TP / num_gt + recall = num_tp / num_gt if num_gt != 0 else 0.0 + + # F1 = 2 * precision * recall / (precision + recall) + f1score = 2 * precision * recall / (precision + recall) if (precision + recall) != 0 else 0.0 + + return accuracy, precision, recall, f1score + + def _compute_class_metrics( + self, + predictions: np.ndarray, + ground_truths: np.ndarray, + class_idx: int, + ) -> Dict[str, float]: + """Compute metrics for a single class. + + Args: + predictions: Array of predicted class indices. + ground_truths: Array of ground truth class indices. + class_idx: Class index to compute metrics for. + + Returns: + Dictionary with accuracy, precision, recall, f1score, tp, fp, fn, num_gt. + """ + # For binary per-class evaluation: + # - TP: predicted class_idx and ground truth is class_idx + # - FP: predicted class_idx but ground truth is not class_idx + # - FN: not predicted class_idx but ground truth is class_idx + + pred_is_class = predictions == class_idx + gt_is_class = ground_truths == class_idx + + tp = int((pred_is_class & gt_is_class).sum()) + fp = int((pred_is_class & ~gt_is_class).sum()) + fn = int((~pred_is_class & gt_is_class).sum()) + num_gt = int(gt_is_class.sum()) + num_pred = int(pred_is_class.sum()) + + # Precision for this class + precision = tp / (tp + fp) if (tp + fp) != 0 else 0.0 + + # Recall for this class + recall = tp / num_gt if num_gt != 0 else 0.0 + + # F1 for this class + f1score = 2 * precision * recall / (precision + recall) if (precision + recall) != 0 else 0.0 + + # Accuracy for this class (matching autoware_perception_evaluation formula) + denominator = num_pred + num_gt - tp + accuracy = tp / denominator if denominator != 0 else 0.0 + + return { + "accuracy": accuracy, + "precision": precision, + "recall": recall, + "f1score": f1score, + "tp": tp, + "fp": fp, + "fn": fn, + "num_gt": num_gt, + } + + def get_confusion_matrix(self) -> np.ndarray: + """Get the confusion matrix. + + Returns: + 2D numpy array where cm[i][j] = count of samples with ground truth i + predicted as class j. + """ + if self._frame_count == 0: + return np.zeros((self.num_classes, self.num_classes), dtype=int) + + predictions = np.array(self._predictions) + ground_truths = np.array(self._ground_truths) + + confusion_matrix = np.zeros((self.num_classes, self.num_classes), dtype=int) + for gt, pred in zip(ground_truths, predictions): + if 0 <= gt < self.num_classes and 0 <= pred < self.num_classes: + confusion_matrix[int(gt), int(pred)] += 1 + + return confusion_matrix + + def get_summary(self) -> Dict[str, Any]: + """Get a summary of the evaluation. + + Returns: + Dictionary with summary metrics including: + - accuracy: Overall accuracy + - per_class_accuracy: Dict mapping class names to accuracies + - confusion_matrix: 2D list + - num_samples: Total number of samples + """ + metrics = self.compute_metrics() + + if not metrics: + return { + "accuracy": 0.0, + "per_class_accuracy": {}, + "confusion_matrix": [], + "num_samples": 0, + } + + per_class_accuracy = {} + for class_name in self.class_names: + key = f"{class_name}_accuracy" + if key in metrics: + per_class_accuracy[class_name] = metrics[key] + + return { + "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..9333ce123 --- /dev/null +++ b/deployment/core/metrics/detection_2d_metrics.py @@ -0,0 +1,479 @@ +""" +2D Detection Metrics Adapter using autoware_perception_evaluation. + +This module provides an adapter 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 adapter 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", + ) + adapter = Detection2DMetricsAdapter(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + adapter.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 = adapter.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_adapter import BaseMetricsAdapter, BaseMetricsConfig + +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 +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. " f"Valid options: {VALID_2D_FRAME_IDS}" + ) + + # Set default evaluation config if not provided + if self.evaluation_config_dict is None: + self.evaluation_config_dict = { + "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", + } + + # Set default critical object filter config if not provided + if self.critical_object_filter_config is None: + self.critical_object_filter_config = { + "target_labels": self.class_names, + "ignore_attributes": None, + } + + # Set default frame pass fail config if not provided + if self.frame_pass_fail_config is None: + num_classes = len(self.class_names) + self.frame_pass_fail_config = { + "target_labels": self.class_names, + "matching_threshold_list": [0.5] * num_classes, + "confidence_threshold_list": None, + } + + +class Detection2DMetricsAdapter(BaseMetricsAdapter): + """ + Adapter for computing 2D detection metrics using autoware_perception_evaluation. + + This adapter 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], + ) + adapter = Detection2DMetricsAdapter(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + adapter.add_frame( + predictions=pred, # List[Dict] with bbox, label, score + ground_truths=gt, # List[Dict] with bbox, label + ) + + # Compute metrics + metrics = adapter.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 adapter. + + 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 adapter 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) -> Dict[str, Any]: + """Get a summary of the evaluation including mAP and per-class metrics. + + Returns: + Dictionary with summary 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 { + "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..15dc51931 --- /dev/null +++ b/deployment/core/metrics/detection_3d_metrics.py @@ -0,0 +1,495 @@ +""" +3D Detection Metrics Adapter using autoware_perception_evaluation. + +This module provides an adapter 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", + ) + adapter = Detection3DMetricsAdapter(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + adapter.add_frame( + predictions=pred, # List[Dict] with bbox_3d, label, score + ground_truths=gt, # List[Dict] with bbox_3d, label + ) + + # Compute metrics + metrics = adapter.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_adapter import BaseMetricsAdapter, BaseMetricsConfig + +logger = logging.getLogger(__name__) + + +@dataclass +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: + self.evaluation_config_dict = { + "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, + } + + # Set default critical object filter config if not provided + if self.critical_object_filter_config is None: + num_classes = len(self.class_names) + self.critical_object_filter_config = { + "target_labels": self.class_names, + "ignore_attributes": None, + "max_distance_list": [121.0] * num_classes, + "min_distance_list": [-121.0] * num_classes, + } + + # Set default frame pass fail config if not provided + if self.frame_pass_fail_config is None: + num_classes = len(self.class_names) + self.frame_pass_fail_config = { + "target_labels": self.class_names, + "matching_threshold_list": [2.0] * num_classes, + "confidence_threshold_list": None, + } + + +class Detection3DMetricsAdapter(BaseMetricsAdapter): + """ + Adapter for computing 3D detection metrics using autoware_perception_evaluation. + + This adapter 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", + ) + adapter = Detection3DMetricsAdapter(config) + + # Add frames + for pred, gt in zip(predictions_list, ground_truths_list): + adapter.add_frame( + predictions=pred, # List[Dict] with bbox_3d, label, score + ground_truths=gt, # List[Dict] with bbox_3d, label + ) + + # Compute metrics + metrics = adapter.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 adapter. + + 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 adapter 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) -> Dict[str, Any]: + """Get a summary of the evaluation including mAP and per-class metrics. + + Returns: + Dictionary with summary 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 { + "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..77c504908 --- /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_workflow.md`](./export_workflow.md) – ONNX/TensorRT export details plus workflows. +- [`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..07c7e298f --- /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. │ +│ - Workflows │ │ - 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 workflows. +- 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 & Workflows + +- `exporters/common/` hosts the base exporters, typed config objects, and `ExporterFactory`. +- Project wrappers live in `exporters/{project}/model_wrappers.py`. +- Complex projects add workflows (e.g., `CenterPointONNXExportWorkflow`) 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/workflows +├── 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..79ea8fbf5 --- /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 workflows) into project runners; let `ExporterFactory` build exporters lazily. +- Store wrappers under `exporters/{model}/model_wrappers.py` and reuse `IdentityWrapper` when reshaping is unnecessary. +- Add workflow 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_workflow.py +└── [optional] tensorrt_workflow.py +``` + +- Simple models: use base exporters + wrappers, no subclassing. +- Complex models: compose workflows 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/workflows. + +## 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..c0be80a7a --- /dev/null +++ b/deployment/docs/configuration.md @@ -0,0 +1,134 @@ +# 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" + +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={"cpu": "cpu", "cuda": "cuda:0"}, + scenarios={ + "both": [ + {"ref_backend": "pytorch", "ref_device": "cpu", + "test_backend": "onnx", "test_device": "cpu"}, + ] + } +) + +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"}, + } +) +``` + +## 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": "cpu"}, + Backend.ONNX: {"enabled": True, "device": "cpu"}, + Backend.TENSORRT: {"enabled": True, "device": "cuda:0"}, + } +) +``` + +## 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 + +- `projects/CenterPoint/deploy/configs/deploy_config.py` +- `projects/YOLOX_opt_elan/deploy/configs/deploy_config.py` +- `projects/CalibrationStatusClassification/deploy/configs/deploy_config.py` diff --git a/deployment/docs/contributing.md b/deployment/docs/contributing.md new file mode 100644 index 000000000..2ed0c3a10 --- /dev/null +++ b/deployment/docs/contributing.md @@ -0,0 +1,31 @@ +# 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. **Exporters** + - Add `exporters/{project}/model_wrappers.py` (reuse `IdentityWrapper` or implement a custom wrapper). + - Introduce `onnx_workflow.py` / `tensorrt_workflow.py` only if multi-stage orchestration is required; prefer composing the base exporters instead of subclassing them. + +3. **Pipelines** + - Inherit from the appropriate task base (`Detection2D`, `Detection3D`, `Classification`). + - Add backend-specific implementations (PyTorch, ONNX, TensorRT) only when behavior deviates from existing ones. + +4. **Configuration** + - Create `projects/{project}/deploy/configs/deploy_config.py`. + - Configure export, verification, and evaluation settings with typed dataclasses where possible. + +5. **Entry Point** + - Add `projects/{project}/deploy/main.py`. + - Follow the dependency injection pattern: explicitly pass wrapper classes and workflows to the runner. + +6. **Documentation** + - Update `deployment/README.md` and the relevant docs in `deployment/docs/`. + - Document special requirements, configuration flags, or workflows. + +## 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..099a4a7c9 --- /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 workflows. +- 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 `BaseMetricsAdapter` at construction. +- Responsible for: + - Creating backend pipelines through `PipelineFactory` + - Preparing verification inputs from the data loader + - Computing task metrics using metrics adapters + - 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_adapter(predictions, ground_truths)` → feed metrics adapter + - `_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 Adapters (Autoware-based adapters) +- 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. +- Output typed metric structures (`Detection3DEvaluationMetrics`, `Detection2DEvaluationMetrics`, `ClassificationEvaluationMetrics`). +- 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 Adapters** ✓ +- **PipelineFactory → Pipelines** ✓ +- **Pipelines ↔ Metrics Adapters** ✗ (evaluators mediate) +- **Metrics Adapters → 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_workflow.md b/deployment/docs/export_workflow.md new file mode 100644 index 000000000..4b4355b65 --- /dev/null +++ b/deployment/docs/export_workflow.md @@ -0,0 +1,50 @@ +# Export Workflows + +## 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` + +Workflows 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 workflows when instantiating the runner: + +```python +runner = CenterPointDeploymentRunner( + ..., + onnx_workflow=CenterPointONNXExportWorkflow(...), + tensorrt_workflow=CenterPointTensorRTExportWorkflow(...), +) +``` + +Simple projects can skip workflows 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..a32521162 --- /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 workflows 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 share typed metrics (`Detection3DEvaluationMetrics`, `Detection2DEvaluationMetrics`, `ClassificationEvaluationMetrics`) so reports remain consistent across backends. + +### 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..570f6cb53 --- /dev/null +++ b/deployment/docs/projects.md @@ -0,0 +1,79 @@ +# Project Guides + +## CenterPoint (3D Detection) + +**Highlights** + +- Multi-file ONNX export (voxel encoder + backbone/head) orchestrated via workflows. +- ONNX-compatible model configuration that mirrors training graph. +- Composed exporters keep logic reusable. + +**Workflows & Wrappers** + +- `CenterPointONNXExportWorkflow` – drives multiple ONNX exports using the generic `ONNXExporter`. +- `CenterPointTensorRTExportWorkflow` – converts each ONNX file via the generic `TensorRTExporter`. +- `CenterPointONNXWrapper` – identity wrapper. + +**Key Files** + +- `projects/CenterPoint/deploy/main.py` +- `projects/CenterPoint/deploy/evaluator.py` +- `deployment/pipelines/centerpoint/` +- `deployment/exporters/centerpoint/onnx_workflow.py` +- `deployment/exporters/centerpoint/tensorrt_workflow.py` + +**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** + +- `projects/YOLOX_opt_elan/deploy/main.py` +- `projects/YOLOX_opt_elan/deploy/evaluator.py` +- `deployment/pipelines/yolox/` +- `deployment/exporters/yolox/model_wrappers.py` + +**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** + +- `projects/CalibrationStatusClassification/deploy/main.py` +- `projects/CalibrationStatusClassification/deploy/evaluator.py` +- `deployment/pipelines/calibration/` +- `deployment/exporters/calibration/model_wrappers.py` + +**Pipeline Structure** + +``` +preprocess() → run_model() → postprocess() +``` diff --git a/deployment/docs/usage.md b/deployment/docs/usage.md new file mode 100644 index 000000000..f4851ef35 --- /dev/null +++ b/deployment/docs/usage.md @@ -0,0 +1,123 @@ +# Usage & Entry Points + +## Basic Commands + +```bash +# CenterPoint deployment +python projects/CenterPoint/deploy/main.py \ + configs/deploy_config.py \ + configs/model_config.py + +# YOLOX deployment +python projects/YOLOX_opt_elan/deploy/main.py \ + configs/deploy_config.py \ + configs/model_config.py + +# Calibration deployment +python projects/CalibrationStatusClassification/deploy/main.py \ + configs/deploy_config.py \ + configs/model_config.py +``` + +## Creating a Project Runner + +Projects pass lightweight configuration objects (wrapper classes and optional workflows) into the runner. Exporters are created lazily via `ExporterFactory`. + +```python +from deployment.exporters.yolox.model_wrappers import YOLOXOptElanONNXWrapper +from deployment.runners import YOLOXOptElanDeploymentRunner + +runner = YOLOXOptElanDeploymentRunner( + data_loader=data_loader, + evaluator=evaluator, + config=config, + model_cfg=model_cfg, + logger=logger, + onnx_wrapper_cls=YOLOXOptElanONNXWrapper, +) +``` + +Key points: + +- Pass wrapper classes (and optional workflows) 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 + [checkpoint] \ # Optional checkpoint path + --work-dir \ # Override work directory + --device \ # Override device + --log-level # DEBUG, INFO, WARNING, ERROR +``` + +## 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..ca58cf2f1 --- /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 adapters. 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 index 6196df918..31f34d6b6 100644 --- a/deployment/exporters/__init__.py +++ b/deployment/exporters/__init__.py @@ -1,10 +1,10 @@ """Model exporters for different backends.""" -from deployment.exporters.base.base_exporter import BaseExporter -from deployment.exporters.base.configs import ONNXExportConfig, TensorRTExportConfig -from deployment.exporters.base.model_wrappers import BaseModelWrapper, IdentityWrapper -from deployment.exporters.base.onnx_exporter import ONNXExporter -from deployment.exporters.base.tensorrt_exporter import TensorRTExporter +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", diff --git a/deployment/exporters/base/base_exporter.py b/deployment/exporters/common/base_exporter.py similarity index 94% rename from deployment/exporters/base/base_exporter.py rename to deployment/exporters/common/base_exporter.py index c68e4cb84..057ef9712 100644 --- a/deployment/exporters/base/base_exporter.py +++ b/deployment/exporters/common/base_exporter.py @@ -10,8 +10,8 @@ import torch -from deployment.exporters.base.configs import BaseExporterConfig -from deployment.exporters.base.model_wrappers import BaseModelWrapper +from deployment.exporters.common.configs import BaseExporterConfig +from deployment.exporters.common.model_wrappers import BaseModelWrapper class BaseExporter(ABC): diff --git a/deployment/exporters/base/configs.py b/deployment/exporters/common/configs.py similarity index 100% rename from deployment/exporters/base/configs.py rename to deployment/exporters/common/configs.py diff --git a/deployment/exporters/base/factory.py b/deployment/exporters/common/factory.py similarity index 83% rename from deployment/exporters/base/factory.py rename to deployment/exporters/common/factory.py index 4aeed055a..9533f2d12 100644 --- a/deployment/exporters/base/factory.py +++ b/deployment/exporters/common/factory.py @@ -8,9 +8,9 @@ from typing import Type from deployment.core import BaseDeploymentConfig -from deployment.exporters.base.model_wrappers import BaseModelWrapper -from deployment.exporters.base.onnx_exporter import ONNXExporter -from deployment.exporters.base.tensorrt_exporter import TensorRTExporter +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: diff --git a/deployment/exporters/base/model_wrappers.py b/deployment/exporters/common/model_wrappers.py similarity index 100% rename from deployment/exporters/base/model_wrappers.py rename to deployment/exporters/common/model_wrappers.py diff --git a/deployment/exporters/base/onnx_exporter.py b/deployment/exporters/common/onnx_exporter.py similarity index 97% rename from deployment/exporters/base/onnx_exporter.py rename to deployment/exporters/common/onnx_exporter.py index 905960241..ca1ed9631 100644 --- a/deployment/exporters/base/onnx_exporter.py +++ b/deployment/exporters/common/onnx_exporter.py @@ -9,8 +9,8 @@ import onnxsim import torch -from deployment.exporters.base.base_exporter import BaseExporter -from deployment.exporters.base.configs import ONNXExportConfig +from deployment.exporters.common.base_exporter import BaseExporter +from deployment.exporters.common.configs import ONNXExportConfig class ONNXExporter(BaseExporter): @@ -35,7 +35,7 @@ def __init__( Args: config: ONNX export configuration dataclass instance. - model_wrapper: Optional model wrapper class (e.g., YOLOXONNXWrapper) + model_wrapper: Optional model wrapper class (e.g., YOLOXOptElanONNXWrapper) logger: Optional logger instance """ super().__init__(config, model_wrapper=model_wrapper, logger=logger) diff --git a/deployment/exporters/base/tensorrt_exporter.py b/deployment/exporters/common/tensorrt_exporter.py similarity index 74% rename from deployment/exporters/base/tensorrt_exporter.py rename to deployment/exporters/common/tensorrt_exporter.py index 165eb7829..0106b68a2 100644 --- a/deployment/exporters/base/tensorrt_exporter.py +++ b/deployment/exporters/common/tensorrt_exporter.py @@ -7,8 +7,8 @@ import torch from deployment.core.artifacts import Artifact -from deployment.exporters.base.base_exporter import BaseExporter -from deployment.exporters.base.configs import TensorRTExportConfig, TensorRTModelInputConfig, TensorRTProfileConfig +from deployment.exporters.common.base_exporter import BaseExporter +from deployment.exporters.common.configs import TensorRTExportConfig, TensorRTModelInputConfig, TensorRTProfileConfig class TensorRTExporter(BaseExporter): @@ -188,11 +188,29 @@ def _configure_input_profiles( 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 + 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) @@ -256,16 +274,61 @@ def _configure_input_shapes( Configure input shapes for TensorRT optimization profile. Note: - This is separate from ONNX `dynamic_axes`: + 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) - - `dynamic_axes` controls symbolic dimensions in the ONNX graph. - - Here, `min/opt/max` shapes define TensorRT optimization profiles, - i.e., the allowed and optimized runtime shapes for each input. + 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. - They are complementary but independent. + 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 + if not model_inputs_cfg or not model_inputs_cfg[0].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") diff --git a/deployment/exporters/workflows/__init__.py b/deployment/exporters/workflows/__init__.py new file mode 100644 index 000000000..c932c18a3 --- /dev/null +++ b/deployment/exporters/workflows/__init__.py @@ -0,0 +1,16 @@ +"""Export workflow interfaces and implementations.""" + +from deployment.exporters.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow +from deployment.exporters.workflows.interfaces import ( + ExportableComponent, + ModelComponentExtractor, +) + +__all__ = [ + # Base workflows + "OnnxExportWorkflow", + "TensorRTExportWorkflow", + # Component extraction interfaces + "ModelComponentExtractor", + "ExportableComponent", +] diff --git a/deployment/exporters/workflows/base.py b/deployment/exporters/workflows/base.py index b4a678ade..ce278cac6 100644 --- a/deployment/exporters/workflows/base.py +++ b/deployment/exporters/workflows/base.py @@ -5,11 +5,12 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any +from typing import Any, Optional from deployment.core.artifacts import Artifact -from deployment.core.base_config import BaseDeploymentConfig -from deployment.core.base_data_loader import BaseDataLoader +from deployment.core.config.base_config import BaseDeploymentConfig +from deployment.core.contexts import ExportContext +from deployment.core.io.base_data_loader import BaseDataLoader class OnnxExportWorkflow(ABC): @@ -26,10 +27,23 @@ def export( output_dir: str, config: BaseDeploymentConfig, sample_idx: int = 0, - **kwargs: Any, + context: Optional[ExportContext] = None, ) -> Artifact: """ Execute the ONNX export workflow 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 + context: Typed export context with project-specific parameters. + Use project-specific context subclasses (e.g., CenterPointExportContext) + for type-safe access to parameters. + + Returns: + Artifact describing the exported ONNX output """ @@ -47,8 +61,20 @@ def export( config: BaseDeploymentConfig, device: str, data_loader: BaseDataLoader, - **kwargs: Any, + context: Optional[ExportContext] = None, ) -> Artifact: """ Execute the TensorRT export workflow 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 + context: Typed export context with project-specific parameters. + Use project-specific context subclasses for type-safe access. + + Returns: + Artifact describing the exported TensorRT output """ diff --git a/deployment/exporters/workflows/interfaces.py b/deployment/exporters/workflows/interfaces.py new file mode 100644 index 000000000..973ab7e6e --- /dev/null +++ b/deployment/exporters/workflows/interfaces.py @@ -0,0 +1,66 @@ +""" +Interfaces for export workflow components. + +This module defines interfaces that allow project-specific code to provide +model-specific knowledge to generic deployment workflows. +""" + +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 +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 workflows. + """ + + @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 index 1b329596f..53bc0f01e 100644 --- a/deployment/pipelines/__init__.py +++ b/deployment/pipelines/__init__.py @@ -5,44 +5,50 @@ multi-stage processing with mixed PyTorch and optimized backend inference. """ -# # Calibration pipelines (classification) -# from deployment.pipelines.calibration import ( -# CalibrationDeploymentPipeline, -# CalibrationONNXPipeline, -# CalibrationPyTorchPipeline, -# CalibrationTensorRTPipeline, -# ) +# Calibration pipelines (classification) +from deployment.pipelines.calibration import ( + CalibrationDeploymentPipeline, + CalibrationONNXPipeline, + CalibrationPyTorchPipeline, + CalibrationTensorRTPipeline, +) -# # CenterPoint pipelines (3D detection) -# from deployment.pipelines.centerpoint import ( -# CenterPointDeploymentPipeline, -# CenterPointONNXPipeline, -# CenterPointPyTorchPipeline, -# CenterPointTensorRTPipeline, -# ) +# CenterPoint pipelines (3D detection) +from deployment.pipelines.centerpoint import ( + CenterPointDeploymentPipeline, + CenterPointONNXPipeline, + CenterPointPyTorchPipeline, + CenterPointTensorRTPipeline, +) -# # YOLOX pipelines (2D detection) -# from deployment.pipelines.yolox import ( -# YOLOXDeploymentPipeline, -# YOLOXONNXPipeline, -# YOLOXPyTorchPipeline, -# YOLOXTensorRTPipeline, -# ) +# Pipeline factory +from deployment.pipelines.factory import PipelineFactory, PipelineRegistry -# __all__ = [ -# # CenterPoint -# "CenterPointDeploymentPipeline", -# "CenterPointPyTorchPipeline", -# "CenterPointONNXPipeline", -# "CenterPointTensorRTPipeline", -# # YOLOX -# "YOLOXDeploymentPipeline", -# "YOLOXPyTorchPipeline", -# "YOLOXONNXPipeline", -# "YOLOXTensorRTPipeline", -# # Calibration -# "CalibrationDeploymentPipeline", -# "CalibrationPyTorchPipeline", -# "CalibrationONNXPipeline", -# "CalibrationTensorRTPipeline", -# ] +# YOLOX pipelines (2D detection) +from deployment.pipelines.yolox import ( + YOLOXDeploymentPipeline, + YOLOXONNXPipeline, + YOLOXPyTorchPipeline, + YOLOXTensorRTPipeline, +) + +__all__ = [ + # Factory + "PipelineFactory", + "PipelineRegistry", + # CenterPoint + "CenterPointDeploymentPipeline", + "CenterPointPyTorchPipeline", + "CenterPointONNXPipeline", + "CenterPointTensorRTPipeline", + # YOLOX + "YOLOXDeploymentPipeline", + "YOLOXPyTorchPipeline", + "YOLOXONNXPipeline", + "YOLOXTensorRTPipeline", + # Calibration + "CalibrationDeploymentPipeline", + "CalibrationPyTorchPipeline", + "CalibrationONNXPipeline", + "CalibrationTensorRTPipeline", +] diff --git a/deployment/pipelines/base/__init__.py b/deployment/pipelines/base/__init__.py deleted file mode 100644 index 1cbc34102..000000000 --- a/deployment/pipelines/base/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Base Pipeline Classes for Deployment Framework. - -This module provides the base abstract classes for all deployment pipelines, -including base pipeline, classification, 2D detection, and 3D detection pipelines. -""" - -from deployment.pipelines.base.base_pipeline import BaseDeploymentPipeline - -__all__ = [ - "BaseDeploymentPipeline", -] diff --git a/deployment/pipelines/common/__init__.py b/deployment/pipelines/common/__init__.py new file mode 100644 index 000000000..e07649794 --- /dev/null +++ b/deployment/pipelines/common/__init__.py @@ -0,0 +1,11 @@ +""" +Base Pipeline Classes for Deployment Framework. + +This module provides the base abstract class for all deployment pipelines. +""" + +from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline + +__all__ = [ + "BaseDeploymentPipeline", +] diff --git a/deployment/pipelines/base/base_pipeline.py b/deployment/pipelines/common/base_pipeline.py similarity index 81% rename from deployment/pipelines/base/base_pipeline.py rename to deployment/pipelines/common/base_pipeline.py index 2f43b8a29..b9dbaee16 100644 --- a/deployment/pipelines/base/base_pipeline.py +++ b/deployment/pipelines/common/base_pipeline.py @@ -75,7 +75,7 @@ def preprocess(self, input_data: Any, **kwargs) -> Any: raise NotImplementedError @abstractmethod - def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple]: + def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple[Any, Dict[str, float]]]: """ Run model inference (backend-specific). @@ -86,7 +86,18 @@ def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple]: preprocessed_input: Preprocessed input data Returns: - Model output (raw tensors or backend-specific format) + Model output, or Tuple of (model_output, stage_latencies) + + If a tuple is returned: + - model_output: Raw tensors or backend-specific format + - stage_latencies: Dict mapping stage names to latency in ms + + If single value is returned, it's treated as model_output with no stage latencies. + + Note: + Returning stage latencies as a tuple is the recommended pattern to avoid + race conditions when pipelines are reused across multiple threads. + Use local variables instead of instance variables for per-request data. """ raise NotImplementedError @@ -163,11 +174,21 @@ def infer( # Run model (backend-specific) model_start = time.perf_counter() - model_output = self.run_model(model_input) + model_result = self.run_model(model_input) model_time = time.perf_counter() latency_breakdown["model_ms"] = (model_time - model_start) * 1000 - # Merge stage-wise latencies if available + # Handle returned stage latencies (new pattern - thread-safe) + stage_latencies = {} + 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: Merge stage-wise latencies from instance variable (deprecated) + # This is kept for backward compatibility but should be removed eventually if hasattr(self, "_stage_latencies") and isinstance(self._stage_latencies, dict): latency_breakdown.update(self._stage_latencies) # Clear for next inference @@ -191,6 +212,18 @@ def infer( logger.exception("Inference failed.") raise + def cleanup(self) -> None: + """ + Cleanup pipeline resources. + + Subclasses should override this to release backend-specific resources + (e.g., TensorRT contexts, ONNX sessions, CUDA streams). + + This method is called automatically when using the pipeline as a + context manager, or can be called explicitly when done with the pipeline. + """ + pass + def __repr__(self): return ( f"{self.__class__.__name__}(" @@ -204,10 +237,6 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit. - - Subclasses can override this to release backend resources - (e.g., TensorRT contexts, ONNX sessions, CUDA streams). - """ - # Cleanup resources if needed - pass + """Context manager exit - cleanup resources.""" + self.cleanup() + return False diff --git a/deployment/pipelines/common/gpu_resource_mixin.py b/deployment/pipelines/common/gpu_resource_mixin.py new file mode 100644 index 000000000..df1836614 --- /dev/null +++ b/deployment/pipelines/common/gpu_resource_mixin.py @@ -0,0 +1,238 @@ +""" +GPU Resource Management Mixin for TensorRT Pipelines. + +This module provides a standardized approach to GPU resource cleanup, +ensuring proper release of TensorRT engines, contexts, and CUDA memory. + +Design Principles: + 1. Single Responsibility: Resource cleanup logic is centralized + 2. Context Manager Protocol: Supports `with` statement for automatic cleanup + 3. Explicit Cleanup: Provides `cleanup()` for manual resource release + 4. Thread Safety: Uses local variables instead of instance state where possible +""" + +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +import torch + +logger = logging.getLogger(__name__) + + +def clear_cuda_memory() -> None: + """ + Clear CUDA memory cache and synchronize. + + This is a utility function that safely clears GPU memory + regardless of whether CUDA is available. + """ + if torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.synchronize() + + +class GPUResourceMixin(ABC): + """ + Mixin class for pipelines that manage GPU resources. + + This mixin provides: + - Standard `cleanup()` interface for resource release + - Context manager protocol for automatic cleanup + - Safe cleanup in `__del__` as fallback + + Subclasses must implement `_release_gpu_resources()` to specify + which resources to release. + + Usage: + class MyTensorRTPipeline(BaseDeploymentPipeline, GPUResourceMixin): + def _release_gpu_resources(self) -> None: + # Release TensorRT engines, contexts, CUDA buffers, etc. + ... + + With context manager: + with MyTensorRTPipeline(...) as pipeline: + results = pipeline.infer(data) + # Resources automatically cleaned up + + Explicit cleanup: + pipeline = MyTensorRTPipeline(...) + try: + results = pipeline.infer(data) + finally: + pipeline.cleanup() + """ + + _cleanup_called: bool = False + + @abstractmethod + def _release_gpu_resources(self) -> None: + """ + Release GPU-specific resources. + + Subclasses must implement this to release their specific resources: + - TensorRT engines and execution contexts + - CUDA device memory allocations + - CUDA streams + - Any other GPU-bound resources + + This method should be idempotent (safe to call multiple times). + """ + raise NotImplementedError + + def cleanup(self) -> None: + """ + Explicitly cleanup GPU resources and release memory. + + This method should be called when the pipeline is no longer needed. + It's safe to call multiple times. + + For automatic cleanup, use the pipeline as a context manager: + with pipeline: + results = pipeline.infer(data) + """ + 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): + """Context manager entry - return self for use in with statement.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - cleanup resources.""" + self.cleanup() + return False # Don't suppress exceptions + + def __del__(self): + """Destructor - cleanup as fallback if not done explicitly.""" + try: + self.cleanup() + except Exception: + pass # Suppress errors in destructor + + +class TensorRTResourceManager: + """ + Context manager for TensorRT inference with automatic resource cleanup. + + This class manages temporary CUDA allocations during inference, + ensuring they are properly freed even if an exception occurs. + + Usage: + with TensorRTResourceManager() as manager: + d_input = manager.allocate(input_nbytes) + d_output = manager.allocate(output_nbytes) + # ... run inference ... + # All allocations automatically freed + """ + + def __init__(self): + self._allocations: List[Any] = [] + self._stream: Optional[Any] = None + + def allocate(self, nbytes: int) -> Any: + """ + Allocate CUDA device memory and track for cleanup. + + Args: + nbytes: Number of bytes to allocate + + Returns: + pycuda.driver.DeviceAllocation object + """ + import pycuda.driver as cuda + + allocation = cuda.mem_alloc(nbytes) + self._allocations.append(allocation) + return allocation + + def get_stream(self) -> Any: + """ + Get or create a CUDA stream. + + Returns: + pycuda.driver.Stream object + """ + if self._stream is None: + import pycuda.driver as cuda + + self._stream = cuda.Stream() + return self._stream + + def synchronize(self) -> None: + """Synchronize the CUDA stream.""" + if self._stream is not None: + self._stream.synchronize() + + def _release_all(self) -> None: + """Release all tracked allocations.""" + 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: + """ + Release TensorRT resources safely. + + This is a utility function that handles the cleanup of various + TensorRT resources in a safe, idempotent manner. + + Args: + engines: Dictionary of TensorRT engine objects + contexts: Dictionary of TensorRT execution context objects + cuda_buffers: List of pycuda.driver.DeviceAllocation objects + """ + # Release contexts first (they reference engines) + if contexts: + for name, context in list(contexts.items()): + if context is not None: + try: + del context + except Exception: + pass + contexts.clear() + + # Release engines + if engines: + for name, engine in list(engines.items()): + if engine is not None: + try: + del engine + except Exception: + pass + engines.clear() + + # Free CUDA buffers + 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/factory.py b/deployment/pipelines/factory.py new file mode 100644 index 000000000..59eea367d --- /dev/null +++ b/deployment/pipelines/factory.py @@ -0,0 +1,208 @@ +""" +Pipeline factory for centralized pipeline instantiation. + +This module provides a factory for creating task-specific pipelines, +eliminating duplicated backend switching logic across evaluators. +""" + +import logging +from typing import Any, Dict, List, Optional, Type + +from deployment.core.backend import Backend +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class PipelineRegistry: + """ + Registry for pipeline classes. + + Each task type registers its pipeline classes for different backends. + """ + + _registry: Dict[str, Dict[Backend, Type[BaseDeploymentPipeline]]] = {} + + @classmethod + def register( + cls, + task_type: str, + backend: Backend, + pipeline_cls: Type[BaseDeploymentPipeline], + ) -> None: + """Register a pipeline class for a task type and backend.""" + if task_type not in cls._registry: + cls._registry[task_type] = {} + cls._registry[task_type][backend] = pipeline_cls + + @classmethod + def get(cls, task_type: str, backend: Backend) -> Optional[Type[BaseDeploymentPipeline]]: + """Get a pipeline class for a task type and backend.""" + return cls._registry.get(task_type, {}).get(backend) + + @classmethod + def register_task( + cls, + task_type: str, + pytorch_cls: Type[BaseDeploymentPipeline], + onnx_cls: Type[BaseDeploymentPipeline], + tensorrt_cls: Type[BaseDeploymentPipeline], + ) -> None: + """Register all backend pipelines for a task type.""" + cls.register(task_type, Backend.PYTORCH, pytorch_cls) + cls.register(task_type, Backend.ONNX, onnx_cls) + cls.register(task_type, Backend.TENSORRT, tensorrt_cls) + + +class PipelineFactory: + """ + Factory for creating deployment pipelines. + + This factory centralizes pipeline creation logic, eliminating the + duplicated backend switching code in evaluators. + """ + + @staticmethod + def create_centerpoint_pipeline( + model_spec: ModelSpec, + pytorch_model: Any, + device: Optional[str] = None, + ) -> BaseDeploymentPipeline: + """ + Create a CenterPoint pipeline. + + Args: + model_spec: Model specification (backend/device/path) + pytorch_model: PyTorch model instance + device: Override device (uses model_spec.device if None) + + Returns: + CenterPoint pipeline instance + """ + from deployment.pipelines.centerpoint import ( + CenterPointONNXPipeline, + CenterPointPyTorchPipeline, + CenterPointTensorRTPipeline, + ) + + device = device or model_spec.device + backend = model_spec.backend + + if backend is Backend.PYTORCH: + return CenterPointPyTorchPipeline(pytorch_model, device=device) + elif backend is Backend.ONNX: + return CenterPointONNXPipeline(pytorch_model, onnx_dir=model_spec.path, device=device) + elif backend is Backend.TENSORRT: + return CenterPointTensorRTPipeline(pytorch_model, tensorrt_dir=model_spec.path, device=device) + else: + raise ValueError(f"Unsupported backend: {backend.value}") + + @staticmethod + def create_yolox_pipeline( + model_spec: ModelSpec, + pytorch_model: Any, + num_classes: int, + class_names: List[str], + device: Optional[str] = None, + ) -> BaseDeploymentPipeline: + """ + Create a YOLOX pipeline. + + Args: + model_spec: Model specification (backend/device/path) + pytorch_model: PyTorch model instance + num_classes: Number of classes + class_names: List of class names + device: Override device (uses model_spec.device if None) + + Returns: + YOLOX pipeline instance + """ + from deployment.pipelines.yolox import ( + YOLOXONNXPipeline, + YOLOXPyTorchPipeline, + YOLOXTensorRTPipeline, + ) + + device = device or model_spec.device + backend = model_spec.backend + + if backend is Backend.PYTORCH: + return YOLOXPyTorchPipeline( + pytorch_model=pytorch_model, + device=device, + num_classes=num_classes, + class_names=class_names, + ) + elif backend is Backend.ONNX: + return YOLOXONNXPipeline( + onnx_path=model_spec.path, + device=device, + num_classes=num_classes, + class_names=class_names, + ) + elif backend is Backend.TENSORRT: + return YOLOXTensorRTPipeline( + engine_path=model_spec.path, + device=device, + num_classes=num_classes, + class_names=class_names, + ) + else: + raise ValueError(f"Unsupported backend: {backend.value}") + + @staticmethod + def create_calibration_pipeline( + model_spec: ModelSpec, + pytorch_model: Any, + num_classes: int = 2, + class_names: Optional[List[str]] = None, + device: Optional[str] = None, + ) -> BaseDeploymentPipeline: + """ + Create a CalibrationStatusClassification pipeline. + + Args: + model_spec: Model specification (backend/device/path) + pytorch_model: PyTorch model instance + num_classes: Number of classes (default: 2) + class_names: List of class names (default: ["miscalibrated", "calibrated"]) + device: Override device (uses model_spec.device if None) + + Returns: + Calibration pipeline instance + """ + from deployment.pipelines.calibration import ( + CalibrationONNXPipeline, + CalibrationPyTorchPipeline, + CalibrationTensorRTPipeline, + ) + + device = device or model_spec.device + backend = model_spec.backend + class_names = class_names or ["miscalibrated", "calibrated"] + + if backend is Backend.PYTORCH: + return CalibrationPyTorchPipeline( + pytorch_model=pytorch_model, + device=device, + num_classes=num_classes, + class_names=class_names, + ) + elif backend is Backend.ONNX: + return CalibrationONNXPipeline( + onnx_path=model_spec.path, + device=device, + num_classes=num_classes, + class_names=class_names, + ) + elif backend is Backend.TENSORRT: + return CalibrationTensorRTPipeline( + engine_path=model_spec.path, + device=device, + num_classes=num_classes, + class_names=class_names, + ) + else: + raise ValueError(f"Unsupported backend: {backend.value}") diff --git a/deployment/runners/__init__.py b/deployment/runners/__init__.py index a4865f31d..d93dfa69c 100644 --- a/deployment/runners/__init__.py +++ b/deployment/runners/__init__.py @@ -1,14 +1,22 @@ """Deployment runners for unified deployment workflow.""" -# from deployment.runners.calibration_runner import CalibrationDeploymentRunner -# from deployment.runners.centerpoint_runner import CenterPointDeploymentRunner -from deployment.runners.deployment_runner import BaseDeploymentRunner - -# from deployment.runners.yolox_runner import YOLOXDeploymentRunner +from deployment.runners.common.artifact_manager import ArtifactManager +from deployment.runners.common.deployment_runner import BaseDeploymentRunner +from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator +from deployment.runners.common.verification_orchestrator import VerificationOrchestrator +from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner +from deployment.runners.projects.centerpoint_runner import CenterPointDeploymentRunner +from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner __all__ = [ + # Base runner "BaseDeploymentRunner", - # "CenterPointDeploymentRunner", - # "YOLOXDeploymentRunner", - # "CalibrationDeploymentRunner", + # Project-specific runners + "CenterPointDeploymentRunner", + "YOLOXOptElanDeploymentRunner", + "CalibrationDeploymentRunner", + # Helper components (orchestrators) + "ArtifactManager", + "VerificationOrchestrator", + "EvaluationOrchestrator", ] diff --git a/deployment/runners/common/__init__.py b/deployment/runners/common/__init__.py new file mode 100644 index 000000000..adbe3af59 --- /dev/null +++ b/deployment/runners/common/__init__.py @@ -0,0 +1,16 @@ +"""Core runner components for the deployment framework.""" + +from deployment.runners.common.artifact_manager import ArtifactManager +from deployment.runners.common.deployment_runner import BaseDeploymentRunner +from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator +from deployment.runners.common.export_orchestrator import ExportOrchestrator, ExportResult +from deployment.runners.common.verification_orchestrator import VerificationOrchestrator + +__all__ = [ + "ArtifactManager", + "BaseDeploymentRunner", + "EvaluationOrchestrator", + "ExportOrchestrator", + "ExportResult", + "VerificationOrchestrator", +] diff --git a/deployment/runners/common/artifact_manager.py b/deployment/runners/common/artifact_manager.py new file mode 100644 index 000000000..ecf11068e --- /dev/null +++ b/deployment/runners/common/artifact_manager.py @@ -0,0 +1,163 @@ +""" +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 +from typing import 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. + + This class centralizes all logic for: + - Registering artifacts after export + - Resolving artifact paths from configuration + - Validating artifact existence + - Looking up artifacts by backend + """ + + def __init__(self, config: BaseDeploymentConfig, logger: logging.Logger): + """ + Initialize artifact manager. + + Args: + config: Deployment configuration + logger: Logger instance + """ + self.config = config + self.logger = logger + self.artifacts: Dict[str, Artifact] = {} + + def register_artifact(self, backend: Backend, artifact: Artifact) -> None: + """ + Register an artifact for a backend. + + Args: + backend: Backend identifier + artifact: Artifact to register + """ + self.artifacts[backend.value] = artifact + self.logger.debug(f"Registered {backend.value} artifact: {artifact.path}") + + def get_artifact(self, backend: Backend) -> Optional[Artifact]: + """ + Get registered artifact for a backend. + + Args: + backend: Backend identifier + + Returns: + Artifact if found, None otherwise + """ + return self.artifacts.get(backend.value) + + def resolve_pytorch_artifact(self, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: + """ + Resolve PyTorch model path from registered artifacts or config. + + Resolution order: + 1. Registered artifacts (from previous export/load operations) + 2. checkpoint_path in config (single source of truth) + + Args: + backend_cfg: Backend configuration dictionary (unused for PyTorch path resolution) + + Returns: + Tuple of (artifact, is_valid). + artifact is an Artifact instance if a path could be resolved, otherwise None. + is_valid indicates whether the artifact exists on disk. + """ + # Check registered artifacts first + artifact = self.artifacts.get(Backend.PYTORCH.value) + if artifact: + return artifact, artifact.exists() + model_path = self.config.checkpoint_path + if not model_path: + return None, False + + artifact = Artifact(path=model_path, multi_file=False) + return artifact, artifact.exists() + + def resolve_onnx_artifact(self, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: + """ + Resolve ONNX model path from backend config or registered artifacts. + + Args: + backend_cfg: Backend configuration dictionary + + Returns: + Tuple of (artifact, is_valid). + artifact is an Artifact instance if a path could be resolved, otherwise None. + is_valid indicates whether the artifact exists on disk. + """ + # Check registered artifacts first + artifact = self.artifacts.get(Backend.ONNX.value) + if artifact: + return artifact, artifact.exists() + + # Fallback to explicit path from config + explicit_path = backend_cfg.get("model_dir") or self.config.export_config.onnx_path + if explicit_path: + is_dir = os.path.isdir(explicit_path) if os.path.exists(explicit_path) else False + fallback_artifact = Artifact(path=explicit_path, multi_file=is_dir) + return fallback_artifact, fallback_artifact.exists() + + return None, False + + def resolve_tensorrt_artifact(self, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: + """ + Resolve TensorRT model path from backend config or registered artifacts. + + Args: + backend_cfg: Backend configuration dictionary + + Returns: + Tuple of (artifact, is_valid). + artifact is an Artifact instance if a path could be resolved, otherwise None. + is_valid indicates whether the artifact exists on disk. + """ + # Check registered artifacts first + artifact = self.artifacts.get(Backend.TENSORRT.value) + if artifact: + return artifact, artifact.exists() + + # Fallback to explicit path from config + explicit_path = backend_cfg.get("engine_dir") or self.config.export_config.tensorrt_path + if explicit_path: + is_dir = os.path.isdir(explicit_path) if os.path.exists(explicit_path) else False + fallback_artifact = Artifact(path=explicit_path, multi_file=is_dir) + return fallback_artifact, fallback_artifact.exists() + + return None, False + + def resolve_artifact(self, backend: Backend, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: + """ + Resolve artifact for any backend. + + This is a convenience method that delegates to backend-specific resolvers. + + Args: + backend: Backend identifier + backend_cfg: Backend configuration dictionary + + Returns: + Tuple of (artifact, is_valid) + """ + if backend == Backend.PYTORCH: + return self.resolve_pytorch_artifact(backend_cfg) + elif backend == Backend.ONNX: + return self.resolve_onnx_artifact(backend_cfg) + elif backend == Backend.TENSORRT: + return self.resolve_tensorrt_artifact(backend_cfg) + else: + self.logger.warning(f"Unknown backend: {backend}") + return None, False diff --git a/deployment/runners/common/deployment_runner.py b/deployment/runners/common/deployment_runner.py new file mode 100644 index 000000000..3c7944ef8 --- /dev/null +++ b/deployment/runners/common/deployment_runner.py @@ -0,0 +1,218 @@ +""" +Unified deployment runner for common deployment workflows. + +This module provides a unified runner that handles the common deployment workflow +across different projects, while allowing project-specific customization. + +Architecture: + The runner orchestrates three specialized orchestrators: + - ExportOrchestrator: Handles PyTorch loading, ONNX export, TensorRT export + - VerificationOrchestrator: Handles output verification across backends + - EvaluationOrchestrator: Handles model evaluation with metrics + + This design keeps the runner thin (~150 lines vs original 850+) while + maintaining flexibility for project-specific customization. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional, Type, TypedDict + +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.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow +from deployment.runners.common.artifact_manager import ArtifactManager +from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator +from deployment.runners.common.export_orchestrator import ExportOrchestrator +from deployment.runners.common.verification_orchestrator import VerificationOrchestrator + + +class DeploymentResultDict(TypedDict, total=False): + """ + Standardized structure returned by `BaseDeploymentRunner.run()`. + + Keys: + pytorch_model: In-memory model instance loaded from the checkpoint (if requested). + onnx_path: Filesystem path to the exported ONNX artifact (single file or directory). + tensorrt_path: Filesystem path to the exported TensorRT engine. + verification_results: Arbitrary dictionary produced by `BaseEvaluator.verify()`. + evaluation_results: Arbitrary dictionary produced by `BaseEvaluator.evaluate()`. + """ + + pytorch_model: Optional[Any] + onnx_path: Optional[str] + tensorrt_path: Optional[str] + verification_results: Dict[str, Any] + evaluation_results: Dict[str, Any] + + +class BaseDeploymentRunner: + """ + Base deployment runner for common deployment workflows. + + This runner orchestrates three specialized components: + 1. ExportOrchestrator: Load PyTorch, export ONNX, export TensorRT + 2. VerificationOrchestrator: Verify outputs across backends + 3. EvaluationOrchestrator: Evaluate models with metrics + + Projects should extend this class and override methods as needed: + - Override load_pytorch_model() for project-specific model loading + - Provide project-specific ONNX/TensorRT workflows via constructor + """ + + def __init__( + self, + data_loader: BaseDataLoader, + evaluator: BaseEvaluator, + config: BaseDeploymentConfig, + model_cfg: Config, + logger: logging.Logger, + onnx_wrapper_cls: Optional[Type[BaseModelWrapper]] = None, + onnx_workflow: Optional[OnnxExportWorkflow] = None, + tensorrt_workflow: Optional[TensorRTExportWorkflow] = None, + ): + """ + Initialize base deployment runner. + + Args: + data_loader: Data loader for samples + evaluator: Evaluator for model evaluation + config: Deployment configuration + model_cfg: Model configuration + logger: Logger instance + onnx_wrapper_cls: Optional ONNX model wrapper class for exporter creation + onnx_workflow: Optional specialized ONNX workflow + tensorrt_workflow: Optional specialized TensorRT workflow + """ + self.data_loader = data_loader + self.evaluator = evaluator + self.config = config + self.model_cfg = model_cfg + self.logger = logger + + # Store workflow references for subclasses to modify + self._onnx_wrapper_cls = onnx_wrapper_cls + self._onnx_workflow = onnx_workflow + self._tensorrt_workflow = tensorrt_workflow + + # Initialize artifact manager (shared across orchestrators) + self.artifact_manager = ArtifactManager(config, logger) + + # Initialize orchestrators (export orchestrator created lazily to allow subclass workflow setup) + 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: + """ + Get export orchestrator (created lazily to allow subclass workflow setup). + + This allows subclasses to set _onnx_workflow and _tensorrt_workflow in __init__ + before the export orchestrator is created. + """ + 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_workflow=self._onnx_workflow, + tensorrt_workflow=self._tensorrt_workflow, + ) + return self._export_orchestrator + + def load_pytorch_model(self, checkpoint_path: str, context: ExportContext) -> Any: + """ + Load PyTorch model from checkpoint. + + Subclasses must implement this method to provide project-specific model loading logic. + Project-specific parameters should be accessed from the typed context object. + + Args: + checkpoint_path: Path to checkpoint file + context: Export context containing project-specific parameters. + Use project-specific context subclasses (e.g., YOLOXExportContext, + CenterPointExportContext) for type-safe access to parameters. + + Returns: + Loaded PyTorch model + + Raises: + NotImplementedError: If not implemented by subclass + + Example: + # In YOLOXDeploymentRunner: + def load_pytorch_model(self, checkpoint_path: str, context: ExportContext) -> Any: + # Type narrow to access YOLOX-specific fields + if isinstance(context, YOLOXExportContext): + model_cfg_path = context.model_cfg_path + else: + model_cfg_path = context.get("model_cfg_path") + ... + """ + raise NotImplementedError(f"{self.__class__.__name__}.load_pytorch_model() must be implemented by subclasses.") + + def run( + self, + context: Optional[ExportContext] = None, + ) -> DeploymentResultDict: + """ + Execute the complete deployment workflow. + + The workflow consists of three phases: + 1. Export: Load PyTorch model, export to ONNX/TensorRT + 2. Verification: Verify outputs across backends + 3. Evaluation: Evaluate models with metrics + + Args: + context: Typed export context with parameters. If None, a default + ExportContext is created. + + Returns: + DeploymentResultDict: Structured summary of all deployment artifacts and reports. + """ + # Create default context if not provided + if context is None: + context = ExportContext() + + results: DeploymentResultDict = { + "pytorch_model": None, + "onnx_path": None, + "tensorrt_path": None, + "verification_results": {}, + "evaluation_results": {}, + } + + # Phase 1: Export + 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 + + # Phase 2: Verification + checkpoint_path = self.config.checkpoint_path + verification_results = self.verification_orchestrator.run( + artifact_manager=self.artifact_manager, + pytorch_checkpoint=checkpoint_path, + onnx_path=results["onnx_path"], + tensorrt_path=results["tensorrt_path"], + ) + results["verification_results"] = verification_results + + # Phase 3: Evaluation + evaluation_results = self.evaluation_orchestrator.run(self.artifact_manager) + results["evaluation_results"] = evaluation_results + + self.logger.info("\n" + "=" * 80) + self.logger.info("Deployment Complete!") + self.logger.info("=" * 80) + + return results diff --git a/deployment/runners/common/evaluation_orchestrator.py b/deployment/runners/common/evaluation_orchestrator.py new file mode 100644 index 000000000..0e9fa2359 --- /dev/null +++ b/deployment/runners/common/evaluation_orchestrator.py @@ -0,0 +1,215 @@ +""" +Evaluation orchestration for deployment workflows. + +This module handles cross-backend evaluation with consistent metrics. +""" + +import logging +from typing import Any, Dict, List + +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.runners.common.artifact_manager import ArtifactManager + + +class EvaluationOrchestrator: + """ + Orchestrates evaluation across backends with consistent metrics. + + This class handles: + - Resolving models to evaluate from configuration + - Running evaluation for each enabled backend + - Collecting and formatting evaluation results + - Logging evaluation progress and results + - Cross-backend metric comparison + """ + + def __init__( + self, + config: BaseDeploymentConfig, + evaluator: BaseEvaluator, + data_loader: BaseDataLoader, + logger: logging.Logger, + ): + """ + Initialize evaluation orchestrator. + + Args: + config: Deployment configuration + evaluator: Evaluator instance for running evaluation + data_loader: Data loader for loading samples + logger: Logger instance + """ + self.config = config + self.evaluator = evaluator + self.data_loader = data_loader + self.logger = logger + + def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: + """ + Run evaluation on specified models. + + Args: + artifact_manager: Artifact manager for resolving model paths + + Returns: + Dictionary containing evaluation results for all backends + """ + 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) + + # Get models to evaluate + models_to_evaluate = self._get_models_to_evaluate(artifact_manager) + + if not models_to_evaluate: + self.logger.warning("No models found for evaluation") + return {} + + # Determine number of samples + num_samples = eval_config.num_samples + if num_samples == -1: + num_samples = self.data_loader.get_num_samples() + + verbose_mode = eval_config.verbose + + # Run evaluation for each model + 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: + # Ensure CUDA memory is cleaned up between model evaluations + from deployment.pipelines.common.gpu_resource_mixin import clear_cuda_memory + + clear_cuda_memory() + + # Print cross-backend comparison if multiple backends + 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]: + """ + Get list of models to evaluate from config. + + Args: + artifact_manager: Artifact manager for resolving paths + + Returns: + List of ModelSpec instances describing models to evaluate + """ + 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") + + # Use artifact_manager to resolve artifact + artifact, is_valid = artifact_manager.resolve_artifact(backend_enum, backend_cfg) + + 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: + """ + Normalize device string for specific backend. + + Args: + backend: Backend identifier + device: Device string from config + + Returns: + Normalized device string + """ + normalized_device = str(device 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.export_config.cuda_device 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 _print_cross_backend_comparison(self, all_results: Dict[str, Any]) -> None: + """ + Print cross-backend comparison of metrics. + + Args: + all_results: Dictionary of results by backend + """ + 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: + # Print primary metrics + 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}") + + # Print latency stats + 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/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py new file mode 100644 index 000000000..cc58e7993 --- /dev/null +++ b/deployment/runners/common/export_orchestrator.py @@ -0,0 +1,518 @@ +""" +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, Dict, 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.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow +from deployment.runners.common.artifact_manager import ArtifactManager + + +@dataclass +class ExportResult: + """ + Result of the export orchestration. + + Attributes: + pytorch_model: Loaded PyTorch model (if loaded) + onnx_path: Path to exported ONNX artifact + tensorrt_path: Path to exported TensorRT engine + """ + + 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). + + This class centralizes all export-related logic: + - Determining when PyTorch model is needed + - Loading PyTorch model via injected loader + - ONNX export (via workflow or standard exporter) + - TensorRT export (via workflow or standard exporter) + - Artifact registration + + By extracting this logic from the runner, the runner becomes a thin + orchestrator that coordinates Export, Verification, and Evaluation. + """ + + # Directory name constants + 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_workflow: Optional[OnnxExportWorkflow] = None, + tensorrt_workflow: Optional[TensorRTExportWorkflow] = None, + ): + """ + Initialize export orchestrator. + + Args: + config: Deployment configuration + data_loader: Data loader for samples + artifact_manager: Artifact manager for registration + logger: Logger instance + model_loader: Callable to load PyTorch model (checkpoint_path, **kwargs) -> model + evaluator: Evaluator instance (for model injection) + onnx_wrapper_cls: Optional ONNX model wrapper class + onnx_workflow: Optional specialized ONNX workflow + tensorrt_workflow: Optional specialized TensorRT workflow + """ + 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_workflow = onnx_workflow + self._tensorrt_workflow = tensorrt_workflow + + # Lazy-initialized exporters + self._onnx_exporter: Optional[ONNXExporter] = None + self._tensorrt_exporter: Optional[TensorRTExporter] = None + + def run( + self, + context: Optional[ExportContext] = None, + ) -> ExportResult: + """ + Execute the complete export workflow. + + This method: + 1. Determines if PyTorch model is needed + 2. Loads PyTorch model if needed + 3. Exports to ONNX if configured + 4. Exports to TensorRT if configured + 5. Resolves external artifact paths + + Args: + context: Typed export context with parameters. If None, a default + ExportContext is created. + + Returns: + ExportResult containing model and artifact paths + """ + # Create default context if not provided + 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 + + # Step 1: Determine if PyTorch model is needed + requires_pytorch = self._determine_pytorch_requirements() + + # Step 2: Load PyTorch model if needed + pytorch_model = None + if requires_pytorch: + pytorch_model = self._load_and_register_pytorch_model(checkpoint_path, context) + if pytorch_model is None: + return result # Loading failed + result.pytorch_model = pytorch_model + + # Step 3: Export ONNX if requested + if should_export_onnx: + # Load model if not already loaded + if pytorch_model is None: + if not checkpoint_path: + self.logger.error("ONNX export requires checkpoint_path but none was provided.") + return result + pytorch_model = self._load_and_register_pytorch_model(checkpoint_path, context) + if pytorch_model is None: + return result + result.pytorch_model = pytorch_model + + onnx_artifact = self._export_onnx(pytorch_model, context) + if onnx_artifact: + result.onnx_path = onnx_artifact.path + + # Step 4: Export TensorRT if requested + if should_export_trt: + onnx_path = result.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 result + + # Ensure ONNX artifact is registered + result.onnx_path = onnx_path + if onnx_path and os.path.exists(onnx_path): + multi_file = os.path.isdir(onnx_path) + self.artifact_manager.register_artifact(Backend.ONNX, Artifact(path=onnx_path, multi_file=multi_file)) + + trt_artifact = self._export_tensorrt(onnx_path, context) + if trt_artifact: + result.tensorrt_path = trt_artifact.path + + # Step 5: Resolve external paths from evaluation config + self._resolve_external_artifacts(result) + + return result + + def _determine_pytorch_requirements(self) -> bool: + """ + Determine if PyTorch model is required based on configuration. + + Returns: + True if PyTorch model is needed, False otherwise + """ + should_export_onnx = self.config.export_config.should_export_onnx() + eval_config = self.config.evaluation_config + verification_cfg = self.config.verification_config + + # Check if PyTorch evaluation is needed + needs_pytorch_eval = False + 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): + needs_pytorch_eval = True + + # Check if PyTorch is needed for verification + needs_pytorch_for_verification = False + if verification_cfg.enabled: + export_mode = self.config.export_config.mode + scenarios = self.config.get_verification_scenarios(export_mode) + if scenarios: + needs_pytorch_for_verification = any( + policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH + for policy in scenarios + ) + + return should_export_onnx or needs_pytorch_eval or needs_pytorch_for_verification + + def _load_and_register_pytorch_model( + self, + checkpoint_path: str, + context: ExportContext, + ) -> Optional[Any]: + """ + Load PyTorch model and register it with artifact manager. + + Args: + checkpoint_path: Path to checkpoint file + context: Export context with project-specific parameters + + Returns: + Loaded PyTorch model, or None if loading failed + """ + 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)) + + # Inject model to evaluator via setter + 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 _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[Artifact]: + """ + Export model to ONNX format. + + Uses either a specialized workflow or the standard ONNX exporter. + + Args: + pytorch_model: PyTorch model to export + context: Export context with project-specific parameters + + Returns: + Artifact describing the exported ONNX output, or None if skipped + """ + if not self.config.export_config.should_export_onnx(): + return None + + if self._onnx_workflow is None and self._onnx_wrapper_cls is None: + raise RuntimeError("ONNX export requested but no wrapper class or workflow provided.") + + onnx_settings = self.config.get_onnx_settings() + # Use context.sample_idx, fallback to runtime config for backward compatibility + sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.get("sample_idx", 0) + + # Save to work_dir/onnx/ directory + 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) + + # Use workflow if available + if self._onnx_workflow is not None: + self.logger.info("=" * 80) + self.logger.info(f"Exporting to ONNX via workflow ({type(self._onnx_workflow).__name__})") + self.logger.info("=" * 80) + try: + artifact = self._onnx_workflow.export( + model=pytorch_model, + data_loader=self.data_loader, + output_dir=onnx_dir, + config=self.config, + sample_idx=sample_idx, + context=context, + ) + except Exception: + self.logger.error("ONNX export workflow failed") + raise + + self.artifact_manager.register_artifact(Backend.ONNX, artifact) + self.logger.info(f"ONNX export successful: {artifact.path}") + return artifact + + # Use standard exporter + exporter = self._get_onnx_exporter() + self.logger.info("=" * 80) + self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") + self.logger.info("=" * 80) + + # Get sample input + sample = self.data_loader.load_sample(sample_idx) + single_input = self.data_loader.preprocess(sample) + + # Get batch size from configuration + batch_size = onnx_settings.batch_size + if batch_size is None: + input_tensor = single_input + self.logger.info("Using dynamic batch size") + else: + # Handle different input shapes + 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}") + + try: + exporter.export(pytorch_model, input_tensor, output_path) + except Exception: + self.logger.error("ONNX export failed") + raise + + 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]: + """ + Export ONNX model to TensorRT engine. + + Uses either a specialized workflow or the standard TensorRT exporter. + + Args: + onnx_path: Path to ONNX model file/directory + context: Export context with project-specific parameters + + Returns: + Artifact describing the exported TensorRT output, or None if skipped + """ + 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_workflow else type(self._get_tensorrt_exporter()).__name__ + self.logger.info("=" * 80) + if self._tensorrt_workflow: + self.logger.info(f"Exporting to TensorRT via workflow ({type(self._tensorrt_workflow).__name__})") + else: + self.logger.info(f"Exporting to TensorRT (Using {exporter_label})") + self.logger.info("=" * 80) + + # Save to work_dir/tensorrt/ directory + tensorrt_dir = os.path.join(self.config.export_config.work_dir, self.TENSORRT_DIR_NAME) + os.makedirs(tensorrt_dir, exist_ok=True) + + # Determine output path based on ONNX file name + output_path = self._get_tensorrt_output_path(onnx_path, tensorrt_dir) + + # Set CUDA device for TensorRT export + cuda_device = self.config.export_config.cuda_device + device_id = self.config.export_config.get_cuda_device_index() + torch.cuda.set_device(device_id) + self.logger.info(f"Using CUDA device for TensorRT export: {cuda_device}") + + # Get sample input for shape configuration + sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.get("sample_idx", 0) + sample_input = self.data_loader.get_shape_sample(sample_idx) + + # Use workflow if available + if self._tensorrt_workflow is not None: + try: + artifact = self._tensorrt_workflow.export( + onnx_path=onnx_path, + output_dir=tensorrt_dir, + config=self.config, + device=cuda_device, + data_loader=self.data_loader, + context=context, + ) + except Exception: + self.logger.error("TensorRT export workflow failed") + raise + + self.artifact_manager.register_artifact(Backend.TENSORRT, artifact) + self.logger.info(f"TensorRT export successful: {artifact.path}") + return artifact + + # Use standard exporter + exporter = self._get_tensorrt_exporter() + + try: + artifact = exporter.export( + model=None, + sample_input=sample_input, + output_path=output_path, + onnx_path=onnx_path, + ) + except Exception: + self.logger.error("TensorRT export failed") + raise + + 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: + """Lazily instantiate and return the ONNX exporter.""" + 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: + """Lazily instantiate and return the TensorRT exporter.""" + 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: + """ + Determine TensorRT output path based on ONNX file name. + + Args: + onnx_path: Path to ONNX model file or directory + tensorrt_dir: Directory for TensorRT engines + + Returns: + Path for TensorRT engine output + """ + if os.path.isdir(onnx_path): + return os.path.join(tensorrt_dir, self.DEFAULT_ENGINE_FILENAME) + else: + 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: + """ + Resolve artifact paths from evaluation config and register them. + + Args: + result: Export result to update with resolved paths + """ + # Resolve ONNX if not already set + if not result.onnx_path: + self._resolve_and_register_artifact(Backend.ONNX, result, "onnx_path") + + # Resolve TensorRT if not already set + 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: + """ + Resolve artifact path from evaluation config and register it. + + Args: + backend: Backend type (ONNX or TENSORRT) + result: Export result to update + attr_name: Attribute name on result ("onnx_path" or "tensorrt_path") + """ + 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[Dict[Any, Any]], backend: Backend) -> Any: + """ + Fetch a config value that may be keyed by either string literals or Backend enums. + """ + if not mapping: + return None + + if backend.value in mapping: + return mapping[backend.value] + + return mapping.get(backend) diff --git a/deployment/runners/common/verification_orchestrator.py b/deployment/runners/common/verification_orchestrator.py new file mode 100644 index 000000000..6bb4489fd --- /dev/null +++ b/deployment/runners/common/verification_orchestrator.py @@ -0,0 +1,234 @@ +""" +Verification orchestration for deployment workflows. + +This module handles scenario-based verification across different backends. +""" + +import logging +from typing import Any, Dict + +from deployment.core.artifacts import Artifact +from deployment.core.backend import Backend +from deployment.core.config.base_config import BaseDeploymentConfig, ExportMode +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.runners.common.artifact_manager import ArtifactManager + + +class VerificationOrchestrator: + """ + Orchestrates verification across backends using scenario-based verification. + + This class handles: + - Running verification scenarios from config + - Resolving model paths for each scenario + - Collecting and aggregating verification results + - Logging verification progress and results + """ + + def __init__( + self, + config: BaseDeploymentConfig, + evaluator: BaseEvaluator, + data_loader: BaseDataLoader, + logger: logging.Logger, + ): + """ + Initialize verification orchestrator. + + Args: + config: Deployment configuration + evaluator: Evaluator instance for running verification + data_loader: Data loader for loading samples + logger: Logger instance + """ + self.config = config + self.evaluator = evaluator + self.data_loader = data_loader + self.logger = logger + + def run( + self, + artifact_manager: ArtifactManager, + pytorch_checkpoint: str = None, + onnx_path: str = None, + tensorrt_path: str = None, + ) -> Dict[str, Any]: + """ + Run verification on exported models using policy-based verification. + + Args: + artifact_manager: Artifact manager for resolving model paths + pytorch_checkpoint: Path to PyTorch checkpoint (optional) + onnx_path: Path to ONNX model file/directory (optional) + tensorrt_path: Path to TensorRT engine file/directory (optional) + + Returns: + Verification results dictionary + """ + verification_cfg = self.config.verification_config + + # Check master switch + 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 {} + + # Check if PyTorch checkpoint is needed + needs_pytorch = any( + policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios + ) + + if needs_pytorch and not pytorch_checkpoint: + self.logger.warning( + "PyTorch checkpoint path not available, but required by verification scenarios. " + "Skipping verification." + ) + return {} + + num_verify_samples = verification_cfg.num_verify_samples + tolerance = verification_cfg.tolerance + devices_map = verification_cfg.devices or {} + + self.logger.info("=" * 80) + self.logger.info(f"Running Verification (mode: {export_mode.value})") + self.logger.info("=" * 80) + + all_results = {} + total_passed = 0 + total_failed = 0 + + for i, policy in enumerate(scenarios): + # Resolve devices using alias system + 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})" + ) + + # Resolve model paths based on backend + ref_path = self._resolve_backend_path(policy.ref_backend, pytorch_checkpoint, onnx_path, tensorrt_path) + test_path = self._resolve_backend_path(policy.test_backend, pytorch_checkpoint, onnx_path, tensorrt_path) + + if not ref_path or not test_path: + self.logger.warning(f" Skipping: missing paths (ref={ref_path}, test={test_path})") + continue + + # Create artifacts and model specs + ref_artifact = self._create_artifact(policy.ref_backend, ref_path) + test_artifact = self._create_artifact(policy.test_backend, test_path) + + 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) + + # Run verification + 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, + ) + + # Store results + policy_key = f"{policy.ref_backend.value}_{ref_device}_vs_{policy.test_backend.value}_{test_device}" + all_results[policy_key] = verification_results + + # Update counters + 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)") + + # Overall summary + 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: Dict[str, str]) -> str: + """ + Resolve device using alias system. + + Args: + device_key: Device key from scenario + devices_map: Device alias mapping + + Returns: + Actual device string + """ + if device_key in devices_map: + return devices_map[device_key] + else: + # Fallback: use the key directly + self.logger.warning(f"Device alias '{device_key}' not found in devices map, using as-is") + return device_key + + def _resolve_backend_path( + self, backend: Backend, pytorch_checkpoint: str, onnx_path: str, tensorrt_path: str + ) -> str: + """ + Resolve model path for a backend. + + Args: + backend: Backend identifier + pytorch_checkpoint: PyTorch checkpoint path + onnx_path: ONNX model path + tensorrt_path: TensorRT engine path + + Returns: + Model path for the backend, or None if not available + """ + if backend == Backend.PYTORCH: + return pytorch_checkpoint + elif backend == Backend.ONNX: + return onnx_path + elif backend == Backend.TENSORRT: + return tensorrt_path + else: + self.logger.warning(f"Unknown backend: {backend}") + return None + + def _create_artifact(self, backend: Backend, path: str) -> Artifact: + """ + Create artifact from path. + + Args: + backend: Backend identifier + path: Model path + + Returns: + Artifact instance + """ + import os + + multi_file = os.path.isdir(path) if path and os.path.exists(path) else False + return Artifact(path=path, multi_file=multi_file) diff --git a/deployment/runners/deployment_runner.py b/deployment/runners/deployment_runner.py deleted file mode 100644 index 20af15344..000000000 --- a/deployment/runners/deployment_runner.py +++ /dev/null @@ -1,859 +0,0 @@ -""" -Unified deployment runner for common deployment workflows. - -This module provides a unified runner that handles the common deployment workflow -across different projects, while allowing project-specific customization. -""" - -import logging -import os -from typing import Any, Dict, List, Optional, Tuple, Type, TypedDict, Union - -import torch -from mmengine.config import Config - -from deployment.core import Artifact, Backend, BaseDataLoader, BaseDeploymentConfig, BaseEvaluator, ModelSpec -from deployment.exporters.base.factory import ExporterFactory -from deployment.exporters.base.model_wrappers import BaseModelWrapper -from deployment.exporters.base.onnx_exporter import ONNXExporter -from deployment.exporters.base.tensorrt_exporter import TensorRTExporter -from deployment.exporters.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow - - -class DeploymentResultDict(TypedDict, total=False): - """ - Standardized structure returned by `BaseDeploymentRunner.run()`. - - Keys: - pytorch_model: In-memory model instance loaded from the checkpoint (if requested). - onnx_path: Filesystem path to the exported ONNX artifact (single file or directory). - tensorrt_path: Filesystem path to the exported TensorRT engine. - verification_results: Arbitrary dictionary produced by `BaseEvaluator.verify()`. - evaluation_results: Arbitrary dictionary produced by `BaseEvaluator.evaluate()`. - """ - - pytorch_model: Optional[Any] - onnx_path: Optional[str] - tensorrt_path: Optional[str] - verification_results: Dict[str, Any] - evaluation_results: Dict[str, Any] - - -class BaseDeploymentRunner: - """ - Base deployment runner for common deployment workflows. - - This runner handles the standard deployment workflow: - 1. Load PyTorch model (if needed) - 2. Export to ONNX (if requested) - 3. Export to TensorRT (if requested) - 4. Verify outputs (if enabled) - 5. Evaluate models (if enabled) - - Projects should extend this class and override methods as needed: - - Override export_onnx() for project-specific ONNX export logic - - Override export_tensorrt() for project-specific TensorRT export logic - - Override load_pytorch_model() for project-specific model loading - """ - - def __init__( - self, - data_loader: BaseDataLoader, - evaluator: BaseEvaluator, - config: BaseDeploymentConfig, - model_cfg: Config, - logger: logging.Logger, - onnx_wrapper_cls: Optional[Type[BaseModelWrapper]] = None, - onnx_workflow: Optional[OnnxExportWorkflow] = None, - tensorrt_workflow: Optional[TensorRTExportWorkflow] = None, - ): - """ - Initialize base deployment runner. - - Args: - data_loader: Data loader for samples - evaluator: Evaluator for model evaluation - config: Deployment configuration - model_cfg: Model configuration - logger: Logger instance - onnx_wrapper_cls: Optional ONNX model wrapper class for exporter creation - onnx_workflow: Optional specialized ONNX workflow - tensorrt_workflow: Optional specialized TensorRT workflow - """ - 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_exporter: Optional[ONNXExporter] = None - self._tensorrt_exporter: Optional[TensorRTExporter] = None - self._onnx_workflow = onnx_workflow - self._tensorrt_workflow = tensorrt_workflow - self.artifacts: Dict[str, Artifact] = {} - - def _get_onnx_exporter(self) -> ONNXExporter: - """ - Lazily instantiate and return the ONNX exporter. - """ - - 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: - """ - Lazily instantiate and return the TensorRT exporter. - """ - - if self._tensorrt_exporter is None: - self._tensorrt_exporter = ExporterFactory.create_tensorrt_exporter( - config=self.config, - logger=self.logger, - ) - return self._tensorrt_exporter - - @staticmethod - def _get_backend_entry(mapping: Optional[Dict[Any, Any]], backend: Backend) -> Any: - """ - Fetch a config value that may be keyed by either string literals or Backend enums. - """ - if not mapping: - return None - - if backend.value in mapping: - return mapping[backend.value] - - return mapping.get(backend) - - def load_pytorch_model(self, checkpoint_path: str, **kwargs) -> Any: - """ - Load PyTorch model from checkpoint. - - Subclasses must implement this method to provide project-specific model loading logic. - - Args: - checkpoint_path: Path to checkpoint file - **kwargs: Additional project-specific arguments - - Returns: - Loaded PyTorch model - - Raises: - NotImplementedError: If not implemented by subclass - """ - raise NotImplementedError(f"{self.__class__.__name__}.load_pytorch_model() must be implemented by subclasses.") - - def export_onnx(self, pytorch_model: Any, **kwargs) -> Optional[Artifact]: - """ - Export model to ONNX format. - - Uses either a specialized workflow or the standard ONNX exporter. - - Args: - pytorch_model: PyTorch model to export - **kwargs: Additional project-specific arguments - - Returns: - Artifact describing the exported ONNX output, or None if skipped - """ - if not self.config.export_config.should_export_onnx(): - return None - - if self._onnx_workflow is None and self._onnx_wrapper_cls is None: - raise RuntimeError("ONNX export requested but no wrapper class or workflow provided.") - - onnx_settings = self.config.get_onnx_settings() - sample_idx = self.config.runtime_config.get("sample_idx", 0) - - # Save to work_dir/onnx/ directory - onnx_dir = os.path.join(self.config.export_config.work_dir, "onnx") - os.makedirs(onnx_dir, exist_ok=True) - output_path = os.path.join(onnx_dir, onnx_settings.save_file) - - if self._onnx_workflow is not None: - self.logger.info("=" * 80) - self.logger.info(f"Exporting to ONNX via workflow ({type(self._onnx_workflow).__name__})") - self.logger.info("=" * 80) - try: - artifact = self._onnx_workflow.export( - model=pytorch_model, - data_loader=self.data_loader, - output_dir=onnx_dir, - config=self.config, - sample_idx=sample_idx, - **kwargs, - ) - except Exception: - self.logger.error("ONNX export workflow failed") - raise - - self.artifacts[Backend.ONNX.value] = 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) - - # Get sample input - sample = self.data_loader.load_sample(sample_idx) - single_input = self.data_loader.preprocess(sample) - - # Get batch size from configuration - batch_size = onnx_settings.batch_size - if batch_size is None: - input_tensor = single_input - self.logger.info("Using dynamic batch size") - else: - # Handle different input shapes - if isinstance(single_input, (list, tuple)): - # Multiple inputs - 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: - # Single input - input_tensor = single_input.repeat(batch_size, *([1] * (len(single_input.shape) - 1))) - self.logger.info(f"Using fixed batch size: {batch_size}") - - try: - exporter.export(pytorch_model, input_tensor, output_path) - except Exception: - self.logger.error("ONNX export failed") - raise - - 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.artifacts[Backend.ONNX.value] = artifact - self.logger.info(f"ONNX export successful: {artifact.path}") - return artifact - - def export_tensorrt(self, onnx_path: str, **kwargs) -> Optional[Artifact]: - """ - Export ONNX model to TensorRT engine. - - Uses either a specialized workflow or the standard TensorRT exporter. - - Args: - onnx_path: Path to ONNX model file/directory - **kwargs: Additional project-specific arguments - - Returns: - Artifact describing the exported TensorRT output, or None if skipped - """ - 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_workflow else type(self._get_tensorrt_exporter()).__name__ - self.logger.info("=" * 80) - if self._tensorrt_workflow: - self.logger.info(f"Exporting to TensorRT via workflow ({type(self._tensorrt_workflow).__name__})") - else: - self.logger.info(f"Exporting to TensorRT (Using {exporter_label})") - self.logger.info("=" * 80) - - # Save to work_dir/tensorrt/ directory - tensorrt_dir = os.path.join(self.config.export_config.work_dir, "tensorrt") - os.makedirs(tensorrt_dir, exist_ok=True) - - # Determine output path based on ONNX file name - if os.path.isdir(onnx_path): - output_path = os.path.join(tensorrt_dir, "model.engine") - else: - onnx_filename = os.path.basename(onnx_path) - engine_filename = onnx_filename.replace(".onnx", ".engine") - output_path = os.path.join(tensorrt_dir, engine_filename) - - # Set CUDA device for TensorRT export - cuda_device = self.config.export_config.cuda_device - device_id = self.config.export_config.get_cuda_device_index() - torch.cuda.set_device(device_id) - self.logger.info(f"Using CUDA device for TensorRT export: {cuda_device}") - - # Get sample input for shape configuration - sample_idx = self.config.runtime_config.get("sample_idx", 0) - sample = self.data_loader.load_sample(sample_idx) - sample_input = self.data_loader.preprocess(sample) - - if isinstance(sample_input, (list, tuple)): - sample_input = sample_input[0] # Use first input for shape - - if self._tensorrt_workflow is not None: - try: - artifact = self._tensorrt_workflow.export( - onnx_path=onnx_path, - output_dir=tensorrt_dir, - config=self.config, - device=cuda_device, - data_loader=self.data_loader, - **kwargs, - ) - except Exception: - self.logger.error("TensorRT export workflow failed") - raise - - self.artifacts[Backend.TENSORRT.value] = artifact - self.logger.info(f"TensorRT export successful: {artifact.path}") - return artifact - - exporter = self._get_tensorrt_exporter() - - try: - artifact = exporter.export( - model=None, - sample_input=sample_input, - output_path=output_path, - onnx_path=onnx_path, - ) - except Exception: - self.logger.error("TensorRT export failed") - raise - - self.artifacts[Backend.TENSORRT.value] = artifact - self.logger.info(f"TensorRT export successful: {artifact.path}") - return artifact - - def _resolve_pytorch_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[Artifact], bool]: - """ - Resolve PyTorch model path from backend config. - - Args: - backend_cfg: Backend configuration dictionary - - Returns: - Tuple of (artifact, is_valid). - artifact is an `Artifact` instance if a path could be resolved, otherwise None. - """ - artifact = self.artifacts.get(Backend.PYTORCH.value) - if artifact: - return artifact, artifact.exists() - - model_path = backend_cfg.get("checkpoint") or self.config.export_config.checkpoint_path - if not model_path: - return None, False - - artifact = Artifact(path=model_path, multi_file=False) - return artifact, artifact.exists() - - def _artifact_from_path(self, backend: Union[str, Backend], path: str) -> Artifact: - backend_enum = Backend.from_value(backend) - existing = self.artifacts.get(backend_enum.value) - if existing and existing.path == path: - return existing - - multi_file = os.path.isdir(path) if path and os.path.exists(path) else False - return Artifact(path=path, multi_file=multi_file) - - def _build_model_spec(self, backend: Union[str, Backend], artifact: Artifact, device: str) -> ModelSpec: - backend_enum = Backend.from_value(backend) - return ModelSpec( - backend=backend_enum, - device=device, - artifact=artifact, - ) - - def _normalize_device_for_backend(self, backend: Union[str, Backend], device: Optional[str]) -> str: - backend_enum = Backend.from_value(backend) - normalized_device = str(device or "cpu") - - if backend_enum 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_enum.value}'. Falling back to CPU." - ) - normalized_device = "cpu" - elif backend_enum is Backend.TENSORRT: - if not normalized_device or normalized_device == "cpu": - normalized_device = self.config.export_config.cuda_device or "cuda:0" - if not normalized_device.startswith("cuda"): - self.logger.warning( - "TensorRT evaluation requires CUDA device. Overriding device " - f"from '{normalized_device}' to 'cuda:0'." - ) - normalized_device = "cuda:0" - - return normalized_device - - def _resolve_onnx_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[Artifact], bool]: - """ - Resolve ONNX model path from backend config. - - Args: - backend_cfg: Backend configuration dictionary - - Returns: - Tuple of (artifact, is_valid). - artifact is an `Artifact` instance if a path could be resolved, otherwise None. - """ - artifact = self.artifacts.get(Backend.ONNX.value) - if artifact: - return artifact, artifact.exists() - - explicit_path = backend_cfg.get("model_dir") or self.config.export_config.onnx_path - if explicit_path: - fallback_artifact = Artifact(path=explicit_path, multi_file=os.path.isdir(explicit_path)) - return fallback_artifact, fallback_artifact.exists() - - return None, False - - def _resolve_tensorrt_artifact(self, backend_cfg: Dict[str, Any]) -> Tuple[Optional[Artifact], bool]: - """ - Resolve TensorRT model path from backend config. - - Args: - backend_cfg: Backend configuration dictionary - - Returns: - Tuple of (artifact, is_valid). - artifact is an `Artifact` instance if a path could be resolved, otherwise None. - """ - artifact = self.artifacts.get(Backend.TENSORRT.value) - if artifact: - return artifact, artifact.exists() - - explicit_path = backend_cfg.get("engine_dir") or self.config.export_config.tensorrt_path - if explicit_path: - fallback_artifact = Artifact(path=explicit_path, multi_file=os.path.isdir(explicit_path)) - return fallback_artifact, fallback_artifact.exists() - - return None, False - - def get_models_to_evaluate(self) -> List[ModelSpec]: - """ - Get list of models to evaluate from config. - - Returns: - List of `ModelSpec` instances describing models to evaluate. - """ - 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: Optional[Artifact] = None - is_valid = False - - if backend_enum is Backend.PYTORCH: - artifact, is_valid = self._resolve_pytorch_artifact(backend_cfg) - elif backend_enum is Backend.ONNX: - artifact, is_valid = self._resolve_onnx_artifact(backend_cfg) - elif backend_enum is Backend.TENSORRT: - artifact, is_valid = self._resolve_tensorrt_artifact(backend_cfg) - - if is_valid and artifact: - spec = self._build_model_spec(backend_enum, artifact, device) - 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 run_verification( - self, pytorch_checkpoint: Optional[str], onnx_path: Optional[str], tensorrt_path: Optional[str], **kwargs - ) -> Dict[str, Any]: - """ - Run verification on exported models using policy-based verification. - - Args: - pytorch_checkpoint: Path to PyTorch checkpoint (reference) - onnx_path: Path to ONNX model file/directory - tensorrt_path: Path to TensorRT engine file/directory - **kwargs: Additional project-specific arguments - - Returns: - Verification results dictionary - """ - verification_cfg = self.config.verification_config - - # Check master switches - 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 {} - - # Check if any scenario actually needs PyTorch checkpoint - needs_pytorch = any( - policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios - ) - - if needs_pytorch and not pytorch_checkpoint: - self.logger.warning( - "PyTorch checkpoint path not available, but required by verification scenarios. Skipping verification." - ) - return {} - - num_verify_samples = verification_cfg.num_verify_samples - tolerance = verification_cfg.tolerance - devices_map = verification_cfg.devices or {} - - self.logger.info("=" * 80) - self.logger.info(f"Running Verification (mode: {export_mode.value})") - self.logger.info("=" * 80) - - all_results = {} - total_passed = 0 - total_failed = 0 - - for i, policy in enumerate(scenarios): - ref_backend = policy.ref_backend - # Resolve device using alias system: - # - Scenarios use aliases (e.g., "cpu", "cuda") for flexibility - # - Actual device strings are defined in verification["devices"] - # - This allows easy device switching: change devices["cpu"] to affect all CPU verifications - ref_device_key = policy.ref_device - if ref_device_key in devices_map: - ref_device = devices_map[ref_device_key] - else: - # Fallback: use the key directly if not found in devices_map (backward compatibility) - ref_device = ref_device_key - self.logger.warning(f"Device alias '{ref_device_key}' not found in devices map, using as-is") - - test_backend = policy.test_backend - test_device_key = policy.test_device - if test_device_key in devices_map: - test_device = devices_map[test_device_key] - else: - # Fallback: use the key directly if not found in devices_map (backward compatibility) - test_device = test_device_key - self.logger.warning(f"Device alias '{test_device_key}' not found in devices map, using as-is") - - self.logger.info( - f"\nScenario {i+1}/{len(scenarios)}: " - f"{ref_backend.value}({ref_device}) vs {test_backend.value}({test_device})" - ) - - # Resolve model paths based on backend - ref_path = None - test_path = None - - if ref_backend is Backend.PYTORCH: - ref_path = pytorch_checkpoint - elif ref_backend is Backend.ONNX: - ref_path = onnx_path - elif ref_backend is Backend.TENSORRT: - ref_path = tensorrt_path - - if test_backend is Backend.ONNX: - test_path = onnx_path - elif test_backend is Backend.TENSORRT: - test_path = tensorrt_path - elif test_backend is Backend.PYTORCH: - test_path = pytorch_checkpoint - - if not ref_path or not test_path: - self.logger.warning(f" Skipping: missing paths (ref={ref_path}, test={test_path})") - continue - - ref_artifact = self._artifact_from_path(ref_backend, ref_path) - test_artifact = self._artifact_from_path(test_backend, test_path) - - # Use policy-based verification interface - reference_spec = self._build_model_spec(ref_backend, ref_artifact, ref_device) - test_spec = self._build_model_spec(test_backend, test_artifact, test_device) - - 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, - ) - - # Extract results for this specific comparison - policy_key = f"{ref_backend.value}_{ref_device}_vs_{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"Policy {i+1} passed ({passed} comparisons)") - else: - self.logger.warning(f"Policy {i+1} failed ({failed}/{passed+failed} comparisons)") - - # Overall summary - 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 run_evaluation(self, **kwargs) -> Dict[str, Any]: - """ - Run evaluation on specified models. - - Args: - **kwargs: Additional project-specific arguments - - Returns: - Dictionary containing evaluation results for all backends - """ - 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() - - 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 = self._build_model_spec(backend, spec.artifact, backend_device) - - 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) - - if len(all_results) > 1: - 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") - - return all_results - - def run(self, checkpoint_path: Optional[str] = None, **kwargs) -> DeploymentResultDict: - """ - Execute the complete deployment workflow. - - Args: - checkpoint_path: Path to PyTorch checkpoint (optional) - **kwargs: Additional project-specific arguments - - Returns: - DeploymentResultDict: Structured summary of all deployment artifacts and reports. - """ - results = { - "pytorch_model": None, - "onnx_path": None, - "tensorrt_path": None, - "verification_results": {}, - "evaluation_results": {}, - } - - export_mode = self.config.export_config.mode - should_export_onnx = self.config.export_config.should_export_onnx() - should_export_trt = self.config.export_config.should_export_tensorrt() - - # Resolve checkpoint / ONNX sources from config if not provided via CLI - if checkpoint_path is None: - checkpoint_path = self.config.export_config.checkpoint_path - - external_onnx_path = self.config.export_config.onnx_path - - # Check if we need model loading and export - eval_config = self.config.evaluation_config - verification_cfg = self.config.verification_config - - # Determine what we need PyTorch model for - needs_export_onnx = should_export_onnx - - # Check if PyTorch evaluation is needed - needs_pytorch_eval = False - if eval_config.enabled: - models_to_eval = eval_config.models - if self._get_backend_entry(models_to_eval, Backend.PYTORCH): - needs_pytorch_eval = True - - # Check if PyTorch is needed for verification - needs_pytorch_for_verification = False - if verification_cfg.enabled: - export_mode = self.config.export_config.mode - scenarios = self.config.get_verification_scenarios(export_mode) - if scenarios: - needs_pytorch_for_verification = any( - policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH - for policy in scenarios - ) - - requires_pytorch_model = needs_export_onnx or needs_pytorch_eval or needs_pytorch_for_verification - - # Load model if needed for export or ONNX/TensorRT evaluation - # Runner is always responsible for loading model, never reads from evaluator - pytorch_model = None - - if requires_pytorch_model: - if not checkpoint_path: - self.logger.error( - "Checkpoint required but not provided. Please set export.checkpoint_path in config or pass via CLI." - ) - return results - - self.logger.info("\nLoading PyTorch model...") - try: - pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) - results["pytorch_model"] = pytorch_model - self.artifacts[Backend.PYTORCH.value] = Artifact(path=checkpoint_path) - - # Single-direction injection: write model to evaluator via setter (never read from it) - 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()") - except Exception as e: - self.logger.error(f"Failed to load PyTorch model: {e}") - return results - - # Export ONNX if requested - if should_export_onnx: - if pytorch_model is None: - if not checkpoint_path: - self.logger.error("ONNX export requires checkpoint_path but none was provided.") - return results - self.logger.info("\nLoading PyTorch model for ONNX export...") - try: - pytorch_model = self.load_pytorch_model(checkpoint_path, **kwargs) - results["pytorch_model"] = pytorch_model - self.artifacts[Backend.PYTORCH.value] = Artifact(path=checkpoint_path) - - # Single-direction injection: write model to evaluator via setter (never read from it) - 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()") - except Exception as e: - self.logger.error(f"Failed to load PyTorch model: {e}") - return results - - try: - onnx_artifact = self.export_onnx(pytorch_model, **kwargs) - if onnx_artifact: - results["onnx_path"] = onnx_artifact.path - except Exception as e: - self.logger.error(f"Failed to export ONNX: {e}") - - # Export TensorRT if requested - if should_export_trt: - onnx_path = results["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 results - else: - results["onnx_path"] = onnx_path # Ensure verification/evaluation can use this path - if onnx_path and os.path.exists(onnx_path): - self.artifacts[Backend.ONNX.value] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) - try: - tensorrt_artifact = self.export_tensorrt(onnx_path, **kwargs) - if tensorrt_artifact: - results["tensorrt_path"] = tensorrt_artifact.path - except Exception as e: - self.logger.error(f"Failed to export TensorRT: {e}") - - # Get model paths from evaluation config if not exported - if not results["onnx_path"] or not results["tensorrt_path"]: - eval_models = self.config.evaluation_config.models - if not results["onnx_path"]: - onnx_path = self._get_backend_entry(eval_models, Backend.ONNX) - if onnx_path and os.path.exists(onnx_path): - results["onnx_path"] = onnx_path - self.artifacts[Backend.ONNX.value] = Artifact(path=onnx_path, multi_file=os.path.isdir(onnx_path)) - elif onnx_path: - self.logger.warning(f"ONNX file from config does not exist: {onnx_path}") - if not results["tensorrt_path"]: - tensorrt_path = self._get_backend_entry(eval_models, Backend.TENSORRT) - if tensorrt_path and os.path.exists(tensorrt_path): - results["tensorrt_path"] = tensorrt_path - self.artifacts[Backend.TENSORRT.value] = Artifact( - path=tensorrt_path, multi_file=os.path.isdir(tensorrt_path) - ) - elif tensorrt_path: - self.logger.warning(f"TensorRT engine from config does not exist: {tensorrt_path}") - - # Verification - verification_results = self.run_verification( - pytorch_checkpoint=checkpoint_path, - onnx_path=results["onnx_path"], - tensorrt_path=results["tensorrt_path"], - **kwargs, - ) - results["verification_results"] = verification_results - - # Evaluation - evaluation_results = self.run_evaluation(**kwargs) - results["evaluation_results"] = evaluation_results - - self.logger.info("\n" + "=" * 80) - self.logger.info("Deployment Complete!") - self.logger.info("=" * 80) - - return results From 92bf2c42d30c4854a1e9adbc9a19ffd1446f58d3 Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 27 Nov 2025 22:37:59 +0900 Subject: [PATCH 16/62] chore: update deploy config, and refactor artifact manager Signed-off-by: vividf --- deployment/core/__init__.py | 22 +-- deployment/core/config/__init__.py | 13 +- deployment/core/config/base_config.py | 92 +++++++----- deployment/core/config/constants.py | 51 ------- deployment/core/contexts.py | 73 --------- deployment/core/evaluation/base_evaluator.py | 14 +- deployment/docs/configuration.md | 25 ++- .../exporters/common/tensorrt_exporter.py | 4 +- deployment/pipelines/__init__.py | 43 +++--- deployment/pipelines/factory.py | 46 ------ deployment/runners/__init__.py | 4 +- deployment/runners/common/artifact_manager.py | 142 +++++++++--------- .../runners/common/deployment_runner.py | 4 - .../runners/common/evaluation_orchestrator.py | 12 +- .../runners/common/export_orchestrator.py | 6 +- .../common/verification_orchestrator.py | 102 ++++--------- 16 files changed, 228 insertions(+), 425 deletions(-) delete mode 100644 deployment/core/config/constants.py diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py index 3346a6d08..a7bdfe598 100644 --- a/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -5,6 +5,7 @@ from deployment.core.config.base_config import ( BackendConfig, BaseDeploymentConfig, + DeviceConfig, EvaluationConfig, ExportConfig, ExportMode, @@ -14,14 +15,6 @@ parse_base_args, setup_logging, ) -from deployment.core.config.constants import ( - EVALUATION_DEFAULTS, - EXPORT_DEFAULTS, - TASK_DEFAULTS, - EvaluationDefaults, - ExportDefaults, - TaskDefaults, -) from deployment.core.config.runtime_config import ( BaseRuntimeConfig, ClassificationRuntimeConfig, @@ -33,14 +26,13 @@ CalibrationExportContext, CenterPointExportContext, ExportContext, - ExportContextType, - PreprocessContext, YOLOXExportContext, - create_export_context, ) from deployment.core.evaluation.base_evaluator import ( + EVALUATION_DEFAULTS, BaseEvaluator, EvalResultDict, + EvaluationDefaults, ModelSpec, TaskProfile, VerifyResultDict, @@ -75,17 +67,15 @@ "Backend", # Typed contexts "ExportContext", - "ExportContextType", "YOLOXExportContext", "CenterPointExportContext", "CalibrationExportContext", - "PreprocessContext", - "create_export_context", "BaseDeploymentConfig", "ExportConfig", "ExportMode", "RuntimeConfig", "BackendConfig", + "DeviceConfig", "EvaluationConfig", "VerificationConfig", "VerificationScenario", @@ -101,11 +91,7 @@ "ClassificationRuntimeConfig", # Constants "EVALUATION_DEFAULTS", - "EXPORT_DEFAULTS", - "TASK_DEFAULTS", "EvaluationDefaults", - "ExportDefaults", - "TaskDefaults", # Data loading "BaseDataLoader", # Evaluation diff --git a/deployment/core/config/__init__.py b/deployment/core/config/__init__.py index 34a533184..3403bf877 100644 --- a/deployment/core/config/__init__.py +++ b/deployment/core/config/__init__.py @@ -12,14 +12,6 @@ parse_base_args, setup_logging, ) -from deployment.core.config.constants import ( - EVALUATION_DEFAULTS, - EXPORT_DEFAULTS, - TASK_DEFAULTS, - EvaluationDefaults, - ExportDefaults, - TaskDefaults, -) from deployment.core.config.runtime_config import ( BaseRuntimeConfig, ClassificationRuntimeConfig, @@ -27,6 +19,7 @@ Detection3DRuntimeConfig, ) from deployment.core.config.task_config import TaskConfig, TaskType +from deployment.core.evaluation.base_evaluator import EVALUATION_DEFAULTS, EvaluationDefaults __all__ = [ "BackendConfig", @@ -40,11 +33,7 @@ "parse_base_args", "setup_logging", "EVALUATION_DEFAULTS", - "EXPORT_DEFAULTS", - "TASK_DEFAULTS", "EvaluationDefaults", - "ExportDefaults", - "TaskDefaults", "BaseRuntimeConfig", "ClassificationRuntimeConfig", "Detection2DRuntimeConfig", diff --git a/deployment/core/config/base_config.py b/deployment/core/config/base_config.py index a5086855d..d2bdf4220 100644 --- a/deployment/core/config/base_config.py +++ b/deployment/core/config/base_config.py @@ -79,11 +79,6 @@ class ExportConfig: mode: ExportMode = ExportMode.BOTH work_dir: str = "work_dirs" onnx_path: Optional[str] = None - tensorrt_path: Optional[str] = None - cuda_device: str = "cuda:0" - - def __post_init__(self) -> None: - object.__setattr__(self, "cuda_device", self._parse_cuda_device(self.cuda_device)) @classmethod def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": @@ -92,8 +87,6 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": 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"), - tensorrt_path=config_dict.get("tensorrt_path"), - cuda_device=config_dict.get("cuda_device", cls.cuda_device), ) def should_export_onnx(self) -> bool: @@ -104,44 +97,61 @@ 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: Dict[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 _parse_cuda_device(device: Optional[str]) -> str: - """Parse and normalize CUDA device string to 'cuda:N' format.""" - if device is None: - return "cuda:0" + 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')") - + raise ValueError("cuda device must be a string (e.g., 'cuda:0')") normalized = device.strip().lower() if normalized == "": - normalized = "cuda:0" - + return None if normalized == "cuda": normalized = "cuda:0" - if not normalized.startswith("cuda"): - raise ValueError(f"Invalid cuda_device '{device}'. Must start with 'cuda'") - - if ":" in normalized: - suffix = normalized.split(":", 1)[1] - suffix = suffix.strip() - if suffix == "": - suffix = "0" - if not suffix.isdigit(): - raise ValueError(f"Invalid CUDA device index in '{device}'") - device_id = int(suffix) - else: - device_id = 0 - + 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) -> int: - """Return CUDA device index as integer.""" - return int(self.cuda_device.split(":", 1)[1]) + 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) @@ -301,6 +311,7 @@ def __init__(self, deploy_cfg: Config): 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", {})) @@ -339,8 +350,14 @@ def _validate_cuda_device(self) -> None: if not self._needs_cuda_device(): return - cuda_device = self.export_config.cuda_device - device_idx = self.export_config.get_cuda_device_index() + 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( @@ -404,6 +421,11 @@ 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) -> Dict[str, Dict[str, Any]]: """ Get evaluation backends configuration. diff --git a/deployment/core/config/constants.py b/deployment/core/config/constants.py deleted file mode 100644 index 09e9e936d..000000000 --- a/deployment/core/config/constants.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Centralized constants for the deployment framework. - -This module consolidates magic numbers and constants that were scattered -across multiple files into a single, configurable location. -""" - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class EvaluationDefaults: - """Default values for evaluation settings.""" - - LOG_INTERVAL: int = 50 - GPU_CLEANUP_INTERVAL: int = 10 - VERIFICATION_TOLERANCE: float = 0.1 - DEFAULT_NUM_SAMPLES: int = 10 - DEFAULT_NUM_VERIFY_SAMPLES: int = 3 - - -@dataclass(frozen=True) -class ExportDefaults: - """Default values for export settings.""" - - ONNX_DIR_NAME: str = "onnx" - TENSORRT_DIR_NAME: str = "tensorrt" - DEFAULT_ENGINE_FILENAME: str = "model.engine" - DEFAULT_ONNX_FILENAME: str = "model.onnx" - DEFAULT_OPSET_VERSION: int = 16 - DEFAULT_WORKSPACE_SIZE: int = 1 << 30 # 1 GB - - -@dataclass(frozen=True) -class TaskDefaults: - """Default values for task-specific settings.""" - - # Default class names for T4Dataset - DETECTION_3D_CLASSES: tuple = ("car", "truck", "bus", "bicycle", "pedestrian") - DETECTION_2D_CLASSES: tuple = ("unknown", "car", "truck", "bus", "trailer", "motorcycle", "pedestrian", "bicycle") - CLASSIFICATION_CLASSES: tuple = ("miscalibrated", "calibrated") - - # Default input sizes - DEFAULT_2D_INPUT_SIZE: tuple = (960, 960) - DEFAULT_CLASSIFICATION_INPUT_SIZE: tuple = (224, 224) - - -# Singleton instances for easy import -EVALUATION_DEFAULTS = EvaluationDefaults() -EXPORT_DEFAULTS = ExportDefaults() -TASK_DEFAULTS = TaskDefaults() diff --git a/deployment/core/contexts.py b/deployment/core/contexts.py index 414c021a9..80c8c1441 100644 --- a/deployment/core/contexts.py +++ b/deployment/core/contexts.py @@ -89,76 +89,3 @@ class CalibrationExportContext(ExportContext): """ pass - - -# Type alias for context types -ExportContextType = ExportContext | YOLOXExportContext | CenterPointExportContext | CalibrationExportContext - - -@dataclass(frozen=True) -class PreprocessContext: - """ - Context for preprocessing operations. - - This context carries metadata and parameters needed during preprocessing - in deployment pipelines. - - Attributes: - img_info: Image metadata dictionary (height, width, scale_factor, etc.) - Required for 2D detection pipelines. - extra: Dictionary for additional preprocessing parameters. - """ - - img_info: Optional[Dict[str, Any]] = None - extra: Dict[str, Any] = field(default_factory=dict) - - def get(self, key: str, default: Any = None) -> Any: - """Get a value from extra dict with a default.""" - return self.extra.get(key, default) - - -# Factory functions for convenience -def create_export_context( - project_type: str = "base", - sample_idx: int = 0, - **kwargs: Any, -) -> ExportContext: - """ - Factory function to create the appropriate export context. - - Args: - project_type: One of "base", "yolox", "centerpoint", "calibration" - sample_idx: Sample index for tracing - **kwargs: Project-specific parameters - - Returns: - Appropriate ExportContext subclass instance - - Example: - ctx = create_export_context("yolox", model_cfg_path="/path/to/config.py") - ctx = create_export_context("centerpoint", rot_y_axis_reference=True) - """ - project_type = project_type.lower() - - if project_type == "yolox": - return YOLOXExportContext( - sample_idx=sample_idx, - model_cfg_path=kwargs.pop("model_cfg_path", None), - extra=kwargs, - ) - elif project_type == "centerpoint": - return CenterPointExportContext( - sample_idx=sample_idx, - rot_y_axis_reference=kwargs.pop("rot_y_axis_reference", False), - extra=kwargs, - ) - elif project_type == "calibration": - return CalibrationExportContext( - sample_idx=sample_idx, - extra=kwargs, - ) - else: - return ExportContext( - sample_idx=sample_idx, - extra=kwargs, - ) diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py index 3f4ef64ea..b5270498f 100644 --- a/deployment/core/evaluation/base_evaluator.py +++ b/deployment/core/evaluation/base_evaluator.py @@ -19,7 +19,6 @@ import torch from deployment.core.backend import Backend -from deployment.core.config.constants import EVALUATION_DEFAULTS from deployment.core.evaluation.evaluator_types import EvalResultDict, ModelSpec, VerifyResultDict from deployment.core.evaluation.verification_mixin import VerificationMixin from deployment.core.io.base_data_loader import BaseDataLoader @@ -32,11 +31,24 @@ "ModelSpec", "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 class TaskProfile: """ diff --git a/deployment/docs/configuration.md b/deployment/docs/configuration.md index c0be80a7a..1b91be981 100644 --- a/deployment/docs/configuration.md +++ b/deployment/docs/configuration.md @@ -11,6 +11,11 @@ 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", @@ -49,11 +54,11 @@ verification = dict( enabled=True, num_verify_samples=3, tolerance=0.1, - devices={"cpu": "cpu", "cuda": "cuda:0"}, + devices=devices, scenarios={ "both": [ {"ref_backend": "pytorch", "ref_device": "cpu", - "test_backend": "onnx", "test_device": "cpu"}, + "test_backend": "onnx", "test_device": "cuda"}, ] } ) @@ -63,13 +68,17 @@ evaluation = dict( num_samples=100, verbose=False, backends={ - "pytorch": {"enabled": True, "device": "cpu"}, - "onnx": {"enabled": True, "device": "cpu"}, - "tensorrt": {"enabled": True, "device": "cuda:0"}, + "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. @@ -79,9 +88,9 @@ from deployment.core import Backend evaluation = dict( backends={ - Backend.PYTORCH: {"enabled": True, "device": "cpu"}, - Backend.ONNX: {"enabled": True, "device": "cpu"}, - Backend.TENSORRT: {"enabled": True, "device": "cuda:0"}, + Backend.PYTORCH: {"enabled": True, "device": devices["cpu"]}, + Backend.ONNX: {"enabled": True, "device": devices["cpu"]}, + Backend.TENSORRT: {"enabled": True, "device": devices["cuda"]}, } ) ``` diff --git a/deployment/exporters/common/tensorrt_exporter.py b/deployment/exporters/common/tensorrt_exporter.py index 0106b68a2..8df8b4f69 100644 --- a/deployment/exporters/common/tensorrt_exporter.py +++ b/deployment/exporters/common/tensorrt_exporter.py @@ -66,9 +66,9 @@ def export( self.logger.info(f" ONNX source: {onnx_path}") self.logger.info(f" Engine output: {output_path}") - return self._export_single_file(onnx_path, output_path, sample_input) + return self._do_tensorrt_export(onnx_path, output_path, sample_input) - def _export_single_file( + def _do_tensorrt_export( self, onnx_path: str, output_path: str, diff --git a/deployment/pipelines/__init__.py b/deployment/pipelines/__init__.py index 53bc0f01e..4c0648ede 100644 --- a/deployment/pipelines/__init__.py +++ b/deployment/pipelines/__init__.py @@ -6,12 +6,12 @@ """ # Calibration pipelines (classification) -from deployment.pipelines.calibration import ( - CalibrationDeploymentPipeline, - CalibrationONNXPipeline, - CalibrationPyTorchPipeline, - CalibrationTensorRTPipeline, -) +# from deployment.pipelines.calibration import ( +# CalibrationDeploymentPipeline, +# CalibrationONNXPipeline, +# CalibrationPyTorchPipeline, +# CalibrationTensorRTPipeline, +# ) # CenterPoint pipelines (3D detection) from deployment.pipelines.centerpoint import ( @@ -22,33 +22,32 @@ ) # Pipeline factory -from deployment.pipelines.factory import PipelineFactory, PipelineRegistry +from deployment.pipelines.factory import PipelineFactory # YOLOX pipelines (2D detection) -from deployment.pipelines.yolox import ( - YOLOXDeploymentPipeline, - YOLOXONNXPipeline, - YOLOXPyTorchPipeline, - YOLOXTensorRTPipeline, -) +# from deployment.pipelines.yolox import ( +# YOLOXDeploymentPipeline, +# YOLOXONNXPipeline, +# YOLOXPyTorchPipeline, +# YOLOXTensorRTPipeline, +# ) __all__ = [ # Factory "PipelineFactory", - "PipelineRegistry", # CenterPoint "CenterPointDeploymentPipeline", "CenterPointPyTorchPipeline", "CenterPointONNXPipeline", "CenterPointTensorRTPipeline", # YOLOX - "YOLOXDeploymentPipeline", - "YOLOXPyTorchPipeline", - "YOLOXONNXPipeline", - "YOLOXTensorRTPipeline", + # "YOLOXDeploymentPipeline", + # "YOLOXPyTorchPipeline", + # "YOLOXONNXPipeline", + # "YOLOXTensorRTPipeline", # Calibration - "CalibrationDeploymentPipeline", - "CalibrationPyTorchPipeline", - "CalibrationONNXPipeline", - "CalibrationTensorRTPipeline", + # "CalibrationDeploymentPipeline", + # "CalibrationPyTorchPipeline", + # "CalibrationONNXPipeline", + # "CalibrationTensorRTPipeline", ] diff --git a/deployment/pipelines/factory.py b/deployment/pipelines/factory.py index 59eea367d..250cca8ab 100644 --- a/deployment/pipelines/factory.py +++ b/deployment/pipelines/factory.py @@ -1,8 +1,5 @@ """ Pipeline factory for centralized pipeline instantiation. - -This module provides a factory for creating task-specific pipelines, -eliminating duplicated backend switching logic across evaluators. """ import logging @@ -15,52 +12,9 @@ logger = logging.getLogger(__name__) -class PipelineRegistry: - """ - Registry for pipeline classes. - - Each task type registers its pipeline classes for different backends. - """ - - _registry: Dict[str, Dict[Backend, Type[BaseDeploymentPipeline]]] = {} - - @classmethod - def register( - cls, - task_type: str, - backend: Backend, - pipeline_cls: Type[BaseDeploymentPipeline], - ) -> None: - """Register a pipeline class for a task type and backend.""" - if task_type not in cls._registry: - cls._registry[task_type] = {} - cls._registry[task_type][backend] = pipeline_cls - - @classmethod - def get(cls, task_type: str, backend: Backend) -> Optional[Type[BaseDeploymentPipeline]]: - """Get a pipeline class for a task type and backend.""" - return cls._registry.get(task_type, {}).get(backend) - - @classmethod - def register_task( - cls, - task_type: str, - pytorch_cls: Type[BaseDeploymentPipeline], - onnx_cls: Type[BaseDeploymentPipeline], - tensorrt_cls: Type[BaseDeploymentPipeline], - ) -> None: - """Register all backend pipelines for a task type.""" - cls.register(task_type, Backend.PYTORCH, pytorch_cls) - cls.register(task_type, Backend.ONNX, onnx_cls) - cls.register(task_type, Backend.TENSORRT, tensorrt_cls) - - class PipelineFactory: """ Factory for creating deployment pipelines. - - This factory centralizes pipeline creation logic, eliminating the - duplicated backend switching code in evaluators. """ @staticmethod diff --git a/deployment/runners/__init__.py b/deployment/runners/__init__.py index d93dfa69c..cfbebdd68 100644 --- a/deployment/runners/__init__.py +++ b/deployment/runners/__init__.py @@ -13,8 +13,8 @@ "BaseDeploymentRunner", # Project-specific runners "CenterPointDeploymentRunner", - "YOLOXOptElanDeploymentRunner", - "CalibrationDeploymentRunner", + # "YOLOXOptElanDeploymentRunner", + # "CalibrationDeploymentRunner", # Helper components (orchestrators) "ArtifactManager", "VerificationOrchestrator", diff --git a/deployment/runners/common/artifact_manager.py b/deployment/runners/common/artifact_manager.py index ecf11068e..4db3122e5 100644 --- a/deployment/runners/common/artifact_manager.py +++ b/deployment/runners/common/artifact_manager.py @@ -7,7 +7,8 @@ import logging import os -from typing import Dict, Optional, Tuple +from collections.abc import Mapping +from typing import Any, Dict, Optional, Tuple from deployment.core.artifacts import Artifact from deployment.core.backend import Backend @@ -23,6 +24,15 @@ class ArtifactManager: - Resolving artifact paths from configuration - Validating artifact existence - Looking up artifacts by backend + + 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): @@ -60,104 +70,92 @@ def get_artifact(self, backend: Backend) -> Optional[Artifact]: """ return self.artifacts.get(backend.value) - def resolve_pytorch_artifact(self, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: + def resolve_artifact(self, backend: Backend) -> Tuple[Optional[Artifact], bool]: """ - Resolve PyTorch model path from registered artifacts or config. + Resolve artifact for any backend with consistent resolution order. - Resolution order: - 1. Registered artifacts (from previous export/load operations) - 2. checkpoint_path in config (single source of truth) + Resolution order (same for all backends): + 1. Registered artifact (from previous export/load operations) + 2. Explicit path from evaluation.backends. config: + - ONNX: model_dir + - TensorRT: engine_dir + 3. Backend-specific fallback (checkpoint_path for PyTorch, export.onnx_path for ONNX) Args: - backend_cfg: Backend configuration dictionary (unused for PyTorch path resolution) + backend: Backend identifier Returns: Tuple of (artifact, is_valid). artifact is an Artifact instance if a path could be resolved, otherwise None. is_valid indicates whether the artifact exists on disk. """ - # Check registered artifacts first - artifact = self.artifacts.get(Backend.PYTORCH.value) + # Priority 1: Check registered artifacts + artifact = self.artifacts.get(backend.value) if artifact: return artifact, artifact.exists() - model_path = self.config.checkpoint_path - if not model_path: - return None, False - artifact = Artifact(path=model_path, multi_file=False) - return artifact, artifact.exists() - - def resolve_onnx_artifact(self, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: - """ - Resolve ONNX model path from backend config or registered artifacts. - - Args: - backend_cfg: Backend configuration dictionary - - Returns: - Tuple of (artifact, is_valid). - artifact is an Artifact instance if a path could be resolved, otherwise None. - is_valid indicates whether the artifact exists on disk. - """ - # Check registered artifacts first - artifact = self.artifacts.get(Backend.ONNX.value) - if artifact: + # Priority 2 & 3: Get path from config + config_path = self._get_config_path(backend) + if config_path: + is_dir = os.path.isdir(config_path) if os.path.exists(config_path) else False + artifact = Artifact(path=config_path, multi_file=is_dir) return artifact, artifact.exists() - # Fallback to explicit path from config - explicit_path = backend_cfg.get("model_dir") or self.config.export_config.onnx_path - if explicit_path: - is_dir = os.path.isdir(explicit_path) if os.path.exists(explicit_path) else False - fallback_artifact = Artifact(path=explicit_path, multi_file=is_dir) - return fallback_artifact, fallback_artifact.exists() - return None, False - def resolve_tensorrt_artifact(self, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: + def _get_config_path(self, backend: Backend) -> Optional[str]: """ - Resolve TensorRT model path from backend config or registered artifacts. + Get artifact path from configuration. + + Resolution order: + 1. evaluation.backends..model_dir or engine_dir (explicit per-backend path) + 2. Backend-specific fallbacks (checkpoint_path, export.onnx_path) Args: - backend_cfg: Backend configuration dictionary + backend: Backend identifier Returns: - Tuple of (artifact, is_valid). - artifact is an Artifact instance if a path could be resolved, otherwise None. - is_valid indicates whether the artifact exists on disk. + Path string if found in config, None otherwise """ - # Check registered artifacts first - artifact = self.artifacts.get(Backend.TENSORRT.value) - if artifact: - return artifact, artifact.exists() - - # Fallback to explicit path from config - explicit_path = backend_cfg.get("engine_dir") or self.config.export_config.tensorrt_path - if explicit_path: - is_dir = os.path.isdir(explicit_path) if os.path.exists(explicit_path) else False - fallback_artifact = Artifact(path=explicit_path, multi_file=is_dir) - return fallback_artifact, fallback_artifact.exists() - - return None, False + # Priority 1: Check evaluation.backends. for explicit path + eval_backends = self.config.evaluation_config.backends + backend_cfg = self._get_backend_entry(eval_backends, backend) + if backend_cfg and isinstance(backend_cfg, Mapping): + # ONNX uses model_dir, TensorRT uses engine_dir + 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 + + # Priority 2: Backend-specific fallbacks from export config + if backend == Backend.PYTORCH: + return self.config.checkpoint_path + elif backend == Backend.ONNX: + return self.config.export_config.onnx_path + # TensorRT has no global fallback path in export config + return None - def resolve_artifact(self, backend: Backend, backend_cfg: Dict) -> Tuple[Optional[Artifact], bool]: + @staticmethod + def _get_backend_entry(mapping: Optional[Mapping], backend: Backend) -> Any: """ - Resolve artifact for any backend. - - This is a convenience method that delegates to backend-specific resolvers. + Fetch a config value that may be keyed by either string literals or Backend enums. Args: - backend: Backend identifier - backend_cfg: Backend configuration dictionary + mapping: Configuration mapping (may be None or MappingProxyType) + backend: Backend to look up Returns: - Tuple of (artifact, is_valid) + Value from mapping if found, None otherwise """ - if backend == Backend.PYTORCH: - return self.resolve_pytorch_artifact(backend_cfg) - elif backend == Backend.ONNX: - return self.resolve_onnx_artifact(backend_cfg) - elif backend == Backend.TENSORRT: - return self.resolve_tensorrt_artifact(backend_cfg) - else: - self.logger.warning(f"Unknown backend: {backend}") - return None, False + 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/runners/common/deployment_runner.py b/deployment/runners/common/deployment_runner.py index 3c7944ef8..dd8288055 100644 --- a/deployment/runners/common/deployment_runner.py +++ b/deployment/runners/common/deployment_runner.py @@ -198,12 +198,8 @@ def run( results["tensorrt_path"] = export_result.tensorrt_path # Phase 2: Verification - checkpoint_path = self.config.checkpoint_path verification_results = self.verification_orchestrator.run( artifact_manager=self.artifact_manager, - pytorch_checkpoint=checkpoint_path, - onnx_path=results["onnx_path"], - tensorrt_path=results["tensorrt_path"], ) results["verification_results"] = verification_results diff --git a/deployment/runners/common/evaluation_orchestrator.py b/deployment/runners/common/evaluation_orchestrator.py index 0e9fa2359..f887d9a67 100644 --- a/deployment/runners/common/evaluation_orchestrator.py +++ b/deployment/runners/common/evaluation_orchestrator.py @@ -142,7 +142,7 @@ def _get_models_to_evaluate(self, artifact_manager: ArtifactManager) -> List[Mod device = str(backend_cfg.get("device", "cpu") or "cpu") # Use artifact_manager to resolve artifact - artifact, is_valid = artifact_manager.resolve_artifact(backend_enum, backend_cfg) + artifact, is_valid = artifact_manager.resolve_artifact(backend_enum) if is_valid and artifact: spec = ModelSpec(backend=backend_enum, device=device, artifact=artifact) @@ -164,7 +164,7 @@ def _normalize_device_for_backend(self, backend: Backend, device: str) -> str: Returns: Normalized device string """ - normalized_device = str(device or "cpu") + 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"): @@ -174,7 +174,7 @@ def _normalize_device_for_backend(self, backend: Backend, device: str) -> str: normalized_device = "cpu" elif backend is Backend.TENSORRT: if not normalized_device or normalized_device == "cpu": - normalized_device = self.config.export_config.cuda_device or "cuda:0" + normalized_device = self.config.devices.cuda or "cuda:0" if not normalized_device.startswith("cuda"): self.logger.warning( "TensorRT evaluation requires CUDA device. " @@ -184,6 +184,12 @@ def _normalize_device_for_backend(self, backend: Backend, device: str) -> str: return normalized_device + def _get_default_device(self, backend: Backend) -> str: + """Return default device for a backend when config omits explicit value.""" + 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: Dict[str, Any]) -> None: """ Print cross-backend comparison of metrics. diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index cc58e7993..2f0d48136 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -380,8 +380,10 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A output_path = self._get_tensorrt_output_path(onnx_path, tensorrt_dir) # Set CUDA device for TensorRT export - cuda_device = self.config.export_config.cuda_device - device_id = self.config.export_config.get_cuda_device_index() + 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}") diff --git a/deployment/runners/common/verification_orchestrator.py b/deployment/runners/common/verification_orchestrator.py index 6bb4489fd..718c87344 100644 --- a/deployment/runners/common/verification_orchestrator.py +++ b/deployment/runners/common/verification_orchestrator.py @@ -7,9 +7,8 @@ import logging from typing import Any, Dict -from deployment.core.artifacts import Artifact from deployment.core.backend import Backend -from deployment.core.config.base_config import BaseDeploymentConfig, ExportMode +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 @@ -22,7 +21,7 @@ class VerificationOrchestrator: This class handles: - Running verification scenarios from config - - Resolving model paths for each scenario + - Resolving model paths via ArtifactManager - Collecting and aggregating verification results - Logging verification progress and results """ @@ -48,21 +47,12 @@ def __init__( self.data_loader = data_loader self.logger = logger - def run( - self, - artifact_manager: ArtifactManager, - pytorch_checkpoint: str = None, - onnx_path: str = None, - tensorrt_path: str = None, - ) -> Dict[str, Any]: + def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: """ Run verification on exported models using policy-based verification. Args: artifact_manager: Artifact manager for resolving model paths - pytorch_checkpoint: Path to PyTorch checkpoint (optional) - onnx_path: Path to ONNX model file/directory (optional) - tensorrt_path: Path to TensorRT engine file/directory (optional) Returns: Verification results dictionary @@ -81,21 +71,26 @@ def run( self.logger.info(f"No verification scenarios for export mode '{export_mode.value}', skipping...") return {} - # Check if PyTorch checkpoint is needed + # Check if PyTorch checkpoint is needed and available needs_pytorch = any( policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios ) - if needs_pytorch and not pytorch_checkpoint: - self.logger.warning( - "PyTorch checkpoint path not available, but required by verification scenarios. " - "Skipping verification." - ) - return {} + if needs_pytorch: + pytorch_artifact, 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 = verification_cfg.devices or {} + 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})") @@ -115,20 +110,21 @@ def run( f"{policy.ref_backend.value}({ref_device}) vs {policy.test_backend.value}({test_device})" ) - # Resolve model paths based on backend - ref_path = self._resolve_backend_path(policy.ref_backend, pytorch_checkpoint, onnx_path, tensorrt_path) - test_path = self._resolve_backend_path(policy.test_backend, pytorch_checkpoint, onnx_path, tensorrt_path) - - if not ref_path or not test_path: - self.logger.warning(f" Skipping: missing paths (ref={ref_path}, test={test_path})") + # Resolve artifacts via ArtifactManager + 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( + f" Skipping: missing or invalid artifacts " + f"(ref={ref_path}, valid={ref_valid}, test={test_path}, valid={test_valid})" + ) continue - # Create artifacts and model specs - ref_artifact = self._create_artifact(policy.ref_backend, ref_path) - test_artifact = self._create_artifact(policy.test_backend, test_path) - + # Create model specs 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) # Run verification @@ -188,47 +184,5 @@ def _resolve_device(self, device_key: str, devices_map: Dict[str, str]) -> str: if device_key in devices_map: return devices_map[device_key] else: - # Fallback: use the key directly self.logger.warning(f"Device alias '{device_key}' not found in devices map, using as-is") return device_key - - def _resolve_backend_path( - self, backend: Backend, pytorch_checkpoint: str, onnx_path: str, tensorrt_path: str - ) -> str: - """ - Resolve model path for a backend. - - Args: - backend: Backend identifier - pytorch_checkpoint: PyTorch checkpoint path - onnx_path: ONNX model path - tensorrt_path: TensorRT engine path - - Returns: - Model path for the backend, or None if not available - """ - if backend == Backend.PYTORCH: - return pytorch_checkpoint - elif backend == Backend.ONNX: - return onnx_path - elif backend == Backend.TENSORRT: - return tensorrt_path - else: - self.logger.warning(f"Unknown backend: {backend}") - return None - - def _create_artifact(self, backend: Backend, path: str) -> Artifact: - """ - Create artifact from path. - - Args: - backend: Backend identifier - path: Model path - - Returns: - Artifact instance - """ - import os - - multi_file = os.path.isdir(path) if path and os.path.exists(path) else False - return Artifact(path=path, multi_file=multi_file) From aa314a42fa350b4b086e5a2010509f47c86d3bce Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 00:01:04 +0900 Subject: [PATCH 17/62] chore: frozen dataclass Signed-off-by: vividf --- deployment/core/artifacts.py | 2 +- deployment/core/config/base_config.py | 2 +- deployment/core/evaluation/base_evaluator.py | 4 ++-- deployment/core/evaluation/results.py | 10 +++++----- deployment/core/metrics/base_metrics_adapter.py | 2 +- deployment/core/metrics/classification_metrics.py | 2 +- deployment/core/metrics/detection_2d_metrics.py | 13 ++++++++----- deployment/core/metrics/detection_3d_metrics.py | 11 +++++++---- deployment/exporters/workflows/interfaces.py | 2 +- 9 files changed, 27 insertions(+), 21 deletions(-) diff --git a/deployment/core/artifacts.py b/deployment/core/artifacts.py index be7f39bbf..e1bdda2ad 100644 --- a/deployment/core/artifacts.py +++ b/deployment/core/artifacts.py @@ -6,7 +6,7 @@ from dataclasses import dataclass -@dataclass +@dataclass(frozen=True) class Artifact: """Represents a produced deployment artifact such as ONNX or TensorRT outputs.""" diff --git a/deployment/core/config/base_config.py b/deployment/core/config/base_config.py index d2bdf4220..841d97e99 100644 --- a/deployment/core/config/base_config.py +++ b/deployment/core/config/base_config.py @@ -232,7 +232,7 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "EvaluationConfig": ) -@dataclass +@dataclass(frozen=True) class VerificationConfig: """Typed configuration for verification settings.""" diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py index b5270498f..bd1a31e7a 100644 --- a/deployment/core/evaluation/base_evaluator.py +++ b/deployment/core/evaluation/base_evaluator.py @@ -49,7 +49,7 @@ class EvaluationDefaults: EVALUATION_DEFAULTS = EvaluationDefaults() -@dataclass +@dataclass(frozen=True) class TaskProfile: """ Profile describing task-specific evaluation behavior. @@ -68,7 +68,7 @@ class TaskProfile: def __post_init__(self): if not self.display_name: - self.display_name = self.task_name + object.__setattr__(self, "display_name", self.task_name) class BaseEvaluator(VerificationMixin, ABC): diff --git a/deployment/core/evaluation/results.py b/deployment/core/evaluation/results.py index eceac2d03..d5f7dae84 100644 --- a/deployment/core/evaluation/results.py +++ b/deployment/core/evaluation/results.py @@ -66,7 +66,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Detection2DResult": ) -@dataclass +@dataclass(frozen=True) class ClassificationResult: """Result for a classification prediction.""" @@ -163,7 +163,7 @@ def to_dict(self) -> Dict[str, Dict[str, float]]: return result -@dataclass +@dataclass(frozen=True) class EvaluationMetrics: """Base class for evaluation metrics.""" @@ -182,7 +182,7 @@ def to_dict(self) -> Dict[str, Any]: return result -@dataclass +@dataclass(frozen=True) class Detection3DEvaluationMetrics(EvaluationMetrics): """Evaluation metrics for 3D detection.""" @@ -213,7 +213,7 @@ def to_dict(self) -> Dict[str, Any]: return result -@dataclass +@dataclass(frozen=True) class Detection2DEvaluationMetrics(EvaluationMetrics): """Evaluation metrics for 2D detection.""" @@ -238,7 +238,7 @@ def to_dict(self) -> Dict[str, Any]: return result -@dataclass +@dataclass(frozen=True) class ClassificationEvaluationMetrics(EvaluationMetrics): """Evaluation metrics for classification.""" diff --git a/deployment/core/metrics/base_metrics_adapter.py b/deployment/core/metrics/base_metrics_adapter.py index c198224eb..816da90ab 100644 --- a/deployment/core/metrics/base_metrics_adapter.py +++ b/deployment/core/metrics/base_metrics_adapter.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BaseMetricsConfig: """Base configuration for all metrics adapters. diff --git a/deployment/core/metrics/classification_metrics.py b/deployment/core/metrics/classification_metrics.py index 0b0124449..325d0449f 100644 --- a/deployment/core/metrics/classification_metrics.py +++ b/deployment/core/metrics/classification_metrics.py @@ -35,7 +35,7 @@ logger = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ClassificationMetricsConfig(BaseMetricsConfig): """Configuration for classification metrics. diff --git a/deployment/core/metrics/detection_2d_metrics.py b/deployment/core/metrics/detection_2d_metrics.py index 9333ce123..38264a6e2 100644 --- a/deployment/core/metrics/detection_2d_metrics.py +++ b/deployment/core/metrics/detection_2d_metrics.py @@ -66,7 +66,7 @@ ] -@dataclass +@dataclass(frozen=True) class Detection2DMetricsConfig(BaseMetricsConfig): """Configuration for 2D detection metrics. @@ -93,12 +93,12 @@ 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. " f"Valid options: {VALID_2D_FRAME_IDS}" + 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: - self.evaluation_config_dict = { + default_eval_config = { "evaluation_task": "detection2d", "target_labels": self.class_names, "iou_2d_thresholds": self.iou_thresholds, @@ -107,22 +107,25 @@ def __post_init__(self): "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: - self.critical_object_filter_config = { + 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) - self.frame_pass_fail_config = { + 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 Detection2DMetricsAdapter(BaseMetricsAdapter): diff --git a/deployment/core/metrics/detection_3d_metrics.py b/deployment/core/metrics/detection_3d_metrics.py index 15dc51931..d8f0f2cb3 100644 --- a/deployment/core/metrics/detection_3d_metrics.py +++ b/deployment/core/metrics/detection_3d_metrics.py @@ -48,7 +48,7 @@ logger = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class Detection3DMetricsConfig(BaseMetricsConfig): """Configuration for 3D detection metrics. @@ -93,7 +93,7 @@ class Detection3DMetricsConfig(BaseMetricsConfig): def __post_init__(self): # Set default evaluation config if not provided if self.evaluation_config_dict is None: - self.evaluation_config_dict = { + default_eval_config = { "evaluation_task": "detection", "target_labels": self.class_names, "center_distance_bev_thresholds": [0.5, 1.0, 2.0, 4.0], @@ -105,25 +105,28 @@ def __post_init__(self): "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) - self.critical_object_filter_config = { + 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) - self.frame_pass_fail_config = { + 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 Detection3DMetricsAdapter(BaseMetricsAdapter): diff --git a/deployment/exporters/workflows/interfaces.py b/deployment/exporters/workflows/interfaces.py index 973ab7e6e..12feaa1a2 100644 --- a/deployment/exporters/workflows/interfaces.py +++ b/deployment/exporters/workflows/interfaces.py @@ -14,7 +14,7 @@ from deployment.exporters.common.configs import ONNXExportConfig -@dataclass +@dataclass(frozen=True) class ExportableComponent: """ A model component ready for ONNX export. From 8a35126207c467002aea1df5a4308a4a78c74792 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 00:20:24 +0900 Subject: [PATCH 18/62] chore: chnage to osp Signed-off-by: vividf --- deployment/core/artifacts.py | 4 ++-- deployment/runners/common/artifact_manager.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deployment/core/artifacts.py b/deployment/core/artifacts.py index e1bdda2ad..985aa3bb1 100644 --- a/deployment/core/artifacts.py +++ b/deployment/core/artifacts.py @@ -2,7 +2,7 @@ from __future__ import annotations -import os +import os.path as osp from dataclasses import dataclass @@ -15,4 +15,4 @@ class Artifact: def exists(self) -> bool: """Return True if the artifact path currently exists on disk.""" - return os.path.exists(self.path) + return osp.exists(self.path) diff --git a/deployment/runners/common/artifact_manager.py b/deployment/runners/common/artifact_manager.py index 4db3122e5..5619ffccf 100644 --- a/deployment/runners/common/artifact_manager.py +++ b/deployment/runners/common/artifact_manager.py @@ -6,7 +6,7 @@ """ import logging -import os +import os.path as osp from collections.abc import Mapping from typing import Any, Dict, Optional, Tuple @@ -97,7 +97,7 @@ def resolve_artifact(self, backend: Backend) -> Tuple[Optional[Artifact], bool]: # Priority 2 & 3: Get path from config config_path = self._get_config_path(backend) if config_path: - is_dir = os.path.isdir(config_path) if os.path.exists(config_path) else False + 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() From 72e833e418f8d30d1807bd34ced46a53017c037b Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 00:35:53 +0900 Subject: [PATCH 19/62] chore: clean code Signed-off-by: vividf --- deployment/core/io/base_data_loader.py | 14 ++++++++++++++ deployment/exporters/common/tensorrt_exporter.py | 8 ++++++-- deployment/runners/common/export_orchestrator.py | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/deployment/core/io/base_data_loader.py b/deployment/core/io/base_data_loader.py index 9dcdbe9dc..58e7d321f 100644 --- a/deployment/core/io/base_data_loader.py +++ b/deployment/core/io/base_data_loader.py @@ -92,6 +92,20 @@ def get_num_samples(self) -> int: """ raise NotImplementedError + @abstractmethod + def get_ground_truth(self, index: int) -> Dict[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. diff --git a/deployment/exporters/common/tensorrt_exporter.py b/deployment/exporters/common/tensorrt_exporter.py index 8df8b4f69..0ecede689 100644 --- a/deployment/exporters/common/tensorrt_exporter.py +++ b/deployment/exporters/common/tensorrt_exporter.py @@ -296,7 +296,11 @@ def _configure_input_shapes( model_inputs_cfg = self.config.model_inputs # Validate that we have shape information - if not model_inputs_cfg or not model_inputs_cfg[0].input_shapes: + 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" @@ -334,7 +338,7 @@ def _configure_input_shapes( # model_inputs is already a Tuple[TensorRTModelInputConfig, ...] first_entry = model_inputs_cfg[0] - input_shapes = self._extract_input_shapes(first_entry) + input_shapes = first_input_shapes if not input_shapes: raise ValueError("TensorRT model_inputs[0] missing 'input_shapes' definitions") diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index 2f0d48136..421780888 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -236,7 +236,7 @@ def _load_and_register_pytorch_model( """ if not checkpoint_path: self.logger.error( - "Checkpoint required but not provided. " "Please set export.checkpoint_path in config or pass via CLI." + "Checkpoint required but not provided. Please set export.checkpoint_path in config or pass via CLI." ) return None From ea4f71aef066a08fac84779d737f0dab3332cc4a Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 10:03:00 +0900 Subject: [PATCH 20/62] chore: update readme Signed-off-by: vividf --- deployment/README.md | 2 +- deployment/docs/usage.md | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/deployment/README.md b/deployment/README.md index 189a7cdb2..37e3d8085 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -24,7 +24,7 @@ python projects/YOLOX_opt_elan/deploy/main.py configs/deploy_config.py configs/m python projects/CalibrationStatusClassification/deploy/main.py configs/deploy_config.py configs/model_config.py ``` -Command-line flags (`--work-dir`, `--device`, `--log-level`, optional `checkpoint`) are consistent across projects. Inject wrapper classes and optional workflows when instantiating a runner; exporters are created lazily inside `BaseDeploymentRunner`. +Only `--log-level` is available as a command-line flag. All other settings (`work_dir`, `device`, `checkpoint_path`) are configured in the deploy config file. Inject wrapper classes and optional workflows when instantiating a runner; exporters are created lazily inside `BaseDeploymentRunner`. ## Documentation Map diff --git a/deployment/docs/usage.md b/deployment/docs/usage.md index f4851ef35..549a50529 100644 --- a/deployment/docs/usage.md +++ b/deployment/docs/usage.md @@ -71,10 +71,7 @@ Create custom contexts by subclassing `ExportContext` and adding dataclass field python deploy/main.py \ \ # Deployment configuration file \ # Model configuration file - [checkpoint] \ # Optional checkpoint path - --work-dir \ # Override work directory - --device \ # Override device - --log-level # DEBUG, INFO, WARNING, ERROR + --log-level # Optional: DEBUG, INFO, WARNING, ERROR (default: INFO) ``` ## Export Modes From de29a857fec3a4ab71b7669cf421771b6a8f6b8c Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 10:30:28 +0900 Subject: [PATCH 21/62] chore: remove unused runtime config Signed-off-by: vividf --- deployment/core/__init__.py | 13 +--- deployment/core/config/__init__.py | 12 +--- deployment/core/config/base_config.py | 17 +++-- deployment/core/config/runtime_config.py | 63 ------------------- .../runners/common/export_orchestrator.py | 6 +- 5 files changed, 14 insertions(+), 97 deletions(-) delete mode 100644 deployment/core/config/runtime_config.py diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py index a7bdfe598..777e8018a 100644 --- a/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -15,12 +15,6 @@ parse_base_args, setup_logging, ) -from deployment.core.config.runtime_config import ( - BaseRuntimeConfig, - ClassificationRuntimeConfig, - Detection2DRuntimeConfig, - Detection3DRuntimeConfig, -) from deployment.core.config.task_config import TaskConfig, TaskType from deployment.core.contexts import ( CalibrationExportContext, @@ -84,11 +78,8 @@ # Task configuration "TaskConfig", "TaskType", - # Runtime configurations (typed) - "BaseRuntimeConfig", - "Detection3DRuntimeConfig", - "Detection2DRuntimeConfig", - "ClassificationRuntimeConfig", + # Runtime configuration + "RuntimeConfig", # Constants "EVALUATION_DEFAULTS", "EvaluationDefaults", diff --git a/deployment/core/config/__init__.py b/deployment/core/config/__init__.py index 3403bf877..404d058ff 100644 --- a/deployment/core/config/__init__.py +++ b/deployment/core/config/__init__.py @@ -7,17 +7,12 @@ ExportConfig, ExportMode, PrecisionPolicy, + RuntimeConfig, VerificationConfig, VerificationScenario, parse_base_args, setup_logging, ) -from deployment.core.config.runtime_config import ( - BaseRuntimeConfig, - ClassificationRuntimeConfig, - Detection2DRuntimeConfig, - Detection3DRuntimeConfig, -) from deployment.core.config.task_config import TaskConfig, TaskType from deployment.core.evaluation.base_evaluator import EVALUATION_DEFAULTS, EvaluationDefaults @@ -34,10 +29,7 @@ "setup_logging", "EVALUATION_DEFAULTS", "EvaluationDefaults", - "BaseRuntimeConfig", - "ClassificationRuntimeConfig", - "Detection2DRuntimeConfig", - "Detection3DRuntimeConfig", + "RuntimeConfig", "TaskConfig", "TaskType", ] diff --git a/deployment/core/config/base_config.py b/deployment/core/config/base_config.py index 841d97e99..426922704 100644 --- a/deployment/core/config/base_config.py +++ b/deployment/core/config/base_config.py @@ -158,19 +158,16 @@ def get_cuda_device_index(self) -> Optional[int]: class RuntimeConfig: """Configuration for runtime I/O settings.""" - data: Mapping[str, Any] + info_file: str = "" + sample_idx: int = 0 @classmethod def from_dict(cls, config_dict: Dict[str, Any]) -> "RuntimeConfig": - return cls(MappingProxyType(dict(config_dict))) - - def get(self, key: str, default: Any = None) -> Any: - """Get a runtime configuration value.""" - return self.data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Dictionary-style access to runtime config.""" - return self.data[key] + """Create RuntimeConfig from dictionary.""" + return cls( + info_file=config_dict.get("info_file", ""), + sample_idx=config_dict.get("sample_idx", 0), + ) @dataclass(frozen=True) diff --git a/deployment/core/config/runtime_config.py b/deployment/core/config/runtime_config.py deleted file mode 100644 index baa627d1a..000000000 --- a/deployment/core/config/runtime_config.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Typed runtime configurations for different task types. - -This module provides task-specific typed configurations for runtime_io, -replacing the weakly-typed Dict[str, Any] access pattern. -""" - -from dataclasses import dataclass -from typing import Optional - - -@dataclass(frozen=True) -class BaseRuntimeConfig: - """Base configuration for all runtime settings.""" - - sample_idx: int = 0 - - -@dataclass(frozen=True) -class Detection3DRuntimeConfig(BaseRuntimeConfig): - """Runtime configuration for 3D detection tasks (e.g., CenterPoint).""" - - info_file: str = "" - - @classmethod - def from_dict(cls, config_dict: dict) -> "Detection3DRuntimeConfig": - """Create config from dictionary.""" - return cls( - sample_idx=config_dict.get("sample_idx", 0), - info_file=config_dict.get("info_file", ""), - ) - - -@dataclass(frozen=True) -class Detection2DRuntimeConfig(BaseRuntimeConfig): - """Runtime configuration for 2D detection tasks (e.g., YOLOX).""" - - ann_file: str = "" - img_prefix: str = "" - - @classmethod - def from_dict(cls, config_dict: dict) -> "Detection2DRuntimeConfig": - """Create config from dictionary.""" - return cls( - sample_idx=config_dict.get("sample_idx", 0), - ann_file=config_dict.get("ann_file", ""), - img_prefix=config_dict.get("img_prefix", ""), - ) - - -@dataclass(frozen=True) -class ClassificationRuntimeConfig(BaseRuntimeConfig): - """Runtime configuration for classification tasks.""" - - info_pkl: str = "" - - @classmethod - def from_dict(cls, config_dict: dict) -> "ClassificationRuntimeConfig": - """Create config from dictionary.""" - return cls( - sample_idx=config_dict.get("sample_idx", 0), - info_pkl=config_dict.get("info_pkl", ""), - ) diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index 421780888..daf7f4720 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -275,8 +275,8 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A raise RuntimeError("ONNX export requested but no wrapper class or workflow provided.") onnx_settings = self.config.get_onnx_settings() - # Use context.sample_idx, fallback to runtime config for backward compatibility - sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.get("sample_idx", 0) + # Use context.sample_idx, fallback to runtime config + sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.sample_idx # Save to work_dir/onnx/ directory onnx_dir = os.path.join(self.config.export_config.work_dir, self.ONNX_DIR_NAME) @@ -388,7 +388,7 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A self.logger.info(f"Using CUDA device for TensorRT export: {cuda_device}") # Get sample input for shape configuration - sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.get("sample_idx", 0) + 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) # Use workflow if available From 85f208dd885af6c0dbbd7540d0926d8e59967f6f Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 11:54:57 +0900 Subject: [PATCH 22/62] chore: remove unused class Signed-off-by: vividf --- deployment/core/__init__.py | 27 --- deployment/core/config/__init__.py | 3 - deployment/core/config/task_config.py | 105 ---------- deployment/core/evaluation/__init__.py | 20 -- deployment/core/evaluation/results.py | 273 ------------------------- deployment/docs/core_contract.md | 2 +- deployment/docs/overview.md | 2 +- 7 files changed, 2 insertions(+), 430 deletions(-) delete mode 100644 deployment/core/config/task_config.py delete mode 100644 deployment/core/evaluation/results.py diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py index 777e8018a..78bf9597e 100644 --- a/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -15,7 +15,6 @@ parse_base_args, setup_logging, ) -from deployment.core.config.task_config import TaskConfig, TaskType from deployment.core.contexts import ( CalibrationExportContext, CenterPointExportContext, @@ -31,17 +30,6 @@ TaskProfile, VerifyResultDict, ) -from deployment.core.evaluation.results import ( - ClassificationEvaluationMetrics, - ClassificationResult, - Detection2DEvaluationMetrics, - Detection2DResult, - Detection3DEvaluationMetrics, - Detection3DResult, - EvaluationMetrics, - LatencyStats, - StageLatencyBreakdown, -) 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 @@ -75,11 +63,6 @@ "VerificationScenario", "setup_logging", "parse_base_args", - # Task configuration - "TaskConfig", - "TaskType", - # Runtime configuration - "RuntimeConfig", # Constants "EVALUATION_DEFAULTS", "EvaluationDefaults", @@ -94,16 +77,6 @@ # Artifacts "Artifact", "ModelSpec", - # Results (typed) - "Detection3DResult", - "Detection2DResult", - "ClassificationResult", - "LatencyStats", - "StageLatencyBreakdown", - "EvaluationMetrics", - "Detection3DEvaluationMetrics", - "Detection2DEvaluationMetrics", - "ClassificationEvaluationMetrics", # Preprocessing "build_preprocessing_pipeline", # Metrics adapters (using autoware_perception_evaluation) diff --git a/deployment/core/config/__init__.py b/deployment/core/config/__init__.py index 404d058ff..3197eb73d 100644 --- a/deployment/core/config/__init__.py +++ b/deployment/core/config/__init__.py @@ -13,7 +13,6 @@ parse_base_args, setup_logging, ) -from deployment.core.config.task_config import TaskConfig, TaskType from deployment.core.evaluation.base_evaluator import EVALUATION_DEFAULTS, EvaluationDefaults __all__ = [ @@ -30,6 +29,4 @@ "EVALUATION_DEFAULTS", "EvaluationDefaults", "RuntimeConfig", - "TaskConfig", - "TaskType", ] diff --git a/deployment/core/config/task_config.py b/deployment/core/config/task_config.py deleted file mode 100644 index 012228364..000000000 --- a/deployment/core/config/task_config.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -Task configuration for unified pipeline configuration. - -This module provides task-specific configuration that can be passed to pipelines, -enabling a more unified approach while still supporting task-specific parameters. -""" - -from dataclasses import dataclass, field -from enum import Enum -from typing import List, Optional, Tuple - - -class TaskType(str, Enum): - """Supported task types.""" - - CLASSIFICATION = "classification" - DETECTION_2D = "detection2d" - DETECTION_3D = "detection3d" - - @classmethod - def from_value(cls, value: str) -> "TaskType": - """Parse string to TaskType.""" - normalized = value.strip().lower() - for member in cls: - if member.value == normalized: - return member - raise ValueError(f"Invalid task type '{value}'. Must be one of {[m.value for m in cls]}.") - - -@dataclass(frozen=True) -class TaskConfig: - """ - Task-specific configuration for deployment pipelines (immutable). - - This configuration encapsulates all task-specific parameters needed - by pipelines, enabling a more unified approach while still supporting - task-specific requirements. - """ - - task_type: TaskType - num_classes: int - class_names: Tuple[str, ...] - - # 2D Detection specific - input_size: Optional[Tuple[int, int]] = None - - # 3D Detection specific - point_cloud_range: Optional[Tuple[float, ...]] = None - voxel_size: Optional[Tuple[float, ...]] = None - - # Optional additional parameters - score_threshold: float = 0.01 - nms_threshold: float = 0.65 - max_detections: int = 300 - - @classmethod - def for_classification( - cls, - num_classes: int, - class_names: List[str], - ) -> "TaskConfig": - """Create configuration for classification tasks.""" - return cls( - task_type=TaskType.CLASSIFICATION, - num_classes=num_classes, - class_names=tuple(class_names), - ) - - @classmethod - def for_detection_2d( - cls, - num_classes: int, - class_names: List[str], - input_size: Tuple[int, int] = (960, 960), - score_threshold: float = 0.01, - nms_threshold: float = 0.65, - max_detections: int = 300, - ) -> "TaskConfig": - """Create configuration for 2D detection tasks.""" - return cls( - task_type=TaskType.DETECTION_2D, - num_classes=num_classes, - class_names=tuple(class_names), - input_size=input_size, - score_threshold=score_threshold, - nms_threshold=nms_threshold, - max_detections=max_detections, - ) - - @classmethod - def for_detection_3d( - cls, - num_classes: int, - class_names: List[str], - point_cloud_range: Optional[List[float]] = None, - voxel_size: Optional[List[float]] = None, - ) -> "TaskConfig": - """Create configuration for 3D detection tasks.""" - return cls( - task_type=TaskType.DETECTION_3D, - num_classes=num_classes, - class_names=tuple(class_names), - point_cloud_range=tuple(point_cloud_range) if point_cloud_range else None, - voxel_size=tuple(voxel_size) if voxel_size else None, - ) diff --git a/deployment/core/evaluation/__init__.py b/deployment/core/evaluation/__init__.py index 5238783c9..1125ce5e0 100644 --- a/deployment/core/evaluation/__init__.py +++ b/deployment/core/evaluation/__init__.py @@ -6,17 +6,6 @@ ModelSpec, VerifyResultDict, ) -from deployment.core.evaluation.results import ( - ClassificationEvaluationMetrics, - ClassificationResult, - Detection2DEvaluationMetrics, - Detection2DResult, - Detection3DEvaluationMetrics, - Detection3DResult, - EvaluationMetrics, - LatencyStats, - StageLatencyBreakdown, -) from deployment.core.evaluation.verification_mixin import VerificationMixin __all__ = [ @@ -25,14 +14,5 @@ "EvalResultDict", "ModelSpec", "VerifyResultDict", - "ClassificationResult", - "Detection2DResult", - "Detection3DResult", - "ClassificationEvaluationMetrics", - "Detection2DEvaluationMetrics", - "Detection3DEvaluationMetrics", - "EvaluationMetrics", - "LatencyStats", - "StageLatencyBreakdown", "VerificationMixin", ] diff --git a/deployment/core/evaluation/results.py b/deployment/core/evaluation/results.py deleted file mode 100644 index d5f7dae84..000000000 --- a/deployment/core/evaluation/results.py +++ /dev/null @@ -1,273 +0,0 @@ -""" -Typed result classes for deployment framework. - -This module provides strongly-typed result classes instead of Dict[str, Any], -enabling better IDE support and catching errors at development time. -""" - -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple - -import numpy as np - - -@dataclass(frozen=True) -class Detection3DResult: - """Result for a single 3D detection (immutable).""" - - bbox_3d: Tuple[float, ...] # [x, y, z, l, w, h, yaw] or with velocity [x, y, z, l, w, h, yaw, vx, vy] - score: float - label: int - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary format.""" - return { - "bbox_3d": list(self.bbox_3d), - "score": self.score, - "label": self.label, - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Detection3DResult": - """Create from dictionary.""" - return cls( - bbox_3d=tuple(data["bbox_3d"]), - score=data["score"], - label=data["label"], - ) - - -@dataclass(frozen=True) -class Detection2DResult: - """Result for a single 2D detection (immutable).""" - - bbox: Tuple[float, ...] # [x1, y1, x2, y2] - score: float - class_id: int - class_name: str = "" - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary format.""" - return { - "bbox": list(self.bbox), - "score": self.score, - "class_id": self.class_id, - "class_name": self.class_name, - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Detection2DResult": - """Create from dictionary.""" - return cls( - bbox=tuple(data["bbox"]), - score=data["score"], - class_id=data.get("class_id", data.get("label", 0)), - class_name=data.get("class_name", ""), - ) - - -@dataclass(frozen=True) -class ClassificationResult: - """Result for a classification prediction.""" - - class_id: int - class_name: str - confidence: float - probabilities: np.ndarray - top_k: List[Dict[str, Any]] = field(default_factory=list) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary format.""" - return { - "class_id": self.class_id, - "class_name": self.class_name, - "confidence": self.confidence, - "probabilities": self.probabilities, - "top_k": self.top_k, - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ClassificationResult": - """Create from dictionary.""" - return cls( - class_id=data["class_id"], - class_name=data["class_name"], - confidence=data["confidence"], - probabilities=data["probabilities"], - top_k=data.get("top_k", []), - ) - - -@dataclass(frozen=True) -class LatencyStats: - """Latency statistics (immutable).""" - - mean_ms: float - std_ms: float - min_ms: float - max_ms: float - median_ms: float - - def to_dict(self) -> Dict[str, float]: - """Convert to dictionary format.""" - return { - "mean_ms": self.mean_ms, - "std_ms": self.std_ms, - "min_ms": self.min_ms, - "max_ms": self.max_ms, - "median_ms": self.median_ms, - } - - @classmethod - def from_latencies(cls, latencies: List[float]) -> "LatencyStats": - """Compute statistics from a list of latency values.""" - if not latencies: - return cls(0.0, 0.0, 0.0, 0.0, 0.0) - - arr = np.array(latencies) - return cls( - 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)), - ) - - -@dataclass(frozen=True) -class StageLatencyBreakdown: - """Latency breakdown by inference stage (immutable).""" - - preprocessing_ms: Optional[LatencyStats] = None - voxel_encoder_ms: Optional[LatencyStats] = None - middle_encoder_ms: Optional[LatencyStats] = None - backbone_head_ms: Optional[LatencyStats] = None - postprocessing_ms: Optional[LatencyStats] = None - model_ms: Optional[LatencyStats] = None - - def to_dict(self) -> Dict[str, Dict[str, float]]: - """Convert to dictionary format.""" - result = {} - if self.preprocessing_ms: - result["preprocessing_ms"] = self.preprocessing_ms.to_dict() - if self.voxel_encoder_ms: - result["voxel_encoder_ms"] = self.voxel_encoder_ms.to_dict() - if self.middle_encoder_ms: - result["middle_encoder_ms"] = self.middle_encoder_ms.to_dict() - if self.backbone_head_ms: - result["backbone_head_ms"] = self.backbone_head_ms.to_dict() - if self.postprocessing_ms: - result["postprocessing_ms"] = self.postprocessing_ms.to_dict() - if self.model_ms: - result["model_ms"] = self.model_ms.to_dict() - return result - - -@dataclass(frozen=True) -class EvaluationMetrics: - """Base class for evaluation metrics.""" - - num_samples: int - latency: LatencyStats - latency_breakdown: Optional[StageLatencyBreakdown] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary format.""" - result = { - "num_samples": self.num_samples, - "latency": self.latency.to_dict(), - } - if self.latency_breakdown: - result["latency_breakdown"] = self.latency_breakdown.to_dict() - return result - - -@dataclass(frozen=True) -class Detection3DEvaluationMetrics(EvaluationMetrics): - """Evaluation metrics for 3D detection.""" - - mAP: float = 0.0 - mAPH: float = 0.0 - per_class_ap: Dict[str, float] = field(default_factory=dict) - total_predictions: int = 0 - total_ground_truths: int = 0 - per_class_predictions: Dict[int, int] = field(default_factory=dict) - per_class_ground_truths: Dict[int, int] = field(default_factory=dict) - detailed_metrics: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary format.""" - result = super().to_dict() - result.update( - { - "mAP": self.mAP, - "mAPH": self.mAPH, - "per_class_ap": self.per_class_ap, - "total_predictions": self.total_predictions, - "total_ground_truths": self.total_ground_truths, - "per_class_predictions": self.per_class_predictions, - "per_class_ground_truths": self.per_class_ground_truths, - "detailed_metrics": self.detailed_metrics, - } - ) - return result - - -@dataclass(frozen=True) -class Detection2DEvaluationMetrics(EvaluationMetrics): - """Evaluation metrics for 2D detection.""" - - mAP: float = 0.0 - mAP_50: float = 0.0 - mAP_75: float = 0.0 - per_class_ap: Dict[str, float] = field(default_factory=dict) - detailed_metrics: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary format.""" - result = super().to_dict() - result.update( - { - "mAP": self.mAP, - "mAP_50": self.mAP_50, - "mAP_75": self.mAP_75, - "per_class_ap": self.per_class_ap, - "detailed_metrics": self.detailed_metrics, - } - ) - return result - - -@dataclass(frozen=True) -class ClassificationEvaluationMetrics(EvaluationMetrics): - """Evaluation metrics for classification.""" - - accuracy: float = 0.0 - precision: float = 0.0 - recall: float = 0.0 - f1score: float = 0.0 - correct_predictions: int = 0 - total_samples: int = 0 - per_class_accuracy: Dict[str, float] = field(default_factory=dict) - per_class_count: Dict[int, int] = field(default_factory=dict) - confusion_matrix: List[List[int]] = field(default_factory=list) - detailed_metrics: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary format.""" - result = super().to_dict() - result.update( - { - "accuracy": self.accuracy, - "precision": self.precision, - "recall": self.recall, - "f1score": self.f1score, - "correct_predictions": self.correct_predictions, - "total_samples": self.total_samples, - "per_class_accuracy": self.per_class_accuracy, - "per_class_count": self.per_class_count, - "confusion_matrix": self.confusion_matrix, - "detailed_metrics": self.detailed_metrics, - } - ) - return result diff --git a/deployment/docs/core_contract.md b/deployment/docs/core_contract.md index 099a4a7c9..d1aa78849 100644 --- a/deployment/docs/core_contract.md +++ b/deployment/docs/core_contract.md @@ -44,7 +44,7 @@ This document defines the responsibilities and boundaries between the primary de ### Metrics Adapters (Autoware-based adapters) - 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. -- Output typed metric structures (`Detection3DEvaluationMetrics`, `Detection2DEvaluationMetrics`, `ClassificationEvaluationMetrics`). +- 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 diff --git a/deployment/docs/overview.md b/deployment/docs/overview.md index a32521162..ce058bf2f 100644 --- a/deployment/docs/overview.md +++ b/deployment/docs/overview.md @@ -41,7 +41,7 @@ verification = dict( ### Multi-Backend Evaluation -Evaluators share typed metrics (`Detection3DEvaluationMetrics`, `Detection2DEvaluationMetrics`, `ClassificationEvaluationMetrics`) so reports remain consistent across backends. +Evaluators return typed results via `EvalResultDict` (TypedDict) ensuring consistent structure across backends. Metrics adapters (`Detection3DMetricsAdapter`, `Detection2DMetricsAdapter`, `ClassificationMetricsAdapter`) compute task-specific metrics using `autoware_perception_evaluation`. ### Pipeline Architecture From 9fe175cfc7046f19cd9ecf8cc36a8643ab468fa7 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 13:25:56 +0900 Subject: [PATCH 23/62] chore: fix context Signed-off-by: vividf --- deployment/core/contexts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/core/contexts.py b/deployment/core/contexts.py index 80c8c1441..6fd0428b6 100644 --- a/deployment/core/contexts.py +++ b/deployment/core/contexts.py @@ -62,7 +62,7 @@ class YOLOXExportContext(ExportContext): to extract from model_cfg.filename. """ - model_cfg_path: Optional[str] = None + model_cfg: Optional[str] = None @dataclass(frozen=True) From 93de7922bd8638f29130aee6f27da60d3c08683c Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 4 Dec 2025 17:04:59 +0900 Subject: [PATCH 24/62] chore: type hint for base config Signed-off-by: vividf --- deployment/core/config/base_config.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/deployment/core/config/base_config.py b/deployment/core/config/base_config.py index 426922704..d19c5a226 100644 --- a/deployment/core/config/base_config.py +++ b/deployment/core/config/base_config.py @@ -5,6 +5,8 @@ Task-specific deployment configs should extend BaseDeploymentConfig. """ +from __future__ import annotations + import argparse import logging from dataclasses import dataclass, field @@ -49,7 +51,7 @@ class ExportMode(str, Enum): NONE = "none" @classmethod - def from_value(cls, value: Optional[Union[str, "ExportMode"]]) -> "ExportMode": + 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 @@ -81,7 +83,7 @@ class ExportConfig: onnx_path: Optional[str] = None @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> "ExportConfig": + def from_dict(cls, config_dict: Dict[str, Any]) -> ExportConfig: """Create ExportConfig from dict.""" return cls( mode=ExportMode.from_value(config_dict.get("mode", ExportMode.BOTH)), @@ -237,12 +239,12 @@ class VerificationConfig: 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) + scenarios: Mapping[ExportMode, Tuple[VerificationScenario, ...]] = field(default_factory=_empty_mapping) @classmethod def from_dict(cls, config_dict: Dict[str, Any]) -> "VerificationConfig": scenarios_raw = config_dict.get("scenarios", {}) or {} - scenario_map: Dict[ExportMode, Tuple["VerificationScenario", ...]] = {} + 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 [])) @@ -256,7 +258,7 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "VerificationConfig": scenarios=MappingProxyType(scenario_map), ) - def get_scenarios(self, mode: ExportMode) -> Tuple["VerificationScenario", ...]: + def get_scenarios(self, mode: ExportMode) -> Tuple[VerificationScenario, ...]: """Return scenarios for a specific export mode.""" return self.scenarios.get(mode, ()) @@ -271,7 +273,7 @@ class VerificationScenario: test_device: str @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "VerificationScenario": + def from_dict(cls, data: Dict[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}") From be694e20a8c10b42018a3016d156c8431e3a98e4 Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 4 Dec 2025 17:21:16 +0900 Subject: [PATCH 25/62] chore fix more dict/mapping and type hint Signed-off-by: vividf --- deployment/core/backend.py | 2 +- deployment/core/config/base_config.py | 22 +++++++++---------- deployment/core/contexts.py | 5 +++-- deployment/core/evaluation/base_evaluator.py | 10 +++++---- deployment/core/evaluation/evaluator_types.py | 2 ++ .../core/evaluation/verification_mixin.py | 10 +++++---- deployment/core/io/base_data_loader.py | 8 ++++--- deployment/core/io/preprocessing_builder.py | 8 ++++--- deployment/exporters/common/configs.py | 8 +++---- .../runners/common/evaluation_orchestrator.py | 6 +++-- .../runners/common/export_orchestrator.py | 4 ++-- .../common/verification_orchestrator.py | 6 +++-- 12 files changed, 53 insertions(+), 38 deletions(-) diff --git a/deployment/core/backend.py b/deployment/core/backend.py index 584a15d7a..87cfc3109 100644 --- a/deployment/core/backend.py +++ b/deployment/core/backend.py @@ -14,7 +14,7 @@ class Backend(str, Enum): TENSORRT = "tensorrt" @classmethod - def from_value(cls, value: Union[str, "Backend"]) -> "Backend": + def from_value(cls, value: Union[str, Backend]) -> Backend: """ Normalize backend identifiers coming from configs or enums. diff --git a/deployment/core/config/base_config.py b/deployment/core/config/base_config.py index d19c5a226..16cb04c89 100644 --- a/deployment/core/config/base_config.py +++ b/deployment/core/config/base_config.py @@ -83,7 +83,7 @@ class ExportConfig: onnx_path: Optional[str] = None @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> ExportConfig: + 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)), @@ -112,7 +112,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "cuda", self._normalize_cuda(self.cuda)) @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> "DeviceConfig": + 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)) @@ -164,7 +164,7 @@ class RuntimeConfig: sample_idx: int = 0 @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> "RuntimeConfig": + def from_dict(cls, config_dict: Mapping[str, Any]) -> RuntimeConfig: """Create RuntimeConfig from dictionary.""" return cls( info_file=config_dict.get("info_file", ""), @@ -180,9 +180,9 @@ class BackendConfig: model_inputs: Tuple[TensorRTModelInputConfig, ...] = field(default_factory=tuple) @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> "BackendConfig": + def from_dict(cls, config_dict: Mapping[str, Any]) -> BackendConfig: common_config = dict(config_dict.get("common_config", {})) - model_inputs_raw: Iterable[Dict[str, Any]] = config_dict.get("model_inputs", []) or [] + 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 ) @@ -195,7 +195,7 @@ 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) -> Dict[str, bool]: + 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, {}) @@ -217,7 +217,7 @@ class EvaluationConfig: devices: Mapping[str, str] = field(default_factory=_empty_mapping) @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> "EvaluationConfig": + 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()} @@ -242,7 +242,7 @@ class VerificationConfig: scenarios: Mapping[ExportMode, Tuple[VerificationScenario, ...]] = field(default_factory=_empty_mapping) @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> "VerificationConfig": + 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(): @@ -273,7 +273,7 @@ class VerificationScenario: test_device: str @classmethod - def from_dict(cls, data: Dict[str, Any]) -> VerificationScenario: + 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}") @@ -411,7 +411,7 @@ def evaluation_config(self) -> EvaluationConfig: return self._evaluation_config @property - def onnx_config(self) -> Dict: + def onnx_config(self) -> Mapping[str, Any]: """Get ONNX configuration.""" return self.deploy_cfg.get("onnx_config", {}) @@ -425,7 +425,7 @@ def devices(self) -> DeviceConfig: """Get normalized device settings.""" return self._device_config - def get_evaluation_backends(self) -> Dict[str, Dict[str, Any]]: + def get_evaluation_backends(self) -> Mapping[Any, Mapping[str, Any]]: """ Get evaluation backends configuration. diff --git a/deployment/core/contexts.py b/deployment/core/contexts.py index 6fd0428b6..486caa1e5 100644 --- a/deployment/core/contexts.py +++ b/deployment/core/contexts.py @@ -27,7 +27,8 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, Optional +from types import MappingProxyType +from typing import Any, Mapping, Optional @dataclass(frozen=True) @@ -45,7 +46,7 @@ class ExportContext: """ sample_idx: int = 0 - extra: Dict[str, Any] = field(default_factory=dict) + 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.""" diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py index bd1a31e7a..18c7d1764 100644 --- a/deployment/core/evaluation/base_evaluator.py +++ b/deployment/core/evaluation/base_evaluator.py @@ -10,10 +10,12 @@ 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, Optional, Tuple +from typing import Any, Dict, List, Mapping, Optional, Tuple import numpy as np import torch @@ -145,7 +147,7 @@ def _create_pipeline(self, model_spec: ModelSpec, device: str) -> Any: @abstractmethod def _prepare_input( self, - sample: Dict[str, Any], + sample: Mapping[str, Any], data_loader: BaseDataLoader, device: str, ) -> Tuple[Any, Dict[str, Any]]: @@ -158,7 +160,7 @@ def _parse_predictions(self, pipeline_output: Any) -> Any: raise NotImplementedError @abstractmethod - def _parse_ground_truths(self, gt_data: Dict[str, Any]) -> Any: + def _parse_ground_truths(self, gt_data: Mapping[str, Any]) -> Any: """Extract ground truth from sample data.""" raise NotImplementedError @@ -300,7 +302,7 @@ def _compute_latency_breakdown( for stage in sorted(all_stages) } - def format_latency_stats(self, stats: Dict[str, float]) -> str: + def format_latency_stats(self, stats: Mapping[str, float]) -> str: """Format latency statistics as a readable string.""" return ( f"Latency: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms " diff --git a/deployment/core/evaluation/evaluator_types.py b/deployment/core/evaluation/evaluator_types.py index b889f00cd..04f9f59d4 100644 --- a/deployment/core/evaluation/evaluator_types.py +++ b/deployment/core/evaluation/evaluator_types.py @@ -5,6 +5,8 @@ runners, and orchestrators. """ +from __future__ import annotations + from dataclasses import dataclass from typing import Any, Dict, TypedDict diff --git a/deployment/core/evaluation/verification_mixin.py b/deployment/core/evaluation/verification_mixin.py index 05afb7533..c8ead03cb 100644 --- a/deployment/core/evaluation/verification_mixin.py +++ b/deployment/core/evaluation/verification_mixin.py @@ -5,10 +5,12 @@ 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, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union import numpy as np import torch @@ -26,7 +28,7 @@ class ComparisonResult: max_diff: float mean_diff: float num_elements: int = 0 - details: Tuple[Tuple[str, "ComparisonResult"], ...] = () + details: Tuple[Tuple[str, ComparisonResult], ...] = () def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization.""" @@ -144,8 +146,8 @@ def _compare_outputs( def _compare_dicts( self, - reference: Dict[str, Any], - test: Dict[str, Any], + reference: Mapping[str, Any], + test: Mapping[str, Any], tolerance: float, logger: logging.Logger, path: str, diff --git a/deployment/core/io/base_data_loader.py b/deployment/core/io/base_data_loader.py index 58e7d321f..bdc94c066 100644 --- a/deployment/core/io/base_data_loader.py +++ b/deployment/core/io/base_data_loader.py @@ -5,8 +5,10 @@ a concrete DataLoader that extends this base class. """ +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Any, Dict, TypedDict +from typing import Any, Dict, Mapping, TypedDict import torch @@ -35,7 +37,7 @@ class BaseDataLoader(ABC): it into a format suitable for model inference. """ - def __init__(self, config: Dict[str, Any]): + def __init__(self, config: Mapping[str, Any]): """ Initialize data loader. @@ -93,7 +95,7 @@ def get_num_samples(self) -> int: raise NotImplementedError @abstractmethod - def get_ground_truth(self, index: int) -> Dict[str, Any]: + def get_ground_truth(self, index: int) -> Mapping[str, Any]: """ Get ground truth annotations for a specific sample. diff --git a/deployment/core/io/preprocessing_builder.py b/deployment/core/io/preprocessing_builder.py index 52912b8bf..1fed7f8c9 100644 --- a/deployment/core/io/preprocessing_builder.py +++ b/deployment/core/io/preprocessing_builder.py @@ -7,14 +7,16 @@ This module is compatible with the BaseDeploymentPipeline. """ +from __future__ import annotations + import logging -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, List, Mapping, Optional from mmengine.config import Config logger = logging.getLogger(__name__) -TransformConfig = Dict[str, Any] +TransformConfig = Mapping[str, Any] PipelineBuilder = Callable[[List[TransformConfig]], Any] @@ -121,7 +123,7 @@ def _build_segmentation(pipeline_cfg: List[TransformConfig]) -> Any: ) -_PIPELINE_BUILDERS: Dict[str, PipelineBuilder] = { +_PIPELINE_BUILDERS: Mapping[str, PipelineBuilder] = { "detection2d": _build_detection2d, "detection3d": _build_detection3d, "classification": _build_classification, diff --git a/deployment/exporters/common/configs.py b/deployment/exporters/common/configs.py index 364e28ed3..76d6bc4b1 100644 --- a/deployment/exporters/common/configs.py +++ b/deployment/exporters/common/configs.py @@ -21,7 +21,7 @@ class TensorRTProfileConfig: max_shape: Tuple[int, ...] = field(default_factory=tuple) @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> "TensorRTProfileConfig": + 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")), @@ -45,7 +45,7 @@ class TensorRTModelInputConfig: input_shapes: Mapping[str, TensorRTProfileConfig] = field(default_factory=_empty_mapping) @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> "TensorRTModelInputConfig": + 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() @@ -96,7 +96,7 @@ class ONNXExportConfig(BaseExporterConfig): batch_size: Optional[int] = None @classmethod - def from_mapping(cls, data: Mapping[str, Any]) -> "ONNXExportConfig": + 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)), @@ -131,7 +131,7 @@ class TensorRTExportConfig(BaseExporterConfig): model_inputs: Tuple[TensorRTModelInputConfig, ...] = field(default_factory=tuple) @classmethod - def from_mapping(cls, data: Mapping[str, Any]) -> "TensorRTExportConfig": + 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( diff --git a/deployment/runners/common/evaluation_orchestrator.py b/deployment/runners/common/evaluation_orchestrator.py index f887d9a67..d6a4ff807 100644 --- a/deployment/runners/common/evaluation_orchestrator.py +++ b/deployment/runners/common/evaluation_orchestrator.py @@ -4,8 +4,10 @@ This module handles cross-backend evaluation with consistent metrics. """ +from __future__ import annotations + import logging -from typing import Any, Dict, List +from typing import Any, Dict, List, Mapping from deployment.core.backend import Backend from deployment.core.config.base_config import BaseDeploymentConfig @@ -190,7 +192,7 @@ def _get_default_device(self, backend: Backend) -> str: return self.config.devices.cuda or "cuda:0" return self.config.devices.cpu or "cpu" - def _print_cross_backend_comparison(self, all_results: Dict[str, Any]) -> None: + def _print_cross_backend_comparison(self, all_results: Mapping[str, Any]) -> None: """ Print cross-backend comparison of metrics. diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index daf7f4720..4ae9de26d 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -10,7 +10,7 @@ import logging import os from dataclasses import dataclass -from typing import Any, Callable, Dict, Optional, Type +from typing import Any, Callable, Mapping, Optional, Type import torch @@ -507,7 +507,7 @@ def _resolve_and_register_artifact( self.logger.warning(f"{backend.value} file from config does not exist: {artifact_path}") @staticmethod - def _get_backend_entry(mapping: Optional[Dict[Any, Any]], backend: Backend) -> Any: + def _get_backend_entry(mapping: Optional[Mapping[Any, Any]], backend: Backend) -> Any: """ Fetch a config value that may be keyed by either string literals or Backend enums. """ diff --git a/deployment/runners/common/verification_orchestrator.py b/deployment/runners/common/verification_orchestrator.py index 718c87344..ac3cf40db 100644 --- a/deployment/runners/common/verification_orchestrator.py +++ b/deployment/runners/common/verification_orchestrator.py @@ -4,8 +4,10 @@ This module handles scenario-based verification across different backends. """ +from __future__ import annotations + import logging -from typing import Any, Dict +from typing import Any, Dict, Mapping from deployment.core.backend import Backend from deployment.core.config.base_config import BaseDeploymentConfig @@ -170,7 +172,7 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: return all_results - def _resolve_device(self, device_key: str, devices_map: Dict[str, str]) -> str: + def _resolve_device(self, device_key: str, devices_map: Mapping[str, str]) -> str: """ Resolve device using alias system. From 46fbc5af96f97ed75d5c808452a08a493b5b040d Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 4 Dec 2025 18:16:14 +0900 Subject: [PATCH 26/62] chore: clean up compose builder Signed-off-by: vividf --- deployment/core/io/preprocessing_builder.py | 84 ++++++--------------- 1 file changed, 23 insertions(+), 61 deletions(-) diff --git a/deployment/core/io/preprocessing_builder.py b/deployment/core/io/preprocessing_builder.py index 1fed7f8c9..6ad682e19 100644 --- a/deployment/core/io/preprocessing_builder.py +++ b/deployment/core/io/preprocessing_builder.py @@ -10,14 +10,15 @@ from __future__ import annotations import logging -from typing import Any, Callable, List, Mapping, Optional +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] -PipelineBuilder = Callable[[List[TransformConfig]], Any] class ComposeBuilder: @@ -57,16 +58,6 @@ def build( f"Please ensure the required package is installed. Error: {e}" ) from e - # Import MMEngine components - try: - from mmengine.dataset import Compose - from mmengine.registry import init_default_scope - except ImportError as e: - raise ImportError( - f"Failed to import mmengine components for scope '{scope}'. " - f"Please ensure mmengine is installed. Error: {e}" - ) from e - # Set default scope and build Compose try: init_default_scope(scope) @@ -82,65 +73,36 @@ def build( ) from e -def _build_detection2d(pipeline_cfg: List[TransformConfig]) -> Any: - """Build 2D detection preprocessing pipeline.""" - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmdet", - import_modules=["mmdet.datasets.transforms"], - ) - - -def _build_detection3d(pipeline_cfg: List[TransformConfig]) -> Any: - """Build 3D detection preprocessing pipeline.""" - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmdet3d", - import_modules=["mmdet3d.datasets.transforms"], - ) - - -def _build_classification(pipeline_cfg: List[TransformConfig]) -> Any: - """ - Build classification preprocessing pipeline using mmpretrain. - - Raises: - ImportError: If mmpretrain is not installed - """ - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmpretrain", - import_modules=["mmpretrain.datasets.transforms"], - ) - - -def _build_segmentation(pipeline_cfg: List[TransformConfig]) -> Any: - """Build segmentation preprocessing pipeline.""" - return ComposeBuilder.build( - pipeline_cfg=pipeline_cfg, - scope="mmseg", - import_modules=["mmseg.datasets.transforms"], - ) - - -_PIPELINE_BUILDERS: Mapping[str, PipelineBuilder] = { - "detection2d": _build_detection2d, - "detection3d": _build_detection3d, - "classification": _build_classification, - "segmentation": _build_segmentation, +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(_PIPELINE_BUILDERS.keys()) +VALID_TASK_TYPES = list(TASK_PIPELINE_CONFIGS.keys()) def _build_pipeline(task_type: str, pipeline_cfg: List[TransformConfig]) -> Any: """Build pipeline for a given task_type using registered builders.""" try: - builder = _PIPELINE_BUILDERS[task_type] + 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 builder(pipeline_cfg) + return ComposeBuilder.build(pipeline_cfg=pipeline_cfg, **task_cfg) def build_preprocessing_pipeline( From bde46dccb14fc0b1eb7dd2cf8573c278354c878b Mon Sep 17 00:00:00 2001 From: vividf Date: Thu, 4 Dec 2025 20:11:50 +0900 Subject: [PATCH 27/62] chore: refactor classification metrics Signed-off-by: vividf --- .../core/metrics/classification_metrics.py | 464 ++++++++++-------- 1 file changed, 257 insertions(+), 207 deletions(-) diff --git a/deployment/core/metrics/classification_metrics.py b/deployment/core/metrics/classification_metrics.py index 325d0449f..c635fde30 100644 --- a/deployment/core/metrics/classification_metrics.py +++ b/deployment/core/metrics/classification_metrics.py @@ -11,53 +11,113 @@ ) adapter = ClassificationMetricsAdapter(config) - # Add frames - for pred_label, gt_label, probs in zip(predictions, ground_truths, probabilities): - adapter.add_frame( - prediction=pred_label, # int (class index) - ground_truth=gt_label, # int (class index) - probabilities=probs, # List[float] (optional) - ) + for pred_label, gt_label in zip(predictions, ground_truths): + adapter.add_frame(prediction=pred_label, ground_truth=gt_label) - # Compute metrics metrics = adapter.compute_metrics() # Returns: {"accuracy": 0.95, "precision": 0.94, "recall": 0.96, "f1score": 0.95, ...} """ import logging -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple +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_adapter import BaseMetricsAdapter, BaseMetricsConfig 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 (e.g., ["miscalibrated", "calibrated"]). - frame_id: Frame ID for evaluation (not used for classification but kept for consistency). + 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. """ - # Override default frame_id for classification (not actually used but kept for interface consistency) - frame_id: str = "classification" + 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}" + ) -class ClassificationMetricsAdapter(BaseMetricsAdapter): - """ - Adapter for computing classification metrics. + 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, + }, + ) - This adapter provides a simplified interface for the deployment framework to - compute accuracy, precision, recall, F1, and per-class metrics for classification - tasks (e.g., calibration status classification). + 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, + }, + ) - The adapter accumulates predictions and ground truths, then computes metrics - using formulas consistent with autoware_perception_evaluation's ClassificationMetricsScore. + +class ClassificationMetricsAdapter(BaseMetricsAdapter): + """Adapter for computing classification metrics using autoware_perception_evaluation. Metrics computed: - Accuracy: TP / (num_predictions + num_gt - TP) @@ -65,48 +125,80 @@ class ClassificationMetricsAdapter(BaseMetricsAdapter): - Recall: TP / num_gt - F1 Score: 2 * precision * recall / (precision + recall) - Per-class accuracy, precision, recall, F1 - - Example usage: - config = ClassificationMetricsConfig( - class_names=["miscalibrated", "calibrated"], - ) - adapter = ClassificationMetricsAdapter(config) - - # Add frames - for pred_label, gt_label, probs in zip(predictions, ground_truths, probabilities): - adapter.add_frame( - prediction=pred_label, - ground_truth=gt_label, - probabilities=probs, - ) - - # Compute metrics - metrics = adapter.compute_metrics() """ - def __init__(self, config: ClassificationMetricsConfig): - """ - Initialize the classification metrics adapter. + def __init__( + self, + config: ClassificationMetricsConfig, + data_root: str = "data/t4dataset/", + result_root_directory: str = "/tmp/perception_eval_classification/", + ): + """Initialize the classification metrics adapter. 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.num_classes = len(config.class_names) - # Storage for accumulated results - self._predictions: List[int] = [] - self._ground_truths: List[int] = [] - self._probabilities: List[List[float]] = [] + 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 adapter for a new evaluation session.""" - self._predictions = [] - self._ground_truths = [] - self._probabilities = [] + 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, @@ -120,176 +212,139 @@ def add_frame( prediction: Predicted class index. ground_truth: Ground truth class index. probabilities: Optional probability scores for each class. - frame_name: Optional name for the frame (not used but kept for consistency). + frame_name: Optional name for the frame. """ - self._predictions.append(prediction) - self._ground_truths.append(ground_truth) - if probabilities is not None: - self._probabilities.append(probabilities) - self._frame_count += 1 + if self.evaluator is None: + self.reset() - def compute_metrics(self) -> Dict[str, float]: - """Compute metrics from all added predictions. + unix_time = int(time.time() * 1e6) + if frame_name is None: + frame_name = str(self._frame_count) - Returns: - Dictionary of metrics including: - - accuracy: Overall accuracy - - precision: Overall precision - - recall: Overall recall - - f1score: Overall F1 score - - {class_name}_accuracy: Per-class accuracy - - {class_name}_precision: Per-class precision - - {class_name}_recall: Per-class recall - - {class_name}_f1score: Per-class F1 score - """ - if self._frame_count == 0: - logger.warning("No samples to evaluate") - return {} - - predictions = np.array(self._predictions) - ground_truths = np.array(self._ground_truths) + # Get confidence score from probabilities if available + score = 1.0 + if probabilities is not None and len(probabilities) > prediction: + score = float(probabilities[prediction]) - metrics = {} + # 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 + ) - # Compute overall metrics - overall_accuracy, overall_precision, overall_recall, overall_f1 = self._compute_overall_metrics( - predictions, ground_truths + frame_ground_truth = FrameGroundTruth( + unix_time=unix_time, + frame_name=frame_name, + objects=[gt_object], + transforms=None, + raw_data=None, ) - metrics["accuracy"] = overall_accuracy - metrics["precision"] = overall_precision - metrics["recall"] = overall_recall - metrics["f1score"] = overall_f1 - - # Compute per-class metrics - for class_idx, class_name in enumerate(self.class_names): - class_metrics = self._compute_class_metrics(predictions, ground_truths, class_idx) - metrics[f"{class_name}_accuracy"] = class_metrics["accuracy"] - metrics[f"{class_name}_precision"] = class_metrics["precision"] - metrics[f"{class_name}_recall"] = class_metrics["recall"] - metrics[f"{class_name}_f1score"] = class_metrics["f1score"] - metrics[f"{class_name}_tp"] = class_metrics["tp"] - metrics[f"{class_name}_fp"] = class_metrics["fp"] - metrics[f"{class_name}_fn"] = class_metrics["fn"] - metrics[f"{class_name}_num_gt"] = class_metrics["num_gt"] - - # Add total counts - metrics["total_samples"] = len(predictions) - metrics["correct_predictions"] = int((predictions == ground_truths).sum()) - - return metrics - - def _compute_overall_metrics( - self, - predictions: np.ndarray, - ground_truths: np.ndarray, - ) -> Tuple[float, float, float, float]: - """Compute overall metrics following autoware_perception_evaluation formulas. - The formulas follow ClassificationMetricsScore._summarize() from - autoware_perception_evaluation. + 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}") - Args: - predictions: Array of predicted class indices. - ground_truths: Array of ground truth class indices. + def compute_metrics(self) -> Dict[str, float]: + """Compute metrics from all added predictions. Returns: - Tuple of (accuracy, precision, recall, f1score). + Dictionary of metrics including accuracy, precision, recall, f1score, + and per-class metrics. """ - num_est = len(predictions) - num_gt = len(ground_truths) - - # Count TP (correct predictions) and FP (incorrect predictions) - num_tp = int((predictions == ground_truths).sum()) - num_fp = num_est - num_tp - - # Accuracy formula from autoware_perception_evaluation: - # accuracy = num_tp / (num_est + num_gt - num_tp) - # This is equivalent to Jaccard index / IoU - denominator = num_est + num_gt - num_tp - accuracy = num_tp / denominator if denominator != 0 else 0.0 - - # Precision = TP / (TP + FP) - precision = num_tp / (num_tp + num_fp) if (num_tp + num_fp) != 0 else 0.0 - - # Recall = TP / num_gt - recall = num_tp / num_gt if num_gt != 0 else 0.0 - - # F1 = 2 * precision * recall / (precision + recall) - f1score = 2 * precision * recall / (precision + recall) if (precision + recall) != 0 else 0.0 + if self.evaluator is None or self._frame_count == 0: + logger.warning("No samples to evaluate") + return {} - return accuracy, precision, recall, f1score + 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 - def _compute_class_metrics( - self, - predictions: np.ndarray, - ground_truths: np.ndarray, - class_idx: int, - ) -> Dict[str, float]: - """Compute metrics for a single class. + traceback.print_exc() + return {} - Args: - predictions: Array of predicted class indices. - ground_truths: Array of ground truth class indices. - class_idx: Class index to compute metrics for. + 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: - Dictionary with accuracy, precision, recall, f1score, tp, fp, fn, num_gt. + 2D numpy array where cm[i][j] = count of ground truth i predicted as j. """ - # For binary per-class evaluation: - # - TP: predicted class_idx and ground truth is class_idx - # - FP: predicted class_idx but ground truth is not class_idx - # - FN: not predicted class_idx but ground truth is class_idx - - pred_is_class = predictions == class_idx - gt_is_class = ground_truths == class_idx - - tp = int((pred_is_class & gt_is_class).sum()) - fp = int((pred_is_class & ~gt_is_class).sum()) - fn = int((~pred_is_class & gt_is_class).sum()) - num_gt = int(gt_is_class.sum()) - num_pred = int(pred_is_class.sum()) - - # Precision for this class - precision = tp / (tp + fp) if (tp + fp) != 0 else 0.0 - - # Recall for this class - recall = tp / num_gt if num_gt != 0 else 0.0 + 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) - # F1 for this class - f1score = 2 * precision * recall / (precision + recall) if (precision + recall) != 0 else 0.0 + confusion_matrix = np.zeros((num_classes, num_classes), dtype=int) - # Accuracy for this class (matching autoware_perception_evaluation formula) - denominator = num_pred + num_gt - tp - accuracy = tp / denominator if denominator != 0 else 0.0 + for frame_result in self.evaluator.frame_results: + if not frame_result.object_results: + continue - return { - "accuracy": accuracy, - "precision": precision, - "recall": recall, - "f1score": f1score, - "tp": tp, - "fp": fp, - "fn": fn, - "num_gt": num_gt, - } - - def get_confusion_matrix(self) -> np.ndarray: - """Get the confusion matrix. + for obj_result in frame_result.object_results: + if obj_result.ground_truth_object is None: + continue - Returns: - 2D numpy array where cm[i][j] = count of samples with ground truth i - predicted as class j. - """ - if self._frame_count == 0: - return np.zeros((self.num_classes, self.num_classes), dtype=int) + pred_name = obj_result.estimated_object.semantic_label.name + gt_name = obj_result.ground_truth_object.semantic_label.name - predictions = np.array(self._predictions) - ground_truths = np.array(self._ground_truths) + # 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, + ) - confusion_matrix = np.zeros((self.num_classes, self.num_classes), dtype=int) - for gt, pred in zip(ground_truths, predictions): - if 0 <= gt < self.num_classes and 0 <= pred < self.num_classes: - confusion_matrix[int(gt), int(pred)] += 1 + if 0 <= pred_idx < num_classes and 0 <= gt_idx < num_classes: + confusion_matrix[gt_idx, pred_idx] += 1 return confusion_matrix @@ -297,11 +352,8 @@ def get_summary(self) -> Dict[str, Any]: """Get a summary of the evaluation. Returns: - Dictionary with summary metrics including: - - accuracy: Overall accuracy - - per_class_accuracy: Dict mapping class names to accuracies - - confusion_matrix: 2D list - - num_samples: Total number of samples + Dictionary with accuracy, precision, recall, f1score, per_class_accuracy, + confusion_matrix, num_samples, and detailed_metrics. """ metrics = self.compute_metrics() @@ -313,11 +365,9 @@ def get_summary(self) -> Dict[str, Any]: "num_samples": 0, } - per_class_accuracy = {} - for class_name in self.class_names: - key = f"{class_name}_accuracy" - if key in metrics: - per_class_accuracy[class_name] = metrics[key] + per_class_accuracy = { + name: metrics[f"{name}_accuracy"] for name in self.class_names if f"{name}_accuracy" in metrics + } return { "accuracy": metrics.get("accuracy", 0.0), From 480a2aab262546adb6c1710d274cc9f3ed0690f6 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 12 Dec 2025 00:19:48 +0900 Subject: [PATCH 28/62] chore: fix deployment result to dataclass Signed-off-by: vividf --- .../runners/common/deployment_runner.py | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/deployment/runners/common/deployment_runner.py b/deployment/runners/common/deployment_runner.py index dd8288055..c1c8354e5 100644 --- a/deployment/runners/common/deployment_runner.py +++ b/deployment/runners/common/deployment_runner.py @@ -17,7 +17,8 @@ from __future__ import annotations import logging -from typing import Any, Dict, Optional, Type, TypedDict +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, Optional, Type from mmengine.config import Config @@ -31,11 +32,12 @@ from deployment.runners.common.verification_orchestrator import VerificationOrchestrator -class DeploymentResultDict(TypedDict, total=False): +@dataclass +class DeploymentResult: """ Standardized structure returned by `BaseDeploymentRunner.run()`. - Keys: + Fields: pytorch_model: In-memory model instance loaded from the checkpoint (if requested). onnx_path: Filesystem path to the exported ONNX artifact (single file or directory). tensorrt_path: Filesystem path to the exported TensorRT engine. @@ -43,11 +45,15 @@ class DeploymentResultDict(TypedDict, total=False): evaluation_results: Arbitrary dictionary produced by `BaseEvaluator.evaluate()`. """ - pytorch_model: Optional[Any] - onnx_path: Optional[str] - tensorrt_path: Optional[str] - verification_results: Dict[str, Any] - evaluation_results: Dict[str, Any] + 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 a dict view for compatibility/serialization.""" + return asdict(self) class BaseDeploymentRunner: @@ -163,7 +169,7 @@ def load_pytorch_model(self, checkpoint_path: str, context: ExportContext) -> An def run( self, context: Optional[ExportContext] = None, - ) -> DeploymentResultDict: + ) -> DeploymentResult: """ Execute the complete deployment workflow. @@ -177,35 +183,29 @@ def run( ExportContext is created. Returns: - DeploymentResultDict: Structured summary of all deployment artifacts and reports. + DeploymentResult: Structured summary of all deployment artifacts and reports. """ # Create default context if not provided if context is None: context = ExportContext() - results: DeploymentResultDict = { - "pytorch_model": None, - "onnx_path": None, - "tensorrt_path": None, - "verification_results": {}, - "evaluation_results": {}, - } + results = DeploymentResult() # Phase 1: Export 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.pytorch_model = export_result.pytorch_model + results.onnx_path = export_result.onnx_path + results.tensorrt_path = export_result.tensorrt_path # Phase 2: Verification verification_results = self.verification_orchestrator.run( artifact_manager=self.artifact_manager, ) - results["verification_results"] = verification_results + results.verification_results = verification_results # Phase 3: Evaluation evaluation_results = self.evaluation_orchestrator.run(self.artifact_manager) - results["evaluation_results"] = evaluation_results + results.evaluation_results = evaluation_results self.logger.info("\n" + "=" * 80) self.logger.info("Deployment Complete!") From caf4ba5c9e42588ec970d9aa8f6835628a36ed97 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 12 Dec 2025 00:40:23 +0900 Subject: [PATCH 29/62] chore: fix readme Signed-off-by: vividf --- deployment/README.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/deployment/README.md b/deployment/README.md index 37e3d8085..d29494597 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -1,14 +1,8 @@ # AWML Deployment Framework -AWML ships a unified, task-agnostic deployment stack that turns trained PyTorch -checkpoints into production-ready ONNX and TensorRT artifacts. The same typed -verification and evaluation toolchain runs across every backend so numerical -parity and metrics stay consistent from project to project. - -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. +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 From 142fc0d10e05a0048ceb724b8f93a7235998b898 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 12 Dec 2025 11:57:36 +0900 Subject: [PATCH 30/62] chore: wrap output to dataclass Signed-off-by: vividf --- deployment/core/evaluation/base_evaluator.py | 65 ++++++++++------- deployment/core/evaluation/evaluator_types.py | 72 ++++++++++++++++++- .../core/evaluation/verification_mixin.py | 10 +-- deployment/core/metrics/__init__.py | 4 ++ .../core/metrics/base_metrics_adapter.py | 51 ++++++++++++- .../core/metrics/classification_metrics.py | 34 ++++----- .../core/metrics/detection_2d_metrics.py | 22 +++--- .../core/metrics/detection_3d_metrics.py | 24 +++---- deployment/pipelines/common/base_pipeline.py | 19 ++--- 9 files changed, 211 insertions(+), 90 deletions(-) diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py index 18c7d1764..e92ff8fee 100644 --- a/deployment/core/evaluation/base_evaluator.py +++ b/deployment/core/evaluation/base_evaluator.py @@ -15,13 +15,20 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Dict, List, Mapping, Optional, Tuple +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, ModelSpec, VerifyResultDict +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 BaseMetricsAdapter @@ -31,6 +38,9 @@ "EvalResultDict", "VerifyResultDict", "ModelSpec", + "InferenceResult", + "LatencyStats", + "LatencyBreakdown", "TaskProfile", "BaseEvaluator", "EvaluationDefaults", @@ -249,12 +259,12 @@ def evaluate( gt_data = data_loader.get_ground_truth(idx) ground_truths = self._parse_ground_truths(gt_data) - raw_output, latency, breakdown = pipeline.infer(input_data, **infer_kwargs) - latencies.append(latency) - if breakdown: - latency_breakdowns.append(breakdown) + 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(raw_output) + predictions = self._parse_predictions(infer_result.output) self._add_to_adapter(predictions, ground_truths) if model.backend is Backend.TENSORRT and idx % EVALUATION_DEFAULTS.GPU_CLEANUP_INTERVAL == 0: @@ -271,41 +281,44 @@ def evaluate( # ================== Utilities ================== - def compute_latency_stats(self, latencies: List[float]) -> Dict[str, float]: + def compute_latency_stats(self, latencies: List[float]) -> LatencyStats: """Compute latency statistics from a list of measurements.""" if not latencies: - return {"mean_ms": 0.0, "std_ms": 0.0, "min_ms": 0.0, "max_ms": 0.0, "median_ms": 0.0} + return LatencyStats.empty() arr = np.array(latencies) - return { - "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)), - } + 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]], - ) -> Dict[str, Dict[str, float]]: + ) -> LatencyBreakdown: """Compute statistics for each latency stage.""" if not latency_breakdowns: - return {} + return LatencyBreakdown.empty() all_stages = set() for breakdown in latency_breakdowns: all_stages.update(breakdown.keys()) - return { - stage: self.compute_latency_stats([bd.get(stage, 0.0) for bd in latency_breakdowns if stage in bd]) - for stage in sorted(all_stages) - } + 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: Mapping[str, float]) -> str: + 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['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms " - f"(min: {stats['min_ms']:.2f}, max: {stats['max_ms']:.2f}, " - f"median: {stats['median_ms']:.2f})" + 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 index 04f9f59d4..de800656f 100644 --- a/deployment/core/evaluation/evaluator_types.py +++ b/deployment/core/evaluation/evaluator_types.py @@ -7,8 +7,8 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Dict, TypedDict +from dataclasses import asdict, dataclass +from typing import Any, Dict, Optional, TypedDict from deployment.core.artifacts import Artifact from deployment.core.backend import Backend @@ -47,6 +47,74 @@ class VerifyResultDict(TypedDict, total=False): 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: """ diff --git a/deployment/core/evaluation/verification_mixin.py b/deployment/core/evaluation/verification_mixin.py index c8ead03cb..d1977f2c9 100644 --- a/deployment/core/evaluation/verification_mixin.py +++ b/deployment/core/evaluation/verification_mixin.py @@ -421,16 +421,16 @@ def _verify_single_sample( ref_name = f"{ref_backend.value} ({ref_device})" logger.info(f"\nRunning {ref_name} reference...") - ref_output, ref_latency, _ = ref_pipeline.infer(input_data, metadata, return_raw_outputs=True) - logger.info(f" {ref_name} latency: {ref_latency:.2f} ms") + 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_output, test_latency, _ = test_pipeline.infer(test_input, metadata, return_raw_outputs=True) - logger.info(f" {test_name} latency: {test_latency:.2f} ms") + 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_output, test_output, tolerance, test_name, logger) + 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: diff --git a/deployment/core/metrics/__init__.py b/deployment/core/metrics/__init__.py index baf178b67..6ce156986 100644 --- a/deployment/core/metrics/__init__.py +++ b/deployment/core/metrics/__init__.py @@ -45,6 +45,8 @@ from deployment.core.metrics.base_metrics_adapter import ( BaseMetricsAdapter, BaseMetricsConfig, + ClassificationSummary, + DetectionSummary, ) from deployment.core.metrics.classification_metrics import ( ClassificationMetricsAdapter, @@ -63,6 +65,8 @@ # Base classes "BaseMetricsAdapter", "BaseMetricsConfig", + "ClassificationSummary", + "DetectionSummary", # 3D Detection "Detection3DMetricsAdapter", "Detection3DMetricsConfig", diff --git a/deployment/core/metrics/base_metrics_adapter.py b/deployment/core/metrics/base_metrics_adapter.py index 816da90ab..acf31229c 100644 --- a/deployment/core/metrics/base_metrics_adapter.py +++ b/deployment/core/metrics/base_metrics_adapter.py @@ -30,6 +30,55 @@ class BaseMetricsConfig: 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 BaseMetricsAdapter(ABC): """ Abstract base class for all task-specific metrics adapters. @@ -98,7 +147,7 @@ def compute_metrics(self) -> Dict[str, float]: pass @abstractmethod - def get_summary(self) -> Dict[str, Any]: + def get_summary(self) -> Any: """ Get a summary of the evaluation including primary metrics. diff --git a/deployment/core/metrics/classification_metrics.py b/deployment/core/metrics/classification_metrics.py index c635fde30..24c96b5ac 100644 --- a/deployment/core/metrics/classification_metrics.py +++ b/deployment/core/metrics/classification_metrics.py @@ -36,7 +36,7 @@ ) from perception_eval.manager import PerceptionEvaluationManager -from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig +from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig, ClassificationSummary logger = logging.getLogger(__name__) @@ -348,34 +348,28 @@ def get_confusion_matrix(self) -> np.ndarray: return confusion_matrix - def get_summary(self) -> Dict[str, Any]: + def get_summary(self) -> ClassificationSummary: """Get a summary of the evaluation. Returns: - Dictionary with accuracy, precision, recall, f1score, per_class_accuracy, - confusion_matrix, num_samples, and detailed_metrics. + ClassificationSummary with aggregate metrics. """ metrics = self.compute_metrics() if not metrics: - return { - "accuracy": 0.0, - "per_class_accuracy": {}, - "confusion_matrix": [], - "num_samples": 0, - } + return ClassificationSummary() per_class_accuracy = { name: metrics[f"{name}_accuracy"] for name in self.class_names if f"{name}_accuracy" in metrics } - return { - "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, - } + 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 index 38264a6e2..a96e7a0a2 100644 --- a/deployment/core/metrics/detection_2d_metrics.py +++ b/deployment/core/metrics/detection_2d_metrics.py @@ -46,7 +46,7 @@ ) from perception_eval.manager import PerceptionEvaluationManager -from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig +from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig, DetectionSummary logger = logging.getLogger(__name__) @@ -451,12 +451,8 @@ def _process_metrics_score(self, metrics_score: MetricsScore) -> Dict[str, float return metric_dict - def get_summary(self) -> Dict[str, Any]: - """Get a summary of the evaluation including mAP and per-class metrics. - - Returns: - Dictionary with summary metrics. - """ + 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) @@ -474,9 +470,9 @@ def get_summary(self) -> Dict[str, Any]: if class_name not in per_class_ap: per_class_ap[class_name] = value - return { - "mAP": primary_map or 0.0, - "per_class_ap": per_class_ap, - "num_frames": self._frame_count, - "detailed_metrics": metrics, - } + 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 index d8f0f2cb3..b0aff9ad7 100644 --- a/deployment/core/metrics/detection_3d_metrics.py +++ b/deployment/core/metrics/detection_3d_metrics.py @@ -43,7 +43,7 @@ from perception_eval.manager import PerceptionEvaluationManager from pyquaternion import Quaternion -from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig +from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig, DetectionSummary logger = logging.getLogger(__name__) @@ -463,12 +463,8 @@ def _process_metrics_score(self, metrics_score: MetricsScore) -> Dict[str, float return metric_dict - def get_summary(self) -> Dict[str, Any]: - """Get a summary of the evaluation including mAP and per-class metrics. - - Returns: - Dictionary with summary metrics. - """ + 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) @@ -489,10 +485,10 @@ def get_summary(self) -> Dict[str, Any]: if class_name not in per_class_ap: per_class_ap[class_name] = value - return { - "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, - } + 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/pipelines/common/base_pipeline.py b/deployment/pipelines/common/base_pipeline.py index b9dbaee16..e5f9314be 100644 --- a/deployment/pipelines/common/base_pipeline.py +++ b/deployment/pipelines/common/base_pipeline.py @@ -22,6 +22,8 @@ import torch +from deployment.core.evaluation.evaluator_types import InferenceResult + logger = logging.getLogger(__name__) @@ -120,7 +122,7 @@ def postprocess(self, model_output: Any, metadata: Dict = None) -> Any: def infer( self, input_data: Any, metadata: Optional[Dict] = None, return_raw_outputs: bool = False, **kwargs - ) -> Tuple[Any, float, Dict[str, float]]: + ) -> InferenceResult: """ Complete inference pipeline. @@ -140,12 +142,11 @@ def infer( **kwargs: Additional arguments passed to preprocess() Returns: - Tuple of (outputs, latency_ms, latency_breakdown) - - outputs: If return_raw_outputs=True: raw_model_output - If return_raw_outputs=False: final_predictions - - latency_ms: Total inference latency in milliseconds - - latency_breakdown: Dictionary with stage-wise latencies (may be empty) - Keys: 'preprocessing_ms', 'model_ms', 'postprocessing_ms' + InferenceResult containing: + - output: raw model output (return_raw_outputs=True) or final predictions + - latency_ms: total inference latency in milliseconds + - breakdown: stage-wise latencies (may be empty) with keys such as + preprocessing_ms, model_ms, postprocessing_ms """ if metadata is None: metadata = {} @@ -198,7 +199,7 @@ def infer( # Postprocess (optional) if return_raw_outputs: - return model_output, total_latency, latency_breakdown + return InferenceResult(output=model_output, latency_ms=total_latency, breakdown=latency_breakdown) else: postprocess_start = time.perf_counter() predictions = self.postprocess(model_output, merged_metadata) @@ -206,7 +207,7 @@ def infer( latency_breakdown["postprocessing_ms"] = (postprocess_time - postprocess_start) * 1000 total_latency = (time.perf_counter() - start_time) * 1000 - return predictions, total_latency, latency_breakdown + return InferenceResult(output=predictions, latency_ms=total_latency, breakdown=latency_breakdown) except Exception: logger.exception("Inference failed.") From 28446906f34a5bfc8bded99a2f384c6c99a70db9 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 15 Dec 2025 17:16:15 +0900 Subject: [PATCH 31/62] chore: clean preprocessing builder Signed-off-by: vividf --- deployment/core/io/preprocessing_builder.py | 72 +++------------------ 1 file changed, 8 insertions(+), 64 deletions(-) diff --git a/deployment/core/io/preprocessing_builder.py b/deployment/core/io/preprocessing_builder.py index 6ad682e19..1472f1e3a 100644 --- a/deployment/core/io/preprocessing_builder.py +++ b/deployment/core/io/preprocessing_builder.py @@ -96,18 +96,9 @@ def build( VALID_TASK_TYPES = list(TASK_PIPELINE_CONFIGS.keys()) -def _build_pipeline(task_type: str, pipeline_cfg: List[TransformConfig]) -> Any: - """Build pipeline for a given task_type using registered builders.""" - 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 build_preprocessing_pipeline( model_cfg: Config, - task_type: Optional[str] = None, + task_type: str = "detection3d", ) -> Any: """ Build preprocessing pipeline from model config. @@ -137,66 +128,19 @@ def build_preprocessing_pipeline( >>> results = pipeline({'img_path': 'image.jpg'}) """ pipeline_cfg = _extract_pipeline_config(model_cfg) - task_type = _resolve_task_type(model_cfg, task_type) - - logger.info("Building preprocessing pipeline with task_type: %s", task_type) - return _build_pipeline(task_type, pipeline_cfg) - - -def _resolve_task_type(model_cfg: Config, task_type: Optional[str] = None) -> str: - """ - Resolve task type from various sources. - - Args: - model_cfg: Model configuration - task_type: Explicit task type (highest priority) - - Returns: - Resolved task type string - - Raises: - ValueError: If task_type cannot be resolved - """ - if task_type is not None: - _validate_task_type(task_type) - return task_type - - if "task_type" in model_cfg: - task_type = model_cfg.task_type - _validate_task_type(task_type) - return task_type - - deploy_section = model_cfg.get("deploy", {}) - if isinstance(deploy_section, dict) and "task_type" in deploy_section: - task_type = deploy_section["task_type"] - _validate_task_type(task_type) - return task_type - - raise ValueError( - "task_type must be specified either via the build_preprocessing_pipeline argument " - "or by setting 'task_type' in the deploy config (deploy_config.py) or " - "model config (model_cfg.task_type or model_cfg.deploy.task_type). " - "Recommended: add 'task_type = \"detection3d\"' (or appropriate type) to deploy_config.py. " - "Automatic inference has been removed." - ) - - -def _validate_task_type(task_type: str) -> None: - """ - Validate task type. - - Args: - task_type: Task type to validate - - Raises: - ValueError: If task_type is invalid - """ 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]: """ From 0b5e947a7e823cb807631c722f826bf7ed4ba6b2 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 16:47:25 +0900 Subject: [PATCH 32/62] chore: rename adapter to interface Signed-off-by: vividf --- deployment/README.md | 2 +- deployment/core/__init__.py | 18 +++---- deployment/core/evaluation/base_evaluator.py | 22 ++++---- deployment/core/metrics/__init__.py | 52 +++++++++---------- ...s_adapter.py => base_metrics_interface.py} | 34 ++++++------ .../core/metrics/classification_metrics.py | 24 +++++---- .../core/metrics/detection_2d_metrics.py | 30 +++++------ .../core/metrics/detection_3d_metrics.py | 28 +++++----- deployment/docs/core_contract.md | 14 ++--- deployment/docs/overview.md | 2 +- deployment/docs/verification_evaluation.md | 2 +- 11 files changed, 116 insertions(+), 112 deletions(-) rename deployment/core/metrics/{base_metrics_adapter.py => base_metrics_interface.py} (81%) diff --git a/deployment/README.md b/deployment/README.md index d29494597..575bb201c 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -66,7 +66,7 @@ Each project ships its own `deploy_config.py`, evaluator, and data loader under ## Core Contract -[`core_contract.md`](docs/core_contract.md) defines the boundaries between runners, orchestrators, evaluators, pipelines, and metrics adapters. Follow the contract when introducing new logic to keep refactors safe and dependencies explicit. +[`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 diff --git a/deployment/core/__init__.py b/deployment/core/__init__.py index 78bf9597e..afe64e8b4 100644 --- a/deployment/core/__init__.py +++ b/deployment/core/__init__.py @@ -34,14 +34,14 @@ from deployment.core.io.base_data_loader import BaseDataLoader from deployment.core.io.preprocessing_builder import build_preprocessing_pipeline from deployment.core.metrics import ( - BaseMetricsAdapter, BaseMetricsConfig, - ClassificationMetricsAdapter, + BaseMetricsInterface, ClassificationMetricsConfig, - Detection2DMetricsAdapter, + ClassificationMetricsInterface, Detection2DMetricsConfig, - Detection3DMetricsAdapter, + Detection2DMetricsInterface, Detection3DMetricsConfig, + Detection3DMetricsInterface, ) __all__ = [ @@ -79,13 +79,13 @@ "ModelSpec", # Preprocessing "build_preprocessing_pipeline", - # Metrics adapters (using autoware_perception_evaluation) - "BaseMetricsAdapter", + # Metrics interfaces (using autoware_perception_evaluation) + "BaseMetricsInterface", "BaseMetricsConfig", - "Detection3DMetricsAdapter", + "Detection3DMetricsInterface", "Detection3DMetricsConfig", - "Detection2DMetricsAdapter", + "Detection2DMetricsInterface", "Detection2DMetricsConfig", - "ClassificationMetricsAdapter", + "ClassificationMetricsInterface", "ClassificationMetricsConfig", ] diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py index e92ff8fee..f5a3c8721 100644 --- a/deployment/core/evaluation/base_evaluator.py +++ b/deployment/core/evaluation/base_evaluator.py @@ -31,7 +31,7 @@ ) from deployment.core.evaluation.verification_mixin import VerificationMixin from deployment.core.io.base_data_loader import BaseDataLoader -from deployment.core.metrics import BaseMetricsAdapter +from deployment.core.metrics import BaseMetricsInterface # Re-export types __all__ = [ @@ -97,14 +97,14 @@ class BaseEvaluator(VerificationMixin, ABC): - _prepare_input(): Prepare model input from sample - _parse_predictions(): Normalize pipeline output - _parse_ground_truths(): Extract ground truth from sample - - _add_to_adapter(): Feed a single frame to the metrics adapter - - _build_results(): Construct final results dict from adapter metrics + - _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_adapter: BaseMetricsAdapter, + metrics_interface: BaseMetricsInterface, task_profile: TaskProfile, model_cfg: Any, ): @@ -112,11 +112,11 @@ def __init__( Initialize evaluator. Args: - metrics_adapter: Metrics adapter for computing task-specific metrics + 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_adapter = metrics_adapter + self.metrics_interface = metrics_interface self.task_profile = task_profile self.model_cfg = model_cfg self.pytorch_model: Any = None @@ -175,8 +175,8 @@ def _parse_ground_truths(self, gt_data: Mapping[str, Any]) -> Any: raise NotImplementedError @abstractmethod - def _add_to_adapter(self, predictions: Any, ground_truths: Any) -> None: - """Add a single frame to the metrics adapter.""" + def _add_to_interface(self, predictions: Any, ground_truths: Any) -> None: + """Add a single frame to the metrics interface.""" raise NotImplementedError @abstractmethod @@ -186,7 +186,7 @@ def _build_results( latency_breakdowns: List[Dict[str, float]], num_samples: int, ) -> EvalResultDict: - """Build final results dict from adapter metrics.""" + """Build final results dict from interface metrics.""" raise NotImplementedError @abstractmethod @@ -242,7 +242,7 @@ def evaluate( self._ensure_model_on_device(model.device) pipeline = self._create_pipeline(model, model.device) - self.metrics_adapter.reset() + self.metrics_interface.reset() latencies = [] latency_breakdowns = [] @@ -265,7 +265,7 @@ def evaluate( latency_breakdowns.append(infer_result.breakdown) predictions = self._parse_predictions(infer_result.output) - self._add_to_adapter(predictions, ground_truths) + 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(): diff --git a/deployment/core/metrics/__init__.py b/deployment/core/metrics/__init__.py index 6ce156986..1acbbae10 100644 --- a/deployment/core/metrics/__init__.py +++ b/deployment/core/metrics/__init__.py @@ -1,79 +1,79 @@ """ -Unified Metrics Adapters for AWML Deployment Framework. +Unified Metrics Interfaces for AWML Deployment Framework. -This module provides task-specific metric adapters that use autoware_perception_evaluation +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 → Detection3DMetricsAdapter (mAP, mAPH using autoware_perception_eval) - 2. 2D Detection → Detection2DMetricsAdapter (mAP using autoware_perception_eval, 2D mode) - 3. Classification → ClassificationMetricsAdapter (accuracy, precision, recall, F1) + 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 Detection3DMetricsAdapter, Detection3DMetricsConfig + from deployment.core.metrics import Detection3DMetricsInterface, Detection3DMetricsConfig config = Detection3DMetricsConfig( class_names=["car", "truck", "bus", "bicycle", "pedestrian"], ) - adapter = Detection3DMetricsAdapter(config) - adapter.add_frame(predictions, ground_truths) - metrics = adapter.compute_metrics() + interface = Detection3DMetricsInterface(config) + interface.add_frame(predictions, ground_truths) + metrics = interface.compute_metrics() # For 2D detection (YOLOX, etc.) - from deployment.core.metrics import Detection2DMetricsAdapter, Detection2DMetricsConfig + from deployment.core.metrics import Detection2DMetricsInterface, Detection2DMetricsConfig config = Detection2DMetricsConfig( class_names=["car", "truck", "bus", ...], ) - adapter = Detection2DMetricsAdapter(config) - adapter.add_frame(predictions, ground_truths) - metrics = adapter.compute_metrics() + interface = Detection2DMetricsInterface(config) + interface.add_frame(predictions, ground_truths) + metrics = interface.compute_metrics() # For classification (Calibration, etc.) - from deployment.core.metrics import ClassificationMetricsAdapter, ClassificationMetricsConfig + from deployment.core.metrics import ClassificationMetricsInterface, ClassificationMetricsConfig config = ClassificationMetricsConfig( class_names=["miscalibrated", "calibrated"], ) - adapter = ClassificationMetricsAdapter(config) - adapter.add_frame(prediction_label, ground_truth_label, probabilities) - metrics = adapter.compute_metrics() + interface = ClassificationMetricsInterface(config) + interface.add_frame(prediction_label, ground_truth_label, probabilities) + metrics = interface.compute_metrics() """ -from deployment.core.metrics.base_metrics_adapter import ( - BaseMetricsAdapter, +from deployment.core.metrics.base_metrics_interface import ( BaseMetricsConfig, + BaseMetricsInterface, ClassificationSummary, DetectionSummary, ) from deployment.core.metrics.classification_metrics import ( - ClassificationMetricsAdapter, ClassificationMetricsConfig, + ClassificationMetricsInterface, ) from deployment.core.metrics.detection_2d_metrics import ( - Detection2DMetricsAdapter, Detection2DMetricsConfig, + Detection2DMetricsInterface, ) from deployment.core.metrics.detection_3d_metrics import ( - Detection3DMetricsAdapter, Detection3DMetricsConfig, + Detection3DMetricsInterface, ) __all__ = [ # Base classes - "BaseMetricsAdapter", + "BaseMetricsInterface", "BaseMetricsConfig", "ClassificationSummary", "DetectionSummary", # 3D Detection - "Detection3DMetricsAdapter", + "Detection3DMetricsInterface", "Detection3DMetricsConfig", # 2D Detection - "Detection2DMetricsAdapter", + "Detection2DMetricsInterface", "Detection2DMetricsConfig", # Classification - "ClassificationMetricsAdapter", + "ClassificationMetricsInterface", "ClassificationMetricsConfig", ] diff --git a/deployment/core/metrics/base_metrics_adapter.py b/deployment/core/metrics/base_metrics_interface.py similarity index 81% rename from deployment/core/metrics/base_metrics_adapter.py rename to deployment/core/metrics/base_metrics_interface.py index acf31229c..37feb8be4 100644 --- a/deployment/core/metrics/base_metrics_adapter.py +++ b/deployment/core/metrics/base_metrics_interface.py @@ -1,11 +1,11 @@ """ -Base Metrics Adapter for unified metric computation. +Base Metrics Interface for unified metric computation. -This module provides the abstract base class that all task-specific metrics adapters -must implement. It ensures a consistent interface across 3D detection, 2D detection, +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 adapters use autoware_perception_evaluation as the underlying computation +All metric interfaces use autoware_perception_evaluation as the underlying computation engine to ensure consistency between training (T4MetricV2) and deployment evaluation. """ @@ -19,7 +19,7 @@ @dataclass(frozen=True) class BaseMetricsConfig: - """Base configuration for all metrics adapters. + """Base configuration for all metrics interfaces. Attributes: class_names: List of class names for evaluation. @@ -79,35 +79,35 @@ def to_dict(self) -> Dict[str, Any]: return data -class BaseMetricsAdapter(ABC): +class BaseMetricsInterface(ABC): """ - Abstract base class for all task-specific metrics adapters. + Abstract base class for all task-specific metrics interfaces. - This class defines the common interface that all metric adapters must implement. - Each adapter wraps autoware_perception_evaluation to compute metrics consistent + 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 adapter with task-specific config + 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: - adapter = SomeMetricsAdapter(config) - adapter.reset() + interface = SomeMetricsInterface(config) + interface.reset() for pred, gt in data: - adapter.add_frame(pred, gt) - metrics = adapter.compute_metrics() + interface.add_frame(pred, gt) + metrics = interface.compute_metrics() """ def __init__(self, config: BaseMetricsConfig): """ - Initialize the metrics adapter. + Initialize the metrics interface. Args: - config: Configuration for the metrics adapter. + config: Configuration for the metrics interface. """ self.config = config self.class_names = config.class_names @@ -117,7 +117,7 @@ def __init__(self, config: BaseMetricsConfig): @abstractmethod def reset(self) -> None: """ - Reset the adapter for a new evaluation session. + Reset the interface for a new evaluation session. This method should clear all accumulated frame data and reinitialize the underlying evaluator. diff --git a/deployment/core/metrics/classification_metrics.py b/deployment/core/metrics/classification_metrics.py index 24c96b5ac..07852243c 100644 --- a/deployment/core/metrics/classification_metrics.py +++ b/deployment/core/metrics/classification_metrics.py @@ -1,7 +1,7 @@ """ -Classification Metrics Adapter using autoware_perception_evaluation. +Classification Metrics Interface using autoware_perception_evaluation. -This module provides an adapter to compute classification metrics (accuracy, precision, +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. @@ -9,12 +9,12 @@ config = ClassificationMetricsConfig( class_names=["miscalibrated", "calibrated"], ) - adapter = ClassificationMetricsAdapter(config) + interface = ClassificationMetricsInterface(config) for pred_label, gt_label in zip(predictions, ground_truths): - adapter.add_frame(prediction=pred_label, ground_truth=gt_label) + interface.add_frame(prediction=pred_label, ground_truth=gt_label) - metrics = adapter.compute_metrics() + metrics = interface.compute_metrics() # Returns: {"accuracy": 0.95, "precision": 0.94, "recall": 0.96, "f1score": 0.95, ...} """ @@ -36,7 +36,11 @@ ) from perception_eval.manager import PerceptionEvaluationManager -from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig, ClassificationSummary +from deployment.core.metrics.base_metrics_interface import ( + BaseMetricsConfig, + BaseMetricsInterface, + ClassificationSummary, +) logger = logging.getLogger(__name__) @@ -116,8 +120,8 @@ def __post_init__(self): ) -class ClassificationMetricsAdapter(BaseMetricsAdapter): - """Adapter for computing classification metrics using autoware_perception_evaluation. +class ClassificationMetricsInterface(BaseMetricsInterface): + """Interface for computing classification metrics using autoware_perception_evaluation. Metrics computed: - Accuracy: TP / (num_predictions + num_gt - TP) @@ -133,7 +137,7 @@ def __init__( data_root: str = "data/t4dataset/", result_root_directory: str = "/tmp/perception_eval_classification/", ): - """Initialize the classification metrics adapter. + """Initialize the classification metrics interface. Args: config: Configuration for classification metrics. @@ -164,7 +168,7 @@ def __init__( self.evaluator: Optional[PerceptionEvaluationManager] = None def reset(self) -> None: - """Reset the adapter for a new evaluation session.""" + """Reset the interface for a new evaluation session.""" self.evaluator = PerceptionEvaluationManager( evaluation_config=self.perception_eval_config, load_ground_truth=False, diff --git a/deployment/core/metrics/detection_2d_metrics.py b/deployment/core/metrics/detection_2d_metrics.py index a96e7a0a2..fb9e73e5c 100644 --- a/deployment/core/metrics/detection_2d_metrics.py +++ b/deployment/core/metrics/detection_2d_metrics.py @@ -1,11 +1,11 @@ """ -2D Detection Metrics Adapter using autoware_perception_evaluation. +2D Detection Metrics Interface using autoware_perception_evaluation. -This module provides an adapter to compute 2D detection metrics (mAP) +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 adapter uses: +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) @@ -14,17 +14,17 @@ class_names=["car", "truck", "bus", "bicycle", "pedestrian", "motorcycle", "trailer", "unknown"], frame_id="camera", ) - adapter = Detection2DMetricsAdapter(config) + interface = Detection2DMetricsInterface(config) # Add frames for pred, gt in zip(predictions_list, ground_truths_list): - adapter.add_frame( + 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 = adapter.compute_metrics() + metrics = interface.compute_metrics() # Returns: {"mAP_iou_2d_0.5": 0.7, "mAP_iou_2d_0.75": 0.65, ...} """ @@ -46,7 +46,7 @@ ) from perception_eval.manager import PerceptionEvaluationManager -from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig, DetectionSummary +from deployment.core.metrics.base_metrics_interface import BaseMetricsConfig, BaseMetricsInterface, DetectionSummary logger = logging.getLogger(__name__) @@ -128,11 +128,11 @@ def __post_init__(self): object.__setattr__(self, "frame_pass_fail_config", default_pass_fail_config) -class Detection2DMetricsAdapter(BaseMetricsAdapter): +class Detection2DMetricsInterface(BaseMetricsInterface): """ - Adapter for computing 2D detection metrics using autoware_perception_evaluation. + Interface for computing 2D detection metrics using autoware_perception_evaluation. - This adapter provides a simplified interface for the deployment framework to + 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: @@ -145,17 +145,17 @@ class Detection2DMetricsAdapter(BaseMetricsAdapter): class_names=["car", "truck", "bus", "bicycle", "pedestrian"], iou_thresholds=[0.5, 0.75], ) - adapter = Detection2DMetricsAdapter(config) + interface = Detection2DMetricsInterface(config) # Add frames for pred, gt in zip(predictions_list, ground_truths_list): - adapter.add_frame( + interface.add_frame( predictions=pred, # List[Dict] with bbox, label, score ground_truths=gt, # List[Dict] with bbox, label ) # Compute metrics - metrics = adapter.compute_metrics() + metrics = interface.compute_metrics() """ _UNKNOWN = "unknown" @@ -167,7 +167,7 @@ def __init__( result_root_directory: str = "/tmp/perception_eval_2d/", ): """ - Initialize the 2D detection metrics adapter. + Initialize the 2D detection metrics interface. Args: config: Configuration for 2D detection metrics. @@ -204,7 +204,7 @@ def __init__( self.evaluator: Optional[PerceptionEvaluationManager] = None def reset(self) -> None: - """Reset the adapter for a new evaluation session.""" + """Reset the interface for a new evaluation session.""" self.evaluator = PerceptionEvaluationManager( evaluation_config=self.perception_eval_config, load_ground_truth=False, diff --git a/deployment/core/metrics/detection_3d_metrics.py b/deployment/core/metrics/detection_3d_metrics.py index b0aff9ad7..235ab795b 100644 --- a/deployment/core/metrics/detection_3d_metrics.py +++ b/deployment/core/metrics/detection_3d_metrics.py @@ -1,7 +1,7 @@ """ -3D Detection Metrics Adapter using autoware_perception_evaluation. +3D Detection Metrics Interface using autoware_perception_evaluation. -This module provides an adapter to compute 3D detection metrics (mAP, mAPH) +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. @@ -10,17 +10,17 @@ class_names=["car", "truck", "bus", "bicycle", "pedestrian"], frame_id="base_link", ) - adapter = Detection3DMetricsAdapter(config) + interface = Detection3DMetricsInterface(config) # Add frames for pred, gt in zip(predictions_list, ground_truths_list): - adapter.add_frame( + interface.add_frame( predictions=pred, # List[Dict] with bbox_3d, label, score ground_truths=gt, # List[Dict] with bbox_3d, label ) # Compute metrics - metrics = adapter.compute_metrics() + metrics = interface.compute_metrics() # Returns: {"mAP_center_distance_bev_0.5": 0.7, ...} """ @@ -43,7 +43,7 @@ from perception_eval.manager import PerceptionEvaluationManager from pyquaternion import Quaternion -from deployment.core.metrics.base_metrics_adapter import BaseMetricsAdapter, BaseMetricsConfig, DetectionSummary +from deployment.core.metrics.base_metrics_interface import BaseMetricsConfig, BaseMetricsInterface, DetectionSummary logger = logging.getLogger(__name__) @@ -129,11 +129,11 @@ def __post_init__(self): object.__setattr__(self, "frame_pass_fail_config", default_pass_fail_config) -class Detection3DMetricsAdapter(BaseMetricsAdapter): +class Detection3DMetricsInterface(BaseMetricsInterface): """ - Adapter for computing 3D detection metrics using autoware_perception_evaluation. + Interface for computing 3D detection metrics using autoware_perception_evaluation. - This adapter provides a simplified interface for the deployment framework to + 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. @@ -142,17 +142,17 @@ class Detection3DMetricsAdapter(BaseMetricsAdapter): class_names=["car", "truck", "bus", "bicycle", "pedestrian"], frame_id="base_link", ) - adapter = Detection3DMetricsAdapter(config) + interface = Detection3DMetricsInterface(config) # Add frames for pred, gt in zip(predictions_list, ground_truths_list): - adapter.add_frame( + interface.add_frame( predictions=pred, # List[Dict] with bbox_3d, label, score ground_truths=gt, # List[Dict] with bbox_3d, label ) # Compute metrics - metrics = adapter.compute_metrics() + metrics = interface.compute_metrics() # Returns: {"mAP_center_distance_bev_0.5": 0.7, ...} """ @@ -165,7 +165,7 @@ def __init__( result_root_directory: str = "/tmp/perception_eval/", ): """ - Initialize the 3D detection metrics adapter. + Initialize the 3D detection metrics interface. Args: config: Configuration for 3D detection metrics. @@ -201,7 +201,7 @@ def __init__( self.evaluator: Optional[PerceptionEvaluationManager] = None def reset(self) -> None: - """Reset the adapter for a new evaluation session.""" + """Reset the interface for a new evaluation session.""" self.evaluator = PerceptionEvaluationManager( evaluation_config=self.perception_eval_config, load_ground_truth=False, diff --git a/deployment/docs/core_contract.md b/deployment/docs/core_contract.md index d1aa78849..90264272a 100644 --- a/deployment/docs/core_contract.md +++ b/deployment/docs/core_contract.md @@ -15,18 +15,18 @@ This document defines the responsibilities and boundaries between the primary de ### 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 `BaseMetricsAdapter` at construction. +- 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 adapters + - 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_adapter(predictions, ground_truths)` → feed metrics adapter + - `_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. @@ -41,7 +41,7 @@ This document defines the responsibilities and boundaries between the primary de - Central location for future pipeline wiring (new tasks/backends). - Pipelines must avoid loading artifacts or computing metrics; they only execute inference. -### Metrics Adapters (Autoware-based adapters) +### 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. @@ -49,9 +49,9 @@ This document defines the responsibilities and boundaries between the primary de ### Summary of Allowed Dependencies - **Runner → Evaluator** (injection) ✓ -- **Evaluator → PipelineFactory / Pipelines / Metrics Adapters** ✓ +- **Evaluator → PipelineFactory / Pipelines / Metrics Interfaces** ✓ - **PipelineFactory → Pipelines** ✓ -- **Pipelines ↔ Metrics Adapters** ✗ (evaluators mediate) -- **Metrics Adapters → Runner/PipelineFactory** ✗ +- **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/overview.md b/deployment/docs/overview.md index ce058bf2f..10b49ed81 100644 --- a/deployment/docs/overview.md +++ b/deployment/docs/overview.md @@ -41,7 +41,7 @@ verification = dict( ### Multi-Backend Evaluation -Evaluators return typed results via `EvalResultDict` (TypedDict) ensuring consistent structure across backends. Metrics adapters (`Detection3DMetricsAdapter`, `Detection2DMetricsAdapter`, `ClassificationMetricsAdapter`) compute task-specific metrics using `autoware_perception_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 diff --git a/deployment/docs/verification_evaluation.md b/deployment/docs/verification_evaluation.md index ca58cf2f1..e4f13e4df 100644 --- a/deployment/docs/verification_evaluation.md +++ b/deployment/docs/verification_evaluation.md @@ -62,4 +62,4 @@ evaluation = dict( ## Core Contract -`deployment/docs/core_contract.md` documents the responsibilities and allowed dependencies between runners, evaluators, pipelines, `PipelineFactory`, and metrics adapters. Following the contract keeps refactors safe and ensures new projects remain compatible with shared infrastructure. +`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. From 24a6386b622cc479218e82526e5b9cd795ffdc1f Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 17:00:34 +0900 Subject: [PATCH 33/62] chore: remove commented line Signed-off-by: vividf --- deployment/pipelines/__init__.py | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/deployment/pipelines/__init__.py b/deployment/pipelines/__init__.py index 4c0648ede..67db58095 100644 --- a/deployment/pipelines/__init__.py +++ b/deployment/pipelines/__init__.py @@ -5,15 +5,7 @@ multi-stage processing with mixed PyTorch and optimized backend inference. """ -# Calibration pipelines (classification) -# from deployment.pipelines.calibration import ( -# CalibrationDeploymentPipeline, -# CalibrationONNXPipeline, -# CalibrationPyTorchPipeline, -# CalibrationTensorRTPipeline, -# ) - -# CenterPoint pipelines (3D detection) +# CenterPoint pipelines from deployment.pipelines.centerpoint import ( CenterPointDeploymentPipeline, CenterPointONNXPipeline, @@ -24,13 +16,8 @@ # Pipeline factory from deployment.pipelines.factory import PipelineFactory -# YOLOX pipelines (2D detection) -# from deployment.pipelines.yolox import ( -# YOLOXDeploymentPipeline, -# YOLOXONNXPipeline, -# YOLOXPyTorchPipeline, -# YOLOXTensorRTPipeline, -# ) +# Add pipelines here + __all__ = [ # Factory @@ -40,14 +27,5 @@ "CenterPointPyTorchPipeline", "CenterPointONNXPipeline", "CenterPointTensorRTPipeline", - # YOLOX - # "YOLOXDeploymentPipeline", - # "YOLOXPyTorchPipeline", - # "YOLOXONNXPipeline", - # "YOLOXTensorRTPipeline", - # Calibration - # "CalibrationDeploymentPipeline", - # "CalibrationPyTorchPipeline", - # "CalibrationONNXPipeline", - # "CalibrationTensorRTPipeline", + # Add pipelines here ] From 9863bbcbae685821afb7fe2badb25997e87236d1 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 17:01:38 +0900 Subject: [PATCH 34/62] chore: move import to head Signed-off-by: vividf --- deployment/pipelines/common/gpu_resource_mixin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/deployment/pipelines/common/gpu_resource_mixin.py b/deployment/pipelines/common/gpu_resource_mixin.py index df1836614..c026b36d5 100644 --- a/deployment/pipelines/common/gpu_resource_mixin.py +++ b/deployment/pipelines/common/gpu_resource_mixin.py @@ -15,6 +15,7 @@ from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional +import pycuda.driver as cuda import torch logger = logging.getLogger(__name__) @@ -148,7 +149,6 @@ def allocate(self, nbytes: int) -> Any: Returns: pycuda.driver.DeviceAllocation object """ - import pycuda.driver as cuda allocation = cuda.mem_alloc(nbytes) self._allocations.append(allocation) @@ -162,8 +162,6 @@ def get_stream(self) -> Any: pycuda.driver.Stream object """ if self._stream is None: - import pycuda.driver as cuda - self._stream = cuda.Stream() return self._stream From bbf75acb2e376e505db98355d4760ffb955b8783 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 18:34:06 +0900 Subject: [PATCH 35/62] chore: refactor factory Signed-off-by: vividf --- deployment/pipelines/__init__.py | 33 ++++ deployment/pipelines/centerpoint/factory.py | 93 ++++++++++ deployment/pipelines/common/__init__.py | 10 +- deployment/pipelines/common/base_factory.py | 121 +++++++++++++ deployment/pipelines/common/project_names.py | 36 ++++ deployment/pipelines/common/registry.py | 168 +++++++++++++++++ deployment/pipelines/factory.py | 181 +++++++------------ 7 files changed, 524 insertions(+), 118 deletions(-) create mode 100644 deployment/pipelines/centerpoint/factory.py create mode 100644 deployment/pipelines/common/base_factory.py create mode 100644 deployment/pipelines/common/project_names.py create mode 100644 deployment/pipelines/common/registry.py diff --git a/deployment/pipelines/__init__.py b/deployment/pipelines/__init__.py index 67db58095..c2ce54eae 100644 --- a/deployment/pipelines/__init__.py +++ b/deployment/pipelines/__init__.py @@ -3,16 +3,42 @@ This module provides pipeline abstractions for models that require multi-stage processing with mixed PyTorch and optimized backend inference. + +Architecture: + - BasePipelineFactory: Abstract base class for project-specific factories + - pipeline_registry: Registry for dynamic project registration + - PipelineFactory: Unified interface for creating pipelines + +Adding a New Project: + 1. Create a factory.py in your project directory (e.g., pipelines/myproject/factory.py) + 2. Implement a class inheriting from BasePipelineFactory + 3. Use @pipeline_registry.register decorator + 4. Import the factory in this __init__.py to trigger registration + +Example: + >>> from deployment.pipelines import PipelineFactory, pipeline_registry + >>> pipeline = PipelineFactory.create("centerpoint", model_spec, pytorch_model) + >>> print(pipeline_registry.list_projects()) """ # CenterPoint pipelines from deployment.pipelines.centerpoint import ( CenterPointDeploymentPipeline, CenterPointONNXPipeline, + CenterPointPipelineFactory, CenterPointPyTorchPipeline, CenterPointTensorRTPipeline, ) +# Base classes and registry +from deployment.pipelines.common import ( + BaseDeploymentPipeline, + BasePipelineFactory, + PipelineRegistry, + ProjectNames, + pipeline_registry, +) + # Pipeline factory from deployment.pipelines.factory import PipelineFactory @@ -20,6 +46,12 @@ __all__ = [ + # Base classes and registry + "BaseDeploymentPipeline", + "BasePipelineFactory", + "PipelineRegistry", + "pipeline_registry", + "ProjectNames", # Factory "PipelineFactory", # CenterPoint @@ -27,5 +59,6 @@ "CenterPointPyTorchPipeline", "CenterPointONNXPipeline", "CenterPointTensorRTPipeline", + "CenterPointPipelineFactory", # Add pipelines here ] diff --git a/deployment/pipelines/centerpoint/factory.py b/deployment/pipelines/centerpoint/factory.py new file mode 100644 index 000000000..0520cb5db --- /dev/null +++ b/deployment/pipelines/centerpoint/factory.py @@ -0,0 +1,93 @@ +""" +CenterPoint Pipeline Factory. + +This module provides the factory for creating CenterPoint pipelines +across different backends (PyTorch, ONNX, TensorRT). +""" + +import logging +from typing import Any, Optional + +from deployment.core.backend import Backend +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.pipelines.centerpoint.centerpoint_onnx import CenterPointONNXPipeline +from deployment.pipelines.centerpoint.centerpoint_pytorch import CenterPointPyTorchPipeline +from deployment.pipelines.centerpoint.centerpoint_tensorrt import CenterPointTensorRTPipeline +from deployment.pipelines.common.base_factory import BasePipelineFactory +from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.common.project_names import ProjectNames +from deployment.pipelines.common.registry import pipeline_registry + +logger = logging.getLogger(__name__) + + +@pipeline_registry.register +class CenterPointPipelineFactory(BasePipelineFactory): + """ + Factory for creating CenterPoint deployment pipelines. + + Supports PyTorch, ONNX, and TensorRT backends for 3D object detection. + + Example: + >>> from deployment.pipelines.centerpoint.factory import CenterPointPipelineFactory + >>> pipeline = CenterPointPipelineFactory.create_pipeline( + ... model_spec=model_spec, + ... pytorch_model=model, + ... ) + """ + + @classmethod + def get_project_name(cls) -> str: + """Return the project name for registry lookup.""" + return ProjectNames.CENTERPOINT + + @classmethod + def create_pipeline( + cls, + model_spec: ModelSpec, + pytorch_model: Any, + device: Optional[str] = None, + **kwargs, + ) -> BaseDeploymentPipeline: + """ + Create a CenterPoint pipeline for the specified backend. + + Args: + model_spec: Model specification (backend/device/path) + pytorch_model: PyTorch CenterPoint model instance + device: Override device (uses model_spec.device if None) + **kwargs: Additional arguments (unused for CenterPoint) + + Returns: + CenterPoint pipeline instance + + Raises: + ValueError: If backend is not supported + """ + 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) + + elif 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, + ) + + elif 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, + ) + + else: + raise ValueError(f"Unsupported backend: {backend.value}") diff --git a/deployment/pipelines/common/__init__.py b/deployment/pipelines/common/__init__.py index e07649794..117fdfcfa 100644 --- a/deployment/pipelines/common/__init__.py +++ b/deployment/pipelines/common/__init__.py @@ -1,11 +1,19 @@ """ Base Pipeline Classes for Deployment Framework. -This module provides the base abstract class for all deployment pipelines. +This module provides the base abstract class for all deployment pipelines, +along with the factory base class and registry for project-specific factories. """ +from deployment.pipelines.common.base_factory import BasePipelineFactory from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.common.project_names import ProjectNames +from deployment.pipelines.common.registry import PipelineRegistry, pipeline_registry __all__ = [ "BaseDeploymentPipeline", + "BasePipelineFactory", + "PipelineRegistry", + "pipeline_registry", + "ProjectNames", ] diff --git a/deployment/pipelines/common/base_factory.py b/deployment/pipelines/common/base_factory.py new file mode 100644 index 000000000..d21dbb2ed --- /dev/null +++ b/deployment/pipelines/common/base_factory.py @@ -0,0 +1,121 @@ +""" +Base Pipeline Factory for Project-specific Pipeline Creation. + +This module provides the abstract base class for pipeline factories, +defining a unified interface for creating pipelines across different backends. + +Architecture: + - Each project (CenterPoint, YOLOX, etc.) implements its own factory + - Factories are registered with the PipelineRegistry + - Main factory uses registry to lookup and delegate to project factories + +Benefits: + - Open-Closed Principle: Add new projects without modifying main factory + - Single Responsibility: Each project manages its own pipeline creation + - Decoupled: Project-specific logic stays in project directories +""" + +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.common.base_pipeline import BaseDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class BasePipelineFactory(ABC): + """ + Abstract base class for project-specific pipeline factories. + + Each project (CenterPoint, YOLOX, Calibration, etc.) should implement + this interface to provide its own pipeline creation logic. + + Example: + class CenterPointPipelineFactory(BasePipelineFactory): + @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: + # Create and return appropriate pipeline based on backend + ... + """ + + @classmethod + @abstractmethod + def get_project_name(cls) -> str: + """ + Get the project name for registry lookup. + + Returns: + Project name (e.g., "centerpoint", "yolox", "calibration") + """ + raise NotImplementedError + + @classmethod + @abstractmethod + def create_pipeline( + cls, + model_spec: ModelSpec, + pytorch_model: Any, + device: Optional[str] = None, + **kwargs, + ) -> BaseDeploymentPipeline: + """ + Create a pipeline for the specified backend. + + Args: + 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 for the specified backend + + Raises: + ValueError: If backend is not supported + """ + raise NotImplementedError + + @classmethod + def get_supported_backends(cls) -> list: + """ + Get list of supported backends for this project. + + Override this method to specify which backends are supported. + Default implementation returns all common backends. + + Returns: + List of supported Backend enums + """ + return [Backend.PYTORCH, Backend.ONNX, Backend.TENSORRT] + + @classmethod + def _validate_backend(cls, backend: Backend) -> None: + """ + Validate that the backend is supported. + + Args: + backend: Backend to validate + + Raises: + ValueError: If backend is not supported + """ + 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()}. " + f"Supported backends: {supported_names}" + ) diff --git a/deployment/pipelines/common/project_names.py b/deployment/pipelines/common/project_names.py new file mode 100644 index 000000000..cbed85316 --- /dev/null +++ b/deployment/pipelines/common/project_names.py @@ -0,0 +1,36 @@ +""" +Project Names for Pipeline Registry. + +Usage: + from deployment.pipelines.common.project_names import ProjectNames + + # In factory: + class CenterPointPipelineFactory(BasePipelineFactory): + @classmethod + def get_project_name(cls) -> str: + return ProjectNames.CENTERPOINT + + # When creating pipeline: + PipelineFactory.create(ProjectNames.CENTERPOINT, model_spec, pytorch_model) +""" + + +class ProjectNames: + """ + Constants for project names. + + Add new project names here when adding new projects. + """ + + CENTERPOINT = "centerpoint" + YOLOX = "yolox" + CALIBRATION = "calibration" + + @classmethod + def all(cls) -> list: + """Return all defined project names.""" + return [ + value + for key, value in vars(cls).items() + if not key.startswith("_") and isinstance(value, str) and key.isupper() + ] diff --git a/deployment/pipelines/common/registry.py b/deployment/pipelines/common/registry.py new file mode 100644 index 000000000..dc0a6db13 --- /dev/null +++ b/deployment/pipelines/common/registry.py @@ -0,0 +1,168 @@ +""" +Pipeline Registry for Dynamic Project Pipeline Registration. + +This module provides a registry pattern for managing pipeline factories, +allowing projects to register themselves and be discovered at runtime. + +Usage: + # In project's factory module (e.g., centerpoint/factory.py): + from deployment.pipelines.common.registry import pipeline_registry + + @pipeline_registry.register + class CenterPointPipelineFactory(BasePipelineFactory): + ... + + # In main code: + pipeline = pipeline_registry.create_pipeline(ProjectNames.CENTERPOINT, model_spec, pytorch_model) +""" + +import logging +from typing import Any, Dict, Optional, Type + +from deployment.core.evaluation.evaluator_types import ModelSpec +from deployment.pipelines.common.base_factory import BasePipelineFactory +from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class PipelineRegistry: + """ + Registry for project-specific pipeline factories. + + This registry maintains a mapping of project names to their factory classes, + enabling dynamic pipeline creation without hardcoding project-specific logic. + + Example: + # Register a factory + @pipeline_registry.register + class MyProjectPipelineFactory(BasePipelineFactory): + @classmethod + def get_project_name(cls) -> str: + return "my_project" + ... + + # Create a pipeline + pipeline = pipeline_registry.create_pipeline( + "my_project", model_spec, pytorch_model + ) + """ + + def __init__(self): + self._factories: Dict[str, Type[BasePipelineFactory]] = {} + + def register(self, factory_cls: Type[BasePipelineFactory]) -> Type[BasePipelineFactory]: + """ + Register a pipeline factory class. + + Can be used as a decorator or called directly. + + Args: + factory_cls: Factory class implementing BasePipelineFactory + + Returns: + The registered factory class (for decorator usage) + + Example: + @pipeline_registry.register + class CenterPointPipelineFactory(BasePipelineFactory): + ... + """ + if not issubclass(factory_cls, BasePipelineFactory): + raise TypeError(f"Factory class must inherit from BasePipelineFactory, " f"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]: + """ + Get the factory class for a project. + + Args: + project_name: Name of the project + + Returns: + Factory class for the project + + Raises: + KeyError: If project is not registered + """ + if project_name not in self._factories: + available = list(self._factories.keys()) + raise KeyError(f"No factory registered for project '{project_name}'. " f"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: + """ + 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 + """ + 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: + """ + List all registered projects. + + Returns: + List of registered project names + """ + return list(self._factories.keys()) + + def is_registered(self, project_name: str) -> bool: + """ + Check if a project is registered. + + Args: + project_name: Name of the project + + Returns: + True if project is registered + """ + return project_name in self._factories + + def reset(self) -> None: + """ + Reset the registry (mainly for testing). + """ + self._factories.clear() + + +# Global registry instance +pipeline_registry = PipelineRegistry() diff --git a/deployment/pipelines/factory.py b/deployment/pipelines/factory.py index 250cca8ab..acc36eae5 100644 --- a/deployment/pipelines/factory.py +++ b/deployment/pipelines/factory.py @@ -1,13 +1,30 @@ """ -Pipeline factory for centralized pipeline instantiation. +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.common import pipeline_registry + pipeline = pipeline_registry.create_pipeline("centerpoint", model_spec, pytorch_model) """ import logging -from typing import Any, Dict, List, Optional, Type +from typing import Any, List, Optional -from deployment.core.backend import Backend from deployment.core.evaluation.evaluator_types import ModelSpec from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.common.registry import pipeline_registry logger = logging.getLogger(__name__) @@ -15,148 +32,78 @@ 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_centerpoint_pipeline( + def create( + project_name: str, model_spec: ModelSpec, pytorch_model: Any, device: Optional[str] = None, + **kwargs, ) -> BaseDeploymentPipeline: """ - Create a CenterPoint pipeline. + 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: - CenterPoint pipeline instance + Pipeline instance + + Raises: + KeyError: If project is not registered + ValueError: If backend is not supported + + Example: + >>> pipeline = PipelineFactory.create( + ... "centerpoint", + ... model_spec, + ... pytorch_model, + ... ) """ - from deployment.pipelines.centerpoint import ( - CenterPointONNXPipeline, - CenterPointPyTorchPipeline, - CenterPointTensorRTPipeline, + return pipeline_registry.create_pipeline( + project_name=project_name, + model_spec=model_spec, + pytorch_model=pytorch_model, + device=device, + **kwargs, ) - device = device or model_spec.device - backend = model_spec.backend - - if backend is Backend.PYTORCH: - return CenterPointPyTorchPipeline(pytorch_model, device=device) - elif backend is Backend.ONNX: - return CenterPointONNXPipeline(pytorch_model, onnx_dir=model_spec.path, device=device) - elif backend is Backend.TENSORRT: - return CenterPointTensorRTPipeline(pytorch_model, tensorrt_dir=model_spec.path, device=device) - else: - raise ValueError(f"Unsupported backend: {backend.value}") - @staticmethod - def create_yolox_pipeline( - model_spec: ModelSpec, - pytorch_model: Any, - num_classes: int, - class_names: List[str], - device: Optional[str] = None, - ) -> BaseDeploymentPipeline: + def list_projects() -> List[str]: """ - Create a YOLOX pipeline. - - Args: - model_spec: Model specification (backend/device/path) - pytorch_model: PyTorch model instance - num_classes: Number of classes - class_names: List of class names - device: Override device (uses model_spec.device if None) + List all registered projects. Returns: - YOLOX pipeline instance + List of registered project names """ - from deployment.pipelines.yolox import ( - YOLOXONNXPipeline, - YOLOXPyTorchPipeline, - YOLOXTensorRTPipeline, - ) - - device = device or model_spec.device - backend = model_spec.backend - - if backend is Backend.PYTORCH: - return YOLOXPyTorchPipeline( - pytorch_model=pytorch_model, - device=device, - num_classes=num_classes, - class_names=class_names, - ) - elif backend is Backend.ONNX: - return YOLOXONNXPipeline( - onnx_path=model_spec.path, - device=device, - num_classes=num_classes, - class_names=class_names, - ) - elif backend is Backend.TENSORRT: - return YOLOXTensorRTPipeline( - engine_path=model_spec.path, - device=device, - num_classes=num_classes, - class_names=class_names, - ) - else: - raise ValueError(f"Unsupported backend: {backend.value}") + return pipeline_registry.list_projects() @staticmethod - def create_calibration_pipeline( - model_spec: ModelSpec, - pytorch_model: Any, - num_classes: int = 2, - class_names: Optional[List[str]] = None, - device: Optional[str] = None, - ) -> BaseDeploymentPipeline: + def is_project_registered(project_name: str) -> bool: """ - Create a CalibrationStatusClassification pipeline. + Check if a project is registered. Args: - model_spec: Model specification (backend/device/path) - pytorch_model: PyTorch model instance - num_classes: Number of classes (default: 2) - class_names: List of class names (default: ["miscalibrated", "calibrated"]) - device: Override device (uses model_spec.device if None) + project_name: Name of the project Returns: - Calibration pipeline instance + True if project is registered """ - from deployment.pipelines.calibration import ( - CalibrationONNXPipeline, - CalibrationPyTorchPipeline, - CalibrationTensorRTPipeline, - ) - - device = device or model_spec.device - backend = model_spec.backend - class_names = class_names or ["miscalibrated", "calibrated"] - - if backend is Backend.PYTORCH: - return CalibrationPyTorchPipeline( - pytorch_model=pytorch_model, - device=device, - num_classes=num_classes, - class_names=class_names, - ) - elif backend is Backend.ONNX: - return CalibrationONNXPipeline( - onnx_path=model_spec.path, - device=device, - num_classes=num_classes, - class_names=class_names, - ) - elif backend is Backend.TENSORRT: - return CalibrationTensorRTPipeline( - engine_path=model_spec.path, - device=device, - num_classes=num_classes, - class_names=class_names, - ) - else: - raise ValueError(f"Unsupported backend: {backend.value}") + return pipeline_registry.is_registered(project_name) From 48a9734bf2f7a54de4febdd5dcb20a8899b58229 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 18:38:33 +0900 Subject: [PATCH 36/62] chore: add error message if export fail Signed-off-by: vividf --- deployment/runners/common/export_orchestrator.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index 4ae9de26d..1ec3e0363 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -143,6 +143,7 @@ def run( if requires_pytorch: pytorch_model = self._load_and_register_pytorch_model(checkpoint_path, context) if pytorch_model is None: + self.logger.error("Export aborted: failed to load PyTorch model; skipping remaining export steps.") return result # Loading failed result.pytorch_model = pytorch_model @@ -155,12 +156,17 @@ def run( return result pytorch_model = self._load_and_register_pytorch_model(checkpoint_path, context) if pytorch_model is None: + self.logger.error( + "ONNX export aborted: failed to load PyTorch model; skipping ONNX/TensorRT export." + ) return result result.pytorch_model = pytorch_model onnx_artifact = self._export_onnx(pytorch_model, context) if onnx_artifact: result.onnx_path = onnx_artifact.path + else: + self.logger.error("ONNX export requested but no artifact was produced.") # Step 4: Export TensorRT if requested if should_export_trt: @@ -181,6 +187,8 @@ def run( trt_artifact = self._export_tensorrt(onnx_path, context) if trt_artifact: result.tensorrt_path = trt_artifact.path + else: + self.logger.error("TensorRT export requested but no artifact was produced.") # Step 5: Resolve external paths from evaluation config self._resolve_external_artifacts(result) @@ -298,7 +306,7 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A context=context, ) except Exception: - self.logger.error("ONNX export workflow failed") + self.logger.exception("ONNX export workflow failed") raise self.artifact_manager.register_artifact(Backend.ONNX, artifact) @@ -334,7 +342,7 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A try: exporter.export(pytorch_model, input_tensor, output_path) except Exception: - self.logger.error("ONNX export failed") + self.logger.exception("ONNX export failed") raise multi_file = bool(self.config.onnx_config.get("multi_file", False)) @@ -403,7 +411,7 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A context=context, ) except Exception: - self.logger.error("TensorRT export workflow failed") + self.logger.exception("TensorRT export workflow failed") raise self.artifact_manager.register_artifact(Backend.TENSORRT, artifact) @@ -421,7 +429,7 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A onnx_path=onnx_path, ) except Exception: - self.logger.error("TensorRT export failed") + self.logger.exception("TensorRT export failed") raise self.artifact_manager.register_artifact(Backend.TENSORRT, artifact) From 734f159b475d8066efb8e4bd0174334b7a9018af Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 19:43:16 +0900 Subject: [PATCH 37/62] chore: refactor export orchestrator Signed-off-by: vividf --- .../runners/common/export_orchestrator.py | 150 +++++++++++++----- 1 file changed, 111 insertions(+), 39 deletions(-) diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index 1ec3e0363..ea5d954d2 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -141,54 +141,22 @@ def run( # Step 2: Load PyTorch model if needed pytorch_model = None if requires_pytorch: - pytorch_model = self._load_and_register_pytorch_model(checkpoint_path, context) - if pytorch_model is None: - self.logger.error("Export aborted: failed to load PyTorch model; skipping remaining export steps.") - return result # Loading failed - result.pytorch_model = pytorch_model + pytorch_model, success = self._ensure_pytorch_model_loaded(pytorch_model, checkpoint_path, context, result) + if not success: + return result # Step 3: Export ONNX if requested if should_export_onnx: - # Load model if not already loaded - if pytorch_model is None: - if not checkpoint_path: - self.logger.error("ONNX export requires checkpoint_path but none was provided.") - return result - pytorch_model = self._load_and_register_pytorch_model(checkpoint_path, context) - if pytorch_model is None: - self.logger.error( - "ONNX export aborted: failed to load PyTorch model; skipping ONNX/TensorRT export." - ) - return result - result.pytorch_model = pytorch_model - - onnx_artifact = self._export_onnx(pytorch_model, context) - if onnx_artifact: - result.onnx_path = onnx_artifact.path - else: - self.logger.error("ONNX export requested but no artifact was produced.") + result.onnx_path = self._run_onnx_export(pytorch_model, context) # Step 4: Export TensorRT if requested if should_export_trt: - onnx_path = result.onnx_path or external_onnx_path + onnx_path = self._resolve_onnx_path_for_trt(result.onnx_path, 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 result - - # Ensure ONNX artifact is registered result.onnx_path = onnx_path - if onnx_path and os.path.exists(onnx_path): - multi_file = os.path.isdir(onnx_path) - self.artifact_manager.register_artifact(Backend.ONNX, Artifact(path=onnx_path, multi_file=multi_file)) - - trt_artifact = self._export_tensorrt(onnx_path, context) - if trt_artifact: - result.tensorrt_path = trt_artifact.path - else: - self.logger.error("TensorRT export requested but no artifact was produced.") + self._register_external_onnx_artifact(onnx_path) + result.tensorrt_path = self._run_tensorrt_export(onnx_path, context) # Step 5: Resolve external paths from evaluation config self._resolve_external_artifacts(result) @@ -263,6 +231,110 @@ def _load_and_register_pytorch_model( 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]: + """ + Ensure PyTorch model is loaded, loading it if necessary. + + Args: + pytorch_model: Existing model or None + checkpoint_path: Path to checkpoint file + context: Export context + result: Export result to update + + Returns: + Tuple of (model, success). If success is False, export should abort. + """ + 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]: + """ + Execute ONNX export and return the artifact path. + + Args: + pytorch_model: PyTorch model to export + context: Export context + + Returns: + Path to exported ONNX artifact, or None if export failed + """ + 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]: + """ + Resolve ONNX path for TensorRT export. + + Args: + exported_onnx_path: Path from ONNX export step + external_onnx_path: External path from config + + Returns: + Resolved ONNX path, or None with error logged if unavailable + """ + 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: + """ + Register an external ONNX artifact if it exists. + + Args: + onnx_path: Path to ONNX file or directory + """ + 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]: + """ + Execute TensorRT export and return the artifact path. + + Args: + onnx_path: Path to ONNX model + context: Export context + + Returns: + Path to exported TensorRT engine, or None if export failed + """ + 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]: """ Export model to ONNX format. From 1f730eefba722d6e8ff9f3bb3523f3034b9e78e3 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 19:50:53 +0900 Subject: [PATCH 38/62] chore: clean up determine pytorch requirements func Signed-off-by: vividf --- .../runners/common/export_orchestrator.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index ea5d954d2..229c9c0a6 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -170,30 +170,31 @@ def _determine_pytorch_requirements(self) -> bool: Returns: True if PyTorch model is needed, False otherwise """ - should_export_onnx = self.config.export_config.should_export_onnx() - eval_config = self.config.evaluation_config - verification_cfg = self.config.verification_config + # Check if ONNX export is needed (requires PyTorch model) + if self.config.export_config.should_export_onnx(): + return True # Check if PyTorch evaluation is needed - needs_pytorch_eval = False + 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): - needs_pytorch_eval = True + return True # Check if PyTorch is needed for verification - needs_pytorch_for_verification = False + 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: - needs_pytorch_for_verification = any( + if any( policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios - ) + ): + return True - return should_export_onnx or needs_pytorch_eval or needs_pytorch_for_verification + return False def _load_and_register_pytorch_model( self, From 3acd96a9056f3d6894e24ac980eb504962e3887c Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 13:33:30 +0900 Subject: [PATCH 39/62] feat: integrate centerpoint to new deployment framework Signed-off-by: vividf --- deployment/exporters/centerpoint/__init__.py | 11 + .../exporters/centerpoint/model_wrappers.py | 15 + .../exporters/centerpoint/onnx_workflow.py | 143 ++++++++ .../centerpoint/tensorrt_workflow.py | 130 +++++++ deployment/pipelines/centerpoint/__init__.py | 44 +++ .../pipelines/centerpoint/centerpoint_onnx.py | 148 ++++++++ .../centerpoint/centerpoint_pipeline.py | 316 +++++++++++++++++ .../centerpoint/centerpoint_pytorch.py | 227 ++++++++++++ .../centerpoint/centerpoint_tensorrt.py | 264 ++++++++++++++ deployment/runners/projects/__init__.py | 11 + .../runners/projects/centerpoint_runner.py | 169 +++++++++ projects/CenterPoint/README.md | 53 ++- .../second_secfpn_4xb16_121m_j6gen2_base.py | 40 +-- projects/CenterPoint/deploy/README.md | 324 +++++++++++++++++ projects/CenterPoint/deploy/__init__.py | 5 + .../CenterPoint/deploy/component_extractor.py | 185 ++++++++++ .../deploy/configs/deploy_config.py | 241 +++++++++++++ projects/CenterPoint/deploy/data_loader.py | 332 ++++++++++++++++++ projects/CenterPoint/deploy/evaluator.py | 212 +++++++++++ projects/CenterPoint/deploy/main.py | 128 +++++++ projects/CenterPoint/deploy/utils.py | 228 ++++++++++++ .../models/detectors/centerpoint_onnx.py | 246 ++++++++++--- .../models/detectors/centerpoint_onnx_old.py | 182 ++++++++++ 23 files changed, 3555 insertions(+), 99 deletions(-) create mode 100644 deployment/exporters/centerpoint/__init__.py create mode 100644 deployment/exporters/centerpoint/model_wrappers.py create mode 100644 deployment/exporters/centerpoint/onnx_workflow.py create mode 100644 deployment/exporters/centerpoint/tensorrt_workflow.py create mode 100644 deployment/pipelines/centerpoint/__init__.py create mode 100644 deployment/pipelines/centerpoint/centerpoint_onnx.py create mode 100644 deployment/pipelines/centerpoint/centerpoint_pipeline.py create mode 100644 deployment/pipelines/centerpoint/centerpoint_pytorch.py create mode 100644 deployment/pipelines/centerpoint/centerpoint_tensorrt.py create mode 100644 deployment/runners/projects/__init__.py create mode 100644 deployment/runners/projects/centerpoint_runner.py create mode 100644 projects/CenterPoint/deploy/README.md create mode 100644 projects/CenterPoint/deploy/__init__.py create mode 100644 projects/CenterPoint/deploy/component_extractor.py create mode 100644 projects/CenterPoint/deploy/configs/deploy_config.py create mode 100644 projects/CenterPoint/deploy/data_loader.py create mode 100644 projects/CenterPoint/deploy/evaluator.py create mode 100644 projects/CenterPoint/deploy/main.py create mode 100644 projects/CenterPoint/deploy/utils.py create mode 100644 projects/CenterPoint/models/detectors/centerpoint_onnx_old.py diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py new file mode 100644 index 000000000..3c6ea8053 --- /dev/null +++ b/deployment/exporters/centerpoint/__init__.py @@ -0,0 +1,11 @@ +"""CenterPoint-specific exporter workflows and model wrappers.""" + +from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper +from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow +from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow + +__all__ = [ + "CenterPointONNXWrapper", + "CenterPointONNXExportWorkflow", + "CenterPointTensorRTExportWorkflow", +] diff --git a/deployment/exporters/centerpoint/model_wrappers.py b/deployment/exporters/centerpoint/model_wrappers.py new file mode 100644 index 000000000..590a137eb --- /dev/null +++ b/deployment/exporters/centerpoint/model_wrappers.py @@ -0,0 +1,15 @@ +""" +CenterPoint-specific model wrappers for ONNX export. + +CenterPoint models don't require special output format conversion, +so we use IdentityWrapper (no modification to model output). +""" + +from deployment.exporters.common.model_wrappers import BaseModelWrapper, IdentityWrapper + +# CenterPoint doesn't need special wrapper, use IdentityWrapper +CenterPointONNXWrapper = IdentityWrapper + +__all__ = [ + "CenterPointONNXWrapper", +] diff --git a/deployment/exporters/centerpoint/onnx_workflow.py b/deployment/exporters/centerpoint/onnx_workflow.py new file mode 100644 index 000000000..c1a640702 --- /dev/null +++ b/deployment/exporters/centerpoint/onnx_workflow.py @@ -0,0 +1,143 @@ +""" +CenterPoint ONNX export workflow using composition. + +This workflow orchestrates multi-file ONNX export for CenterPoint models. +It uses the ModelComponentExtractor pattern to keep model-specific logic +separate from the generic export workflow. +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +import torch + +from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig +from deployment.core.contexts import ExportContext +from deployment.exporters.common.factory import ExporterFactory +from deployment.exporters.common.model_wrappers import IdentityWrapper +from deployment.exporters.workflows.base import OnnxExportWorkflow +from deployment.exporters.workflows.interfaces import ModelComponentExtractor + + +class CenterPointONNXExportWorkflow(OnnxExportWorkflow): + """ + CenterPoint ONNX export workflow. + + Orchestrates multi-file ONNX export using a generic ONNXExporter and + CenterPointComponentExtractor for model-specific logic. + + Components exported: + - voxel encoder → pts_voxel_encoder.onnx + - backbone+neck+head → pts_backbone_neck_head.onnx + """ + + def __init__( + self, + exporter_factory: type[ExporterFactory], + component_extractor: ModelComponentExtractor, + config: BaseDeploymentConfig, + logger: Optional[logging.Logger] = None, + ): + """ + Initialize CenterPoint ONNX export workflow. + + Args: + exporter_factory: Factory class for creating exporters + component_extractor: Extracts model components (injected from projects/) + config: Deployment configuration + logger: Optional logger instance + """ + 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, + context: Optional[ExportContext] = None, + ) -> Artifact: + """ + Export CenterPoint model to multi-file ONNX format. + + Args: + model: CenterPoint PyTorch model + data_loader: Data loader for extracting sample features + output_dir: Output directory for ONNX files + config: Deployment configuration (not used, kept for interface) + sample_idx: Sample index to use for feature extraction + context: Export context with project-specific parameters (currently unused, + but available for future extensions) + + Returns: + Artifact pointing to output directory with multi_file=True + """ + # context available for future extensions + _ = context + # Ensure output directory exists + os.makedirs(output_dir, exist_ok=True) + + 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}") + + # Extract features using component extractor helper + # This delegates to model's _extract_features method + try: + self.logger.info("Extracting features from sample data...") + if hasattr(self.component_extractor, "extract_features"): + sample_data = self.component_extractor.extract_features(model, data_loader, sample_idx) + else: + raise AttributeError("Component extractor must provide extract_features method") + except Exception as exc: + self.logger.error(f"Failed to extract features: {exc}") + raise RuntimeError("Feature extraction failed") from exc + + # Extract exportable components (delegates all model-specific logic) + components = self.component_extractor.extract_components(model, sample_data) + + # Export each component using generic ONNX exporter + exported_paths = [] + for i, component in enumerate(components, 1): + self.logger.info(f"\n[{i}/{len(components)}] Exporting {component.name}...") + + # Create fresh exporter for each component (no caching) + exporter = self.exporter_factory.create_onnx_exporter( + config=self.config, wrapper_cls=IdentityWrapper, logger=self.logger # CenterPoint doesn't need wrapper + ) + + # Determine output path + output_path = os.path.join(output_dir, f"{component.name}.onnx") + + # Export component + try: + exporter.export( + model=component.module, + sample_input=component.sample_input, + output_path=output_path, + config_override=component.config_override, + ) + exported_paths.append(output_path) + self.logger.info(f"✓ Exported {component.name}: {output_path}") + except Exception as exc: + self.logger.error(f"Failed to export {component.name}") + raise RuntimeError(f"{component.name} export failed") from exc + + # Log summary + 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)}") + + return Artifact(path=output_dir, multi_file=True) diff --git a/deployment/exporters/centerpoint/tensorrt_workflow.py b/deployment/exporters/centerpoint/tensorrt_workflow.py new file mode 100644 index 000000000..c94c88b1d --- /dev/null +++ b/deployment/exporters/centerpoint/tensorrt_workflow.py @@ -0,0 +1,130 @@ +""" +CenterPoint TensorRT export workflow using composition. + +This workflow orchestrates multi-file TensorRT export for CenterPoint models. +It converts multiple ONNX files to TensorRT engines. +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +import torch + +from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig +from deployment.core.contexts import ExportContext +from deployment.exporters.common.factory import ExporterFactory +from deployment.exporters.workflows.base import TensorRTExportWorkflow + + +class CenterPointTensorRTExportWorkflow(TensorRTExportWorkflow): + """ + CenterPoint TensorRT export workflow. + + Converts CenterPoint ONNX files to multiple TensorRT engines: + - pts_voxel_encoder.onnx → pts_voxel_encoder.engine + - pts_backbone_neck_head.onnx → pts_backbone_neck_head.engine + """ + + def __init__( + self, + exporter_factory: type[ExporterFactory], + config: BaseDeploymentConfig, + logger: Optional[logging.Logger] = None, + ): + """ + Initialize CenterPoint TensorRT export workflow. + + Args: + exporter_factory: Factory class for creating exporters + config: Deployment configuration + logger: Optional logger instance + """ + self.exporter_factory = exporter_factory + self.config = config + self.logger = logger or logging.getLogger(__name__) + + def export( + self, + *, + onnx_path: str, + output_dir: str, + config: BaseDeploymentConfig, + device: str, + data_loader: BaseDataLoader, + context: Optional[ExportContext] = None, + ) -> Artifact: + """ + Export CenterPoint ONNX files to TensorRT engines. + + Args: + onnx_path: Path to directory containing ONNX files + output_dir: Output directory for TensorRT engines + config: Deployment configuration (not used, kept for interface) + device: CUDA device string (e.g., "cuda:0") + data_loader: Data loader (not used for TensorRT) + context: Export context with project-specific parameters (currently unused, + but available for future extensions) + + Returns: + Artifact pointing to output directory with multi_file=True + """ + # context available for future extensions + _ = context + onnx_dir = onnx_path + + # Validate inputs + 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") + + if not os.path.isdir(onnx_dir): + raise ValueError(f"onnx_path must be a directory for multi-file export, got: {onnx_dir}") + + # Set CUDA device + device_id = int(device.split(":", 1)[1]) + torch.cuda.set_device(device_id) + self.logger.info(f"Using CUDA device: {device}") + + # Create output directory + os.makedirs(output_dir, exist_ok=True) + + # Define ONNX → TensorRT file pairs + onnx_files = [ + ("pts_voxel_encoder.onnx", "pts_voxel_encoder.engine"), + ("pts_backbone_neck_head.onnx", "pts_backbone_neck_head.engine"), + ] + + # Convert each ONNX file to TensorRT + for i, (onnx_file, trt_file) in enumerate(onnx_files, 1): + onnx_file_path = os.path.join(onnx_dir, onnx_file) + trt_path = os.path.join(output_dir, trt_file) + + # Validate ONNX file exists + if not os.path.exists(onnx_file_path): + raise FileNotFoundError(f"ONNX file not found: {onnx_file_path}") + + self.logger.info(f"\n[{i}/{len(onnx_files)}] Converting {onnx_file} to TensorRT...") + + # Create fresh exporter (no caching) + exporter = self.exporter_factory.create_tensorrt_exporter(config=self.config, logger=self.logger) + + # Export to TensorRT + try: + artifact = exporter.export( + model=None, # Not needed for TensorRT conversion + sample_input=None, # Shape info from config + output_path=trt_path, + onnx_path=onnx_file_path, + ) + self.logger.info(f"✓ TensorRT engine saved: {artifact.path}") + except Exception as exc: + self.logger.error(f"Failed to convert {onnx_file} to TensorRT") + raise RuntimeError(f"TensorRT export failed for {onnx_file}") from exc + + self.logger.info(f"\nAll TensorRT engines exported successfully to {output_dir}") + return Artifact(path=output_dir, multi_file=True) diff --git a/deployment/pipelines/centerpoint/__init__.py b/deployment/pipelines/centerpoint/__init__.py new file mode 100644 index 000000000..9786420c2 --- /dev/null +++ b/deployment/pipelines/centerpoint/__init__.py @@ -0,0 +1,44 @@ +""" +CenterPoint Deployment Pipelines. + +This module provides unified deployment pipelines for CenterPoint 3D object detection +across different backends (PyTorch, ONNX, TensorRT). + +Example usage: + +PyTorch: + >>> from deployment.pipelines.centerpoint import CenterPointPyTorchPipeline + >>> pipeline = CenterPointPyTorchPipeline(model, device='cuda') + >>> predictions, latency, breakdown = pipeline.infer(points) + +ONNX: + >>> from deployment.pipelines.centerpoint import CenterPointONNXPipeline + >>> pipeline = CenterPointONNXPipeline(pytorch_model, onnx_dir='models', device='cuda') + >>> predictions, latency, breakdown = pipeline.infer(points) + +TensorRT: + >>> from deployment.pipelines.centerpoint import CenterPointTensorRTPipeline + >>> pipeline = CenterPointTensorRTPipeline(pytorch_model, tensorrt_dir='engines', device='cuda') + >>> predictions, latency, breakdown = pipeline.infer(points) + +Note: + All pipelines now use the unified `infer()` interface from the base class. + The `breakdown` dict contains stage-wise latencies: + - preprocessing_ms + - voxel_encoder_ms + - middle_encoder_ms + - backbone_head_ms + - postprocessing_ms +""" + +from deployment.pipelines.centerpoint.centerpoint_onnx import CenterPointONNXPipeline +from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline +from deployment.pipelines.centerpoint.centerpoint_pytorch import CenterPointPyTorchPipeline +from deployment.pipelines.centerpoint.centerpoint_tensorrt import CenterPointTensorRTPipeline + +__all__ = [ + "CenterPointDeploymentPipeline", + "CenterPointPyTorchPipeline", + "CenterPointONNXPipeline", + "CenterPointTensorRTPipeline", +] diff --git a/deployment/pipelines/centerpoint/centerpoint_onnx.py b/deployment/pipelines/centerpoint/centerpoint_onnx.py new file mode 100644 index 000000000..47e2c7572 --- /dev/null +++ b/deployment/pipelines/centerpoint/centerpoint_onnx.py @@ -0,0 +1,148 @@ +""" +CenterPoint ONNX Pipeline Implementation. + +This module implements the CenterPoint pipeline using ONNX Runtime, +optimizing voxel encoder and backbone/head while keeping middle encoder in PyTorch. +""" + +import logging +import os.path as osp +from typing import List + +import numpy as np +import onnxruntime as ort +import torch + +from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class CenterPointONNXPipeline(CenterPointDeploymentPipeline): + """ + ONNX Runtime implementation of CenterPoint pipeline. + + Uses ONNX Runtime for voxel encoder and backbone/head, + while keeping middle encoder in PyTorch (sparse convolution cannot be converted). + + Provides good cross-platform compatibility and moderate speedup. + """ + + def __init__(self, pytorch_model, onnx_dir: str, device: str = "cpu"): + """ + Initialize ONNX pipeline. + + Args: + pytorch_model: PyTorch model (for preprocessing, middle encoder, postprocessing) + onnx_dir: Directory containing ONNX model files + device: Device for inference ('cpu' or 'cuda') + """ + 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): + """Load ONNX models for voxel encoder and backbone/head.""" + # Define model paths + 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") + + # Verify files exist + 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}") + + # Create session options + so = ort.SessionOptions() + # Disable graph optimization for numerical consistency with PyTorch + # Graph optimizations can reorder operations and fuse layers, causing numerical differences + so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + so.log_severity_level = 3 # ERROR level + + # Set execution providers + 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") + + # Load voxel encoder + 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}") + + # Load backbone + head + 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: + """ + Run voxel encoder using ONNX Runtime. + + Args: + input_features: Input features [N_voxels, max_points, feature_dim] + + Returns: + voxel_features: Voxel features [N_voxels, feature_dim] + """ + # Convert to numpy + input_array = input_features.cpu().numpy().astype(np.float32) + + # Get input and output names from ONNX model + input_name = self.voxel_encoder_session.get_inputs()[0].name + output_name = self.voxel_encoder_session.get_outputs()[0].name + + # Run ONNX inference with explicit output name for consistency + outputs = self.voxel_encoder_session.run( + [output_name], {input_name: input_array} # Specify output name explicitly + ) + + # Convert back to torch tensor + voxel_features = torch.from_numpy(outputs[0]).to(self.device) + + # Squeeze middle dimension if present (ONNX may output 3D) + 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]: + """ + Run backbone + neck + head using ONNX Runtime. + + Args: + spatial_features: Spatial features [B, C, H, W] + + Returns: + List of head outputs: [heatmap, reg, height, dim, rot, vel] + """ + # Convert to numpy + input_array = spatial_features.cpu().numpy().astype(np.float32) + + # Get input and output names from ONNX model + input_name = self.backbone_head_session.get_inputs()[0].name + output_names = [output.name for output in self.backbone_head_session.get_outputs()] + + # Run ONNX inference with explicit output names for consistency + outputs = self.backbone_head_session.run( + output_names, {input_name: input_array} # Specify output names explicitly + ) + + # Convert outputs to torch tensors + # outputs should be: [heatmap, reg, height, dim, rot, vel] + 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/pipelines/centerpoint/centerpoint_pipeline.py b/deployment/pipelines/centerpoint/centerpoint_pipeline.py new file mode 100644 index 000000000..3fa9001ae --- /dev/null +++ b/deployment/pipelines/centerpoint/centerpoint_pipeline.py @@ -0,0 +1,316 @@ +""" +CenterPoint Deployment Pipeline Base Class. + +This module provides the abstract base class for CenterPoint deployment, +defining the unified pipeline that shares PyTorch processing while allowing +backend-specific optimizations for voxel encoder and backbone/head. +""" + +import logging +import time +from abc import abstractmethod +from typing import Any, Dict, List, Tuple + +import numpy as np +import torch + +from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class CenterPointDeploymentPipeline(BaseDeploymentPipeline): + """ + Abstract base class for CenterPoint deployment pipeline. + + This class defines the complete inference flow for CenterPoint, with: + - Shared preprocessing (voxelization + input features) + - Shared middle encoder processing + - Shared postprocessing (predict_by_feat) + - Abstract methods for backend-specific voxel encoder and backbone/head + + The design eliminates code duplication by centralizing PyTorch processing + while allowing ONNX/TensorRT backends to optimize the convertible parts. + """ + + def __init__(self, pytorch_model, device: str = "cuda", backend_type: str = "unknown"): + """ + Initialize CenterPoint pipeline. + + Args: + pytorch_model: PyTorch model (used for preprocessing, middle encoder, postprocessing) + device: Device for inference ('cuda' or 'cpu') + backend_type: Backend type ('pytorch', 'onnx', 'tensorrt') + """ + # Get class names from model config if available + class_names = ["VEHICLE", "PEDESTRIAN", "CYCLIST"] # Default T4Dataset classes + if hasattr(pytorch_model, "CLASSES"): + class_names = pytorch_model.CLASSES + elif hasattr(pytorch_model, "cfg") and hasattr(pytorch_model.cfg, "class_names"): + class_names = pytorch_model.cfg.class_names + + # Get point cloud range and voxel size from model config + point_cloud_range = None + voxel_size = None + if hasattr(pytorch_model, "cfg"): + if hasattr(pytorch_model.cfg, "point_cloud_range"): + point_cloud_range = pytorch_model.cfg.point_cloud_range + if hasattr(pytorch_model.cfg, "voxel_size"): + voxel_size = pytorch_model.cfg.voxel_size + + # Initialize parent class + 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 = {} # Store stage-wise latencies for detailed breakdown + + # ========== Shared Methods (All backends use same logic) ========== + + def preprocess(self, points: torch.Tensor, **kwargs) -> Tuple[Dict[str, torch.Tensor], Dict]: + """ + Preprocess: voxelization + input features preparation. + + ONNX/TensorRT backends use this for voxelization and input feature preparation. + PyTorch backend may override this method for end-to-end inference. + + Args: + points: Input point cloud [N, point_features] + **kwargs: Additional preprocessing parameters + + Returns: + Tuple of (preprocessed_dict, metadata): + - preprocessed_dict: Dictionary containing: + - 'input_features': 11-dim features for voxel encoder [N_voxels, max_points, 11] + - 'voxels': Raw voxel data + - 'num_points': Number of points per voxel + - 'coors': Voxel coordinates [N_voxels, 4] (batch_idx, z, y, x) + - metadata: Empty dict (for compatibility with base class) + """ + from mmdet3d.structures import Det3DDataSample + + # Ensure points are on correct device + points_tensor = points.to(self.device) + + # Step 1: Voxelization using PyTorch data_preprocessor + 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"] + + # Step 2: Get input features (only for ONNX/TensorRT models) + 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 tuple format for compatibility with base class infer() + return preprocessed_dict, {} + + def process_middle_encoder(self, voxel_features: torch.Tensor, coors: torch.Tensor) -> torch.Tensor: + """ + Process through middle encoder using PyTorch. + + All backends use the same middle encoder processing because sparse convolution + cannot be converted to ONNX/TensorRT efficiently. + + Args: + voxel_features: Features from voxel encoder [N_voxels, feature_dim] + coors: Voxel coordinates [N_voxels, 4] + + Returns: + spatial_features: Spatial features [B, C, H, W] + """ + # Ensure tensors are on correct device + voxel_features = voxel_features.to(self.device) + coors = coors.to(self.device) + + # Calculate batch size + batch_size = int(coors[-1, 0].item()) + 1 if len(coors) > 0 else 1 + + # Process through PyTorch middle encoder + 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]: + """ + Postprocess: decode head outputs using PyTorch's predict_by_feat. + + All backends use the same postprocessing to ensure consistent results. + This includes NMS, coordinate transformation, and score filtering. + + Args: + head_outputs: List of [heatmap, reg, height, dim, rot, vel] + sample_meta: Sample metadata (point_cloud_range, voxel_size, etc.) + + Returns: + List of predictions with bbox_3d, score, and label + """ + # Ensure all outputs are on correct device + head_outputs = [out.to(self.device) for out in head_outputs] + + # Organize head outputs: [heatmap, reg, height, dim, rot, vel] + if len(head_outputs) != 6: + raise ValueError(f"Expected 6 head outputs, got {len(head_outputs)}") + + heatmap, reg, height, dim, rot, vel = head_outputs + + # Check if rot_y_axis_reference conversion is needed + # When ONNX/TensorRT outputs use rot_y_axis_reference format, we need to convert back + # to standard format before passing to PyTorch's predict_by_feat + 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: + # Convert dim from [w, l, h] back to [l, w, h] + dim = dim[:, [1, 0, 2], :, :] + + # Convert rot from [-cos(x), -sin(y)] back to [sin(y), cos(x)] + rot = rot * (-1.0) + rot = rot[:, [1, 0], :, :] + + # Convert to mmdet3d format + preds_dict = {"heatmap": heatmap, "reg": reg, "height": height, "dim": dim, "rot": rot, "vel": vel} + preds_dicts = ([preds_dict],) # Tuple[List[dict]] format + + # Prepare metadata + from mmdet3d.structures import LiDARInstance3DBoxes + + if "box_type_3d" not in sample_meta: + sample_meta["box_type_3d"] = LiDARInstance3DBoxes + batch_input_metas = [sample_meta] + + # Use PyTorch's predict_by_feat for consistent decoding + 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 + ) + + # Parse predictions + 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(), # [x, y, z, w, l, h, yaw] + "score": float(scores_3d[i]), + "label": int(labels_3d[i]), + } + ) + + return results + + # ========== Abstract Methods (Backend-specific implementations) ========== + + @abstractmethod + def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: + """ + Run voxel encoder inference. + + This method must be implemented by each backend (PyTorch/ONNX/TensorRT) + to provide optimized voxel encoder inference. + + Args: + input_features: Input features [N_voxels, max_points, feature_dim] + + Returns: + voxel_features: Voxel features [N_voxels, feature_dim] + """ + raise NotImplementedError + + @abstractmethod + def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: + """ + Run backbone + neck + head inference. + + This method must be implemented by each backend (PyTorch/ONNX/TensorRT) + to provide optimized backbone/neck/head inference. + + Args: + spatial_features: Spatial features [B, C, H, W] + + Returns: + List of head outputs: [heatmap, reg, height, dim, rot, vel] + """ + raise NotImplementedError + + # ========== Main Inference Pipeline ========== + + def run_model(self, preprocessed_input: Dict[str, torch.Tensor]) -> Tuple[List[torch.Tensor], Dict[str, float]]: + """ + Run complete multi-stage model inference. + + This method implements all inference stages: + 1. Voxel Encoder (backend-specific) + 2. Middle Encoder (PyTorch) + 3. Backbone + Head (backend-specific) + + This method is called by the base class `infer()` method, which handles + preprocessing, postprocessing, latency tracking, and error handling. + + Args: + preprocessed_input: Dict from preprocess() containing: + - 'input_features': Input features for voxel encoder [N_voxels, max_points, 11] + - 'coors': Voxel coordinates [N_voxels, 4] + - 'voxels': Raw voxel data + - 'num_points': Number of points per voxel + + Returns: + Tuple of (head_outputs, stage_latencies): + - head_outputs: List of head outputs [heatmap, reg, height, dim, rot, vel] + - stage_latencies: Dict mapping stage names to latency in ms + + Note: + Stage latencies are returned (not stored in instance variable) to avoid + race conditions when pipelines are reused across multiple threads. + """ + + # Use local variable for thread safety (not instance variable) + stage_latencies = {} + + # Stage 1: Voxel Encoder (backend-specific) + start = time.perf_counter() + voxel_features = self.run_voxel_encoder(preprocessed_input["input_features"]) + stage_latencies["voxel_encoder_ms"] = (time.perf_counter() - start) * 1000 + + # Stage 2: Middle Encoder (PyTorch - shared across all backends) + 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 + + # Stage 3: Backbone + Head (backend-specific) + 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/pipelines/centerpoint/centerpoint_pytorch.py b/deployment/pipelines/centerpoint/centerpoint_pytorch.py new file mode 100644 index 000000000..b56d2eabf --- /dev/null +++ b/deployment/pipelines/centerpoint/centerpoint_pytorch.py @@ -0,0 +1,227 @@ +""" +CenterPoint PyTorch Pipeline Implementation. + +This module implements the CenterPoint pipeline using pure PyTorch, +providing a baseline for comparison with optimized backends. +""" + +import logging +import time +from typing import Dict, List, Tuple + +import torch +from mmdet3d.apis import inference_detector + +from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline + +logger = logging.getLogger(__name__) + + +class CenterPointPyTorchPipeline(CenterPointDeploymentPipeline): + """ + PyTorch implementation of CenterPoint pipeline. + + Uses pure PyTorch for all components, providing maximum flexibility + and ease of debugging at the cost of inference speed. + + For standard CenterPoint models (non-ONNX), uses end-to-end inference. + """ + + def __init__(self, pytorch_model, device: str = "cuda"): + """ + Initialize PyTorch pipeline. + + Args: + pytorch_model: PyTorch CenterPoint model + device: Device for inference + """ + super().__init__(pytorch_model, device, backend_type="pytorch") + + # Check if this is an ONNX-compatible model + self.is_onnx_model = hasattr(pytorch_model.pts_voxel_encoder, "get_input_features") + + if self.is_onnx_model: + logger.info("PyTorch pipeline initialized (ONNX-compatible model)") + else: + logger.info("PyTorch pipeline initialized (standard model, using end-to-end inference)") + + def infer(self, points: torch.Tensor, sample_meta: Dict = None, return_raw_outputs: bool = False) -> Tuple: + """ + Complete inference pipeline. + + For standard models, uses mmdet3d's inference_detector for end-to-end inference. + For ONNX-compatible models, uses the staged pipeline. + + Args: + points: Input point cloud + sample_meta: Sample metadata + return_raw_outputs: If True, return raw head outputs (only for ONNX models) + """ + if sample_meta is None: + sample_meta = {} + + # For standard models, use end-to-end inference + if not self.is_onnx_model: + if return_raw_outputs: + raise NotImplementedError( + "return_raw_outputs=True is only supported for ONNX-compatible models. " + "Standard models use end-to-end inference via inference_detector." + ) + return self._infer_end_to_end(points, sample_meta) + + # For ONNX models, use staged pipeline + return super().infer(points, sample_meta, return_raw_outputs=return_raw_outputs) + + def _infer_end_to_end(self, points: torch.Tensor, sample_meta: Dict) -> Tuple[List[Dict], float, Dict[str, float]]: + """End-to-end inference for standard PyTorch models.""" + + start_time = time.time() + + try: + # Convert points to numpy for inference_detector + if isinstance(points, torch.Tensor): + points_np = points.cpu().numpy() + else: + points_np = points + + # Use mmdet3d's inference API + with torch.no_grad(): + results = inference_detector(self.pytorch_model, points_np) + + # Parse results + predictions = [] + if len(results) > 0 and hasattr(results[0], "pred_instances_3d"): + pred_instances = results[0].pred_instances_3d + + if hasattr(pred_instances, "bboxes_3d"): + 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)): + predictions.append( + { + "bbox_3d": bboxes_3d[i][:7].tolist(), # [x, y, z, w, l, h, yaw] + "score": float(scores_3d[i]), + "label": int(labels_3d[i]), + } + ) + + latency_ms = (time.time() - start_time) * 1000 + + # Empty latency breakdown for end-to-end models (not broken down into stages) + latency_breakdown = {} + + return predictions, latency_ms, latency_breakdown + + except Exception as e: + logger.error(f"End-to-end inference failed: {e}") + import traceback + + traceback.print_exc() + raise + + def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: + """ + Run voxel encoder using PyTorch. + + Note: Only used for ONNX-compatible models. + + Args: + input_features: Input features [N_voxels, max_points, feature_dim] + + Returns: + voxel_features: Voxel features [N_voxels, feature_dim] + """ + 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) + + # Ensure output is 2D: [N_voxels, feature_dim] + # ONNX-compatible models may output 3D tensor that needs squeezing + if voxel_features.ndim == 3: + # Try to squeeze to 2D + if voxel_features.shape[1] == 1: + # Shape: [N_voxels, 1, feature_dim] -> [N_voxels, feature_dim] + voxel_features = voxel_features.squeeze(1) + elif voxel_features.shape[2] == 1: + # Shape: [N_voxels, feature_dim, 1] -> [N_voxels, feature_dim] + voxel_features = voxel_features.squeeze(2) + else: + # Cannot determine which dimension to squeeze + # This might be the input features [N_voxels, max_points, feature_dim] + # which should have been processed by the encoder + raise RuntimeError( + f"Voxel encoder output has unexpected 3D shape: {voxel_features.shape}. " + f"Expected 2D output [N_voxels, feature_dim]. " + f"This may indicate the voxel encoder didn't process the input correctly. " + f"Input features shape was: {input_features.shape}" + ) + elif voxel_features.ndim > 3: + raise RuntimeError( + f"Voxel encoder output has {voxel_features.ndim}D shape: {voxel_features.shape}. " + f"Expected 2D output [N_voxels, feature_dim]." + ) + + return voxel_features + + def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: + """ + Run backbone + neck + head using PyTorch. + + Note: Only used for ONNX-compatible models. + + Args: + spatial_features: Spatial features [B, C, H, W] + + Returns: + List of head outputs: [heatmap, reg, height, dim, rot, vel] + """ + spatial_features = spatial_features.to(self.device) + + with torch.no_grad(): + # Backbone + x = self.pytorch_model.pts_backbone(spatial_features) + + # Neck + if hasattr(self.pytorch_model, "pts_neck") and self.pytorch_model.pts_neck is not None: + x = self.pytorch_model.pts_neck(x) + + # Head - returns tuple of task head outputs + head_outputs_tuple = self.pytorch_model.pts_bbox_head(x) + + # Handle two possible output formats: + # 1. ONNX head: Tuple[torch.Tensor] directly (heatmap, reg, height, dim, rot, vel) + # 2. Standard head: Tuple[List[Dict]] format + + if isinstance(head_outputs_tuple, tuple) and len(head_outputs_tuple) > 0: + first_element = head_outputs_tuple[0] + + # Check if this is ONNX format (tuple of tensors) + if isinstance(first_element, torch.Tensor): + # ONNX format: (heatmap, reg, height, dim, rot, vel) + head_outputs = list(head_outputs_tuple) + + elif isinstance(first_element, list) and len(first_element) > 0: + # Standard format: (List[Dict],) + preds_dict = first_element[0] # Get first (and only) dict + + # Extract individual outputs + 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/pipelines/centerpoint/centerpoint_tensorrt.py b/deployment/pipelines/centerpoint/centerpoint_tensorrt.py new file mode 100644 index 000000000..4aab142f6 --- /dev/null +++ b/deployment/pipelines/centerpoint/centerpoint_tensorrt.py @@ -0,0 +1,264 @@ +""" +CenterPoint TensorRT Pipeline Implementation. + +This module implements the CenterPoint pipeline using TensorRT, +providing maximum inference speed on NVIDIA GPUs. +""" + +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.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline +from deployment.pipelines.common.gpu_resource_mixin import ( + GPUResourceMixin, + TensorRTResourceManager, + release_tensorrt_resources, +) + +logger = logging.getLogger(__name__) + + +class CenterPointTensorRTPipeline(GPUResourceMixin, CenterPointDeploymentPipeline): + """ + TensorRT implementation of CenterPoint pipeline. + + Uses TensorRT for voxel encoder and backbone/head, + while keeping middle encoder in PyTorch (sparse convolution cannot be converted). + + Provides maximum inference speed on NVIDIA GPUs with INT8/FP16 optimization. + + Resource Management: + This pipeline implements GPUResourceMixin for proper resource cleanup. + Use as a context manager for automatic cleanup: + + with CenterPointTensorRTPipeline(...) as pipeline: + results = pipeline.infer(data) + # Resources automatically released + """ + + def __init__(self, pytorch_model, tensorrt_dir: str, device: str = "cuda"): + """ + Initialize TensorRT pipeline. + + Args: + pytorch_model: PyTorch model (for preprocessing, middle encoder, postprocessing) + tensorrt_dir: Directory containing TensorRT engine files + device: Device for inference (must be '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 # For GPUResourceMixin + + self._load_tensorrt_engines() + + logger.info(f"TensorRT pipeline initialized with engines from: {tensorrt_dir}") + + def _load_tensorrt_engines(self): + """Load TensorRT engines for voxel encoder and backbone/head.""" + # Initialize TensorRT + trt.init_libnvinfer_plugins(self._logger, "") + runtime = trt.Runtime(self._logger) + + # Define engine files + engine_files = { + "voxel_encoder": "pts_voxel_encoder.engine", + "backbone_neck_head": "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}") + + try: + # Load engine + 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}") + + # Create execution context + context = engine.create_execution_context() + + # Check if context creation succeeded + if context is None: + raise RuntimeError( + f"Failed to create execution context for {component}. " + "This is likely due to GPU out-of-memory. " + "Try reducing batch size or closing other GPU processes." + ) + + self._engines[component] = engine + self._contexts[component] = context + + logger.info(f"Loaded TensorRT engine: {component}") + + except Exception as e: + raise RuntimeError(f"Failed to load TensorRT engine {component}: {e}") + + def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: + """ + Run voxel encoder using TensorRT. + + Args: + input_features: Input features [N_voxels, max_points, feature_dim] + + Returns: + voxel_features: Voxel features [N_voxels, feature_dim] + """ + 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") + + # Convert to numpy + input_array = input_features.cpu().numpy().astype(np.float32) + if not input_array.flags["C_CONTIGUOUS"]: + input_array = np.ascontiguousarray(input_array) + + # Get tensor names + input_name, output_name = self._get_io_names(engine, single_output=True) + + # Set input shape and get output shape + 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) + + # Use resource manager for automatic cleanup + 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() + + # Convert to torch tensor + voxel_features = torch.from_numpy(output_array).to(self.device) + + # Squeeze middle dimension if present + if voxel_features.ndim == 3 and voxel_features.shape[1] == 1: + voxel_features = voxel_features.squeeze(1) + + return voxel_features + + def _get_io_names(self, engine, single_output: bool = False): + """Extract input/output tensor names from TensorRT engine.""" + 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]: + """ + Run backbone + neck + head using TensorRT. + + Args: + spatial_features: Spatial features [B, C, H, W] + + Returns: + List of head outputs: [heatmap, reg, height, dim, rot, vel] + """ + 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") + + # DEBUG: Log input statistics for verification + if hasattr(self, "_debug_mode") and self._debug_mode: + logger.info( + f"TensorRT backbone input: shape={spatial_features.shape}, " + f"range=[{spatial_features.min():.3f}, {spatial_features.max():.3f}], " + f"mean={spatial_features.mean():.3f}, std={spatial_features.std():.3f}" + ) + + # Convert to numpy + input_array = spatial_features.cpu().numpy().astype(np.float32) + if not input_array.flags["C_CONTIGUOUS"]: + input_array = np.ascontiguousarray(input_array) + + # Get tensor names + input_name, output_names = self._get_io_names(engine, single_output=False) + + # Set input shape + context.set_input_shape(input_name, input_array.shape) + + # Prepare output arrays + 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 + + # Use resource manager for automatic cleanup + 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 engines and contexts (GPUResourceMixin implementation).""" + release_tensorrt_resources( + engines=getattr(self, "_engines", None), + contexts=getattr(self, "_contexts", None), + ) diff --git a/deployment/runners/projects/__init__.py b/deployment/runners/projects/__init__.py new file mode 100644 index 000000000..7a42b7b47 --- /dev/null +++ b/deployment/runners/projects/__init__.py @@ -0,0 +1,11 @@ +"""Project-specific deployment runners.""" + +from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner +from deployment.runners.projects.centerpoint_runner import CenterPointDeploymentRunner +from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner + +__all__ = [ + "CalibrationDeploymentRunner", + "CenterPointDeploymentRunner", + "YOLOXOptElanDeploymentRunner", +] diff --git a/deployment/runners/projects/centerpoint_runner.py b/deployment/runners/projects/centerpoint_runner.py new file mode 100644 index 000000000..acdd14a93 --- /dev/null +++ b/deployment/runners/projects/centerpoint_runner.py @@ -0,0 +1,169 @@ +""" +CenterPoint-specific deployment runner. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from deployment.core.contexts import CenterPointExportContext, ExportContext +from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper +from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow +from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow +from deployment.exporters.common.factory import ExporterFactory +from deployment.runners.common.deployment_runner import BaseDeploymentRunner +from projects.CenterPoint.deploy.component_extractor import CenterPointComponentExtractor +from projects.CenterPoint.deploy.utils import build_centerpoint_onnx_model + + +class CenterPointDeploymentRunner(BaseDeploymentRunner): + """ + CenterPoint-specific deployment runner. + + Handles CenterPoint-specific requirements: + - Multi-file ONNX export (voxel encoder + backbone/neck/head) via workflow + - Multi-file TensorRT export via workflow + - Uses generic ONNX/TensorRT exporters composed into CenterPoint workflows + - ONNX-compatible model loading + + Key improvements: + - Uses ExporterFactory instead of passing runner methods + - Injects CenterPointComponentExtractor for model-specific logic + - No circular dependencies or exporter caching + """ + + def __init__( + self, + data_loader, + evaluator, + config, + model_cfg, + logger: logging.Logger, + onnx_wrapper_cls=None, + onnx_workflow=None, + tensorrt_workflow=None, + ): + """ + Initialize CenterPoint deployment runner. + + Args: + data_loader: Data loader for samples + evaluator: Evaluator for model evaluation + config: Deployment configuration + model_cfg: Model configuration + logger: Logger instance + onnx_wrapper_cls: Optional ONNX wrapper (defaults to CenterPointONNXWrapper) + onnx_workflow: Optional custom ONNX workflow + tensorrt_workflow: Optional custom TensorRT workflow + """ + # Create component extractor for model-specific logic + component_extractor = CenterPointComponentExtractor(logger=logger) + + # Initialize base runner + super().__init__( + data_loader=data_loader, + evaluator=evaluator, + config=config, + model_cfg=model_cfg, + logger=logger, + onnx_wrapper_cls=onnx_wrapper_cls or CenterPointONNXWrapper, + onnx_workflow=onnx_workflow, + tensorrt_workflow=tensorrt_workflow, + ) + + # Create workflows with ExporterFactory and component extractor + if self._onnx_workflow is None: + self._onnx_workflow = CenterPointONNXExportWorkflow( + exporter_factory=ExporterFactory, + component_extractor=component_extractor, + config=self.config, + logger=self.logger, + ) + + if self._tensorrt_workflow is None: + self._tensorrt_workflow = CenterPointTensorRTExportWorkflow( + exporter_factory=ExporterFactory, + config=self.config, + logger=self.logger, + ) + + def load_pytorch_model( + self, + checkpoint_path: str, + context: ExportContext, + ) -> Any: + """ + Build ONNX-compatible CenterPoint model from checkpoint. + + This method: + 1. Builds ONNX-compatible model + 2. Updates runner's config to ONNX version + 3. Explicitly injects model and config to evaluator + + Args: + checkpoint_path: Path to checkpoint file + context: Export context. Use CenterPointExportContext for type-safe access + to rot_y_axis_reference. Falls back to context.extra for compatibility. + + Returns: + Loaded PyTorch model (ONNX-compatible) + """ + # Extract rot_y_axis_reference from typed context or extra dict + 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, + ) + + # Update runner's internal model_cfg to ONNX-friendly version + self.model_cfg = onnx_cfg + + # Explicitly inject model and config to evaluator + self._inject_model_to_evaluator(model, onnx_cfg) + + return model + + def _inject_model_to_evaluator(self, model: Any, onnx_cfg: Any) -> None: + """ + Inject PyTorch model and ONNX config to evaluator. + + Args: + model: PyTorch model to inject + onnx_cfg: ONNX-compatible config to inject + """ + # Check if evaluator has the setter methods + has_set_onnx_config = hasattr(self.evaluator, "set_onnx_config") + has_set_pytorch_model = hasattr(self.evaluator, "set_pytorch_model") + + if not (has_set_onnx_config and has_set_pytorch_model): + self.logger.warning( + "Evaluator does not have set_onnx_config() and/or set_pytorch_model() methods. " + "CenterPoint evaluator should implement these methods for proper model injection. " + f"Has set_onnx_config: {has_set_onnx_config}, " + f"Has set_pytorch_model: {has_set_pytorch_model}" + ) + return + + # Inject ONNX-compatible config + 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 + + # Inject PyTorch model + 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/projects/CenterPoint/README.md b/projects/CenterPoint/README.md index 6b265e890..c1409e335 100644 --- a/projects/CenterPoint/README.md +++ b/projects/CenterPoint/README.md @@ -2,10 +2,10 @@ ## Summary - [Support priority](https://github.com/tier4/AWML/blob/main/docs/design/autoware_ml_design.md#support-priority): Tier S -- ROS package: [auotoware_lidar_centerpoint](https://github.com/autowarefoundation/autoware.universe/tree/main/perception/autoware_lidar_centerpoint) +- ROS package: [auotoware_lidar_centerpoint] (https://github.com/autowarefoundation/autoware.universe/tree/main/perception/autoware_lidar_centerpoint) - Supported dataset - [x] T4dataset - - [ ] NuScenes + - [] NuScenes - Supported model - [x] LiDAR-only model - Other supported feature @@ -19,23 +19,13 @@ - v1 (121m range, grid_size = 760) - [CenterPoint base/1.X](./docs/CenterPoint/v1/base.md) - [CenterPoint x2/1.X](./docs/CenterPoint/v1/x2.md) - - v2 (121m range, grid_size = 760) - - [CenterPoint base/2.X](./docs/CenterPoint/v2/base.md) - - [CenterPoint x2/2.X](./docs/CenterPoint/v2/x2.md) -- CenterPoint-ConvNeXtPC - - [CenterPoint-ConvNeXtPC base/0.x](./docs/CenterPoint-ConvNeXtPC/v0/base.md) -- CenterPoint-ShortRange - - v0 - - [CenterPoint-ShortRange base/0.X](./docs/CenterPoint-ShortRange/v0/base.md) - - v2 - - [CenterPoint-ShortRange base/2.X](./docs/CenterPoint-ShortRange/v2/base.md) - - [CenterPoint-ShortRange j6gen2/2.X](./docs/CenterPoint-ShortRange/v2/j6gen2.md) + - [CenterPoint-ConvNeXtPC base/0.x](./docs/CenterPoint-ConvNeXtPC/v0/base.md) ## Get started ### 1. Setup -- Please follow the [installation tutorial](/docs/tutorial/tutorial_detection_3d.md) to set up the environment. -- Run docker. +- Please follow the [installation tutorial](/docs/tutorial/tutorial_detection_3d.md)to set up the environment. +- Run docker ```sh docker run -it --rm --gpus all --shm-size=64g --name awml -p 6006:6006 -v $PWD/:/workspace -v $PWD/data:/workspace/data autoware-ml @@ -44,7 +34,7 @@ docker run -it --rm --gpus all --shm-size=64g --name awml -p 6006:6006 -v $PWD/: ### 2. Train #### 2.1 Environment set up -- Set `CUBLAS_WORKSPACE_CONFIG` for the deterministic behavior, plese check this [nvidia doc](https://docs.nvidia.com/cuda/cublas/index.html#results-reproducibility) for more info. +Set `CUBLAS_WORKSPACE_CONFIG` for the deterministic behavior, plese check this [nvidia doc](https://docs.nvidia.com/cuda/cublas/index.html#results-reproducibility) for more info ```sh export CUBLAS_WORKSPACE_CONFIG=:4096:8 @@ -52,16 +42,16 @@ export CUBLAS_WORKSPACE_CONFIG=:4096:8 #### 2.2. Train CenterPoint model with T4dataset-base -- [choice] Train with a single GPU. - - Rename config file to use for single GPU and batch size. - - Change `train_batch_size` and `train_gpu_size` accordingly. +- [choice] Train with a single GPU + - Rename config file to use for single GPU and batch size + - Change `train_batch_size` and `train_gpu_size` accordingly ```sh # T4dataset (121m) python tools/detection3d/train.py projects/CenterPoint/configs/t4dataset/second_secfpn_2xb8_121m_base.py ``` -- [choice] Train with multi GPU. +- [choice] Train with multi GPU ```sh # Command @@ -73,7 +63,7 @@ bash tools/detection3d/dist_script.sh projects/CenterPoint/configs/t4dataset/Cen ### 3. Evaluation -- Run evaluation on a test set, please select experiment config accordingly. +- Run evaluation on a test set, please select experiment config accordingly - [choice] Evaluate with a single GPU @@ -83,8 +73,8 @@ DIR="work_dirs/centerpoint/t4dataset/second_secfpn_2xb8_121m_base/" && \ python tools/detection3d/test.py projects/CenterPoint/configs/t4dataset/second_secfpn_2xb8_121m_base.py $DIR/epoch_50.pth ``` -- [choice] Evaluate with multiple GPUs. - - Note that if you choose to evaluate with multiple GPUs, you might get slightly different results as compared to single GPU due to differences across GPUs. +- [choice] Evaluate with multiple GPUs + - Note that if you choose to evaluate with multiple GPUs, you might get slightly different results as compared to single GPU due to differences across GPUs ```sh # Command @@ -98,7 +88,7 @@ bash tools/detection3d/dist_script.sh projects/CenterPoint/configs/t4dataset/Cen ### 4. Visualization -- Run inference and visualize bounding boxes from a CenterPoint model. +- Run inference and visualize bounding boxes from a CenterPoint model ```sh # Inference for t4dataset @@ -110,7 +100,7 @@ where `frame-range` represents the range of frames to visualize. ### 5. Deploy -- Make an onnx file for a CenterPoint model. +- Make an onnx file for a CenterPoint model ```sh # Deploy for t4dataset @@ -121,15 +111,14 @@ python projects/CenterPoint/scripts/deploy.py projects/CenterPoint/configs/t4dat where `rot_y_axis_reference` can be removed if we would like to use the original counterclockwise x-axis rotation system. ## Troubleshooting -### Difference from original CenterPoint from mmdetection3d v1 -- To maintain the backward compatibility with the previous ML library, we modified the original CenterPoint from mmdetection3d v1 such as: - - Exclude voxel center from z-dimension as part of pillar features. - - Assume that the rotation system in the deployed ONNX file is in clockwise y-axis, and a bounding box is [x, y, z, w, l, h] for the deployed ONNX file. - - Do not use CBGS dataset to align the experiment configuration with the older library. -- Latest mmdetection3D assumes the lidar coordinate system is in the right-handed x-axis reference, also the dimensionality of a bounding box is [x, y, z, l, w, h], please check [this](https://mmdetection3d.readthedocs.io/en/latest/user_guides/coord_sys_tutorial.html) for more details. +- The difference from original CenterPoint from mmdetection3d v1 + - To maintain the backward compatibility with the previous ML library, we modified the original CenterPoint from mmdetection3d v1 such as: + - Exclude voxel center from z-dimension as part of pillar features + - Assume that the rotation system in the deployed ONNX file is in clockwise y-axis, and a bounding box is [x, y, z, w, l, h] for the deployed ONNX file + - Do not use CBGS dataset to align the experiment configuration with the older library +- Latest mmdetection3D assumes the lidar coordinate system is in the right-handed x-axis reference, also the dimensionality of a bounding box is [x, y, z, l, w, h], please check [this](https://mmdetection3d.readthedocs.io/en/latest/user_guides/coord_sys_tutorial.html) for more details ## Reference -- "Center-based 3D Object Detection and Tracking", Tianwei Yin, Xingyi Zhou, Philipp Krähenbühl, CVPR2021. - [CenterPoint of mmdetection3d](https://github.com/open-mmlab/mmdetection3d/tree/main/configs/centerpoint) 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/deploy/README.md b/projects/CenterPoint/deploy/README.md new file mode 100644 index 000000000..9c834a5b1 --- /dev/null +++ b/projects/CenterPoint/deploy/README.md @@ -0,0 +1,324 @@ +# CenterPoint Deployment + +Complete deployment pipeline for CenterPoint 3D object detection. + +## Features + +- ✅ Export to ONNX and TensorRT +- ✅ Full evaluation with 3D detection metrics +- ✅ Latency benchmarking +- ✅ Cross-backend verification +- ✅ Uses MMDet3D pipeline for consistency with training +- ✅ Modernized from legacy DeploymentRunner + +## Quick Start + +### 1. Prepare Data + +Make sure you have T4Dataset or similar 3D detection dataset: +``` +data/t4dataset/ +├── centerpoint_infos_train.pkl +├── centerpoint_infos_val.pkl +└── lidar/ + └── *.bin +``` + +### 2. Export and Evaluate + +```bash +# Export to ONNX and TensorRT with evaluation +python projects/CenterPoint/deploy/main.py \ + projects/CenterPoint/deploy/deploy_config.py \ + projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_base_amp.py \ + path/to/checkpoint.pth \ + --work-dir work_dirs/centerpoint_deployment \ + --replace-onnx-models # Important for ONNX export +``` + +### 3. Evaluation Only + +```bash +# Evaluate PyTorch model only +python projects/CenterPoint/deploy/main.py \ + projects/CenterPoint/deploy/deploy_config.py \ + projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_base_amp.py \ + path/to/checkpoint.pth +``` + +## Configuration + +### Export Settings + +```python +export = dict( + mode='both', # 'onnx', 'trt', 'both', 'none' + verify=True, # Cross-backend verification + device='cuda:0', # Device + work_dir='work_dirs/centerpoint_deployment' +) +``` + +### Evaluation Settings + +```python +evaluation = dict( + enabled=True, + num_samples=50, # 3D is slower, use fewer samples + verbose=False, + models_to_evaluate=['pytorch'] # Add 'onnx' after export +) +``` + +### ONNX Settings + +```python +onnx_config = dict( + opset_version=16, + do_constant_folding=True, + save_file="centerpoint.onnx", + export_params=True, + keep_initializers_as_inputs=False, + simplify=True, +) +``` + +### TensorRT Settings + +```python +backend_config = dict( + common_config=dict( + # Precision policy for TensorRT + # Options: 'auto', 'fp16', 'fp32_tf32', 'strongly_typed' + precision_policy="auto", + # TensorRT workspace size (bytes) + max_workspace_size=2 << 30, # 2 GB (3D models need more memory) + ), +) +``` + +### Verification Settings + +```python +# Note: Verification is controlled by export.verify (see export section above) +# This section only contains verification parameters (tolerance, devices, etc.) +verification = dict( + tolerance=1e-1, # Slightly higher tolerance for 3D detection + num_verify_samples=1, # Fewer samples for 3D (slower) + devices=dict( + pytorch="cpu", # PyTorch reference device (should be 'cpu') + onnx_cpu="cpu", # ONNX verification device for PyTorch comparison + onnx_cuda="cuda:0", # ONNX verification device for TensorRT comparison + tensorrt="cuda:0", # TensorRT verification device (must be 'cuda:0') + ), +) +``` + +## TensorRT Architecture + +CenterPoint uses a multi-engine TensorRT setup: + +1. **pts_voxel_encoder.engine** - Voxel feature extraction +2. **pts_backbone_neck_head.engine** - Backbone, neck, and head processing + +The TensorRT backend automatically handles the pipeline between these engines, including: +- Voxel encoder inference +- Middle encoder processing (PyTorch) +- Backbone/neck/head inference +- Output formatting + +## Output Structure + +After deployment, you'll find: + +``` +work_dirs/centerpoint_deployment/ +├── pts_voxel_encoder.onnx +├── pts_backbone_neck_head.onnx +└── tensorrt/ + ├── pts_voxel_encoder.engine + └── pts_backbone_neck_head.engine +``` + +## Troubleshooting + +### TensorRT Build Issues + +If TensorRT engine building fails: + +1. **Memory Issues**: Increase `max_workspace_size` in config +2. **Shape Issues**: Check input shapes match your data +3. **Precision Issues**: Try different `precision_policy` settings + +### Verification Failures + +If cross-backend verification fails: + +1. **Tolerance**: Increase `tolerance` in verification config +2. **Samples**: Reduce `num_verify_samples` for faster testing +3. **Device**: Ensure all backends use the same device + +### Performance Issues + +For better TensorRT performance: + +1. **Precision**: Use `fp16` for faster inference +2. **Batch Size**: Optimize for your typical batch sizes +3. **Profiling**: Use TensorRT profiling tools for optimization + +### Important Flags + +- `--replace-onnx-models`: Replace model components with ONNX-compatible versions + - Changes `CenterPoint` → `CenterPointONNX` + - Changes `PillarFeatureNet` → `PillarFeatureNetONNX` + - Changes `CenterHead` → `CenterHeadONNX` + +- `--rot-y-axis-reference`: Convert rotation to y-axis clockwise reference + +## Output + +The deployment pipeline will generate: + +``` +work_dirs/centerpoint_deployment/ +├── pillar_encoder.onnx +├── backbone.onnx +├── neck.onnx +└── head.onnx +``` + +And print evaluation results: +``` +================================================================================ +CenterPoint Evaluation Results +================================================================================ + +Detection Statistics: + Total Predictions: 1234 + Total Ground Truths: 1180 + +Per-Class Statistics: + VEHICLE: + Predictions: 890 + Ground Truths: 856 + PEDESTRIAN: + Predictions: 234 + Ground Truths: 218 + CYCLIST: + Predictions: 110 + Ground Truths: 106 + +Latency Statistics: + Mean: 45.23 ms + Std: 3.45 ms + Min: 41.82 ms + Max: 58.31 ms + Median: 44.18 ms + +Total Samples: 50 +================================================================================ +``` + +## Architecture + +``` +CenterPointDataLoader (from data_loader.py) + ├── Uses MMDet3D test pipeline + ├── Loads info.pkl + ├── Handles voxelization + └── Preprocesses point clouds + +CenterPointEvaluator (from evaluator.py) + ├── Supports PyTorch, ONNX (TensorRT coming) + ├── Computes detection statistics + └── Measures latency + +main.py + ├── Replaces legacy DeploymentRunner + ├── Exports to ONNX + ├── Verifies outputs (TODO) + └── Runs evaluation +``` + +## Migration from Legacy Code + +This new implementation replaces the old `DeploymentRunner`: + +### Old Way (scripts/deploy.py) +```python +from projects.CenterPoint.runners.deployment_runner import DeploymentRunner + +runner = DeploymentRunner( + experiment_name=experiment_name, + model_cfg_path=model_cfg_path, + checkpoint_path=checkpoint_path, + work_dir=work_dir, + replace_onnx_models=True, + device='gpu', + onnx_opset_version=13 +) +runner.run() +``` + +### New Way (deploy/main.py) +```bash +python projects/CenterPoint/deploy/main.py \ + deploy_config.py \ + model_config.py \ + checkpoint.pth \ + --replace-onnx-models +``` + +**Benefits of New Approach**: +- ✅ Integrated with unified deployment framework +- ✅ Supports verification and evaluation +- ✅ Consistent with other projects (YOLOX, etc.) +- ✅ Better configuration management +- ✅ More modular and maintainable + +## Troubleshooting + +### Issue: Dataset not found + +**Solution**: Update `runtime_io.info_file` in deploy_config.py + +### Issue: ONNX export fails without --replace-onnx-models + +**Solution**: Always use `--replace-onnx-models` flag for ONNX export + +### Issue: Out of memory + +**Solution**: +1. Reduce `evaluation.num_samples` +2. Reduce point cloud range +3. Increase `backend_config.common_config.max_workspace_size` + +### Issue: Different results between training and deployment + +**Solution**: +1. Make sure using same config file +2. Verify pipeline is correctly built +3. Check voxelization parameters + +## Known Limitations + +1. **3D mAP Metrics**: Current implementation uses simplified metrics. For production, integrate with `mmdet3d.core.evaluation` for proper 3D detection metrics (mAP, NDS, mATE, etc.) + +2. **TensorRT Support**: TensorRT export for multi-file ONNX models needs custom implementation + +3. **Batch Inference**: Currently supports single sample inference + +## TODO + +- [ ] Integrate with mmdet3d evaluation for proper mAP/NDS metrics +- [ ] Implement TensorRT multi-file export +- [ ] Add cross-backend verification +- [ ] Support batch inference +- [ ] Add visualization tools + +## References + +- [Deployment Framework Design](../../../docs/design/deploy_pipeline_design.md) +- [DataLoader Tutorial](../../../docs/tutorial/tutorial_deployment_dataloader.md) +- [CenterPoint Paper](https://arxiv.org/abs/2006.11275) +- [Old DeploymentRunner](../runners/deployment_runner.py) (deprecated) diff --git a/projects/CenterPoint/deploy/__init__.py b/projects/CenterPoint/deploy/__init__.py new file mode 100644 index 000000000..faf1e1867 --- /dev/null +++ b/projects/CenterPoint/deploy/__init__.py @@ -0,0 +1,5 @@ +"""CenterPoint Deployment Module.""" + +from .data_loader import CenterPointDataLoader + +__all__ = ["CenterPointDataLoader"] diff --git a/projects/CenterPoint/deploy/component_extractor.py b/projects/CenterPoint/deploy/component_extractor.py new file mode 100644 index 000000000..95d6cb4e6 --- /dev/null +++ b/projects/CenterPoint/deploy/component_extractor.py @@ -0,0 +1,185 @@ +""" +CenterPoint-specific component extractor. + +This module contains all CenterPoint-specific logic for extracting +exportable model components. It implements the ModelComponentExtractor +interface from the deployment framework. +""" + +import logging +from typing import Any, List, Tuple + +import torch + +from deployment.exporters.common.configs import ONNXExportConfig +from deployment.exporters.workflows.interfaces import ExportableComponent, ModelComponentExtractor + +logger = logging.getLogger(__name__) + + +class CenterPointComponentExtractor(ModelComponentExtractor): + """ + Extracts exportable components from CenterPoint model. + + CenterPoint uses a multi-stage architecture that requires multi-file ONNX export: + 1. Voxel Encoder: Converts voxels to features + 2. Backbone+Neck+Head: Detection head + + This extractor handles all CenterPoint-specific logic: + - Feature extraction from model + - Creating combined backbone+neck+head module + - Preparing sample inputs for each component + - Configuring ONNX export settings + """ + + def __init__(self, logger: logging.Logger = None): + """ + Initialize extractor. + + Args: + logger: Optional logger instance + """ + self.logger = logger or logging.getLogger(__name__) + + def extract_components(self, model: torch.nn.Module, sample_data: Any) -> List[ExportableComponent]: + """ + Extract CenterPoint components for ONNX export. + + Args: + model: CenterPoint PyTorch model + sample_data: Tuple of (input_features, voxel_dict) from preprocessing + + Returns: + List containing two components: voxel encoder and backbone+neck+head + """ + # Unpack sample data + input_features, voxel_dict = sample_data + + self.logger.info("Extracting CenterPoint components for export...") + + # Component 1: Voxel Encoder + voxel_component = self._create_voxel_encoder_component(model, input_features) + + # Component 2: Backbone+Neck+Head + 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: + """Create exportable voxel encoder component.""" + return ExportableComponent( + name="pts_voxel_encoder", + 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=True, + save_file="pts_voxel_encoder.onnx", + ), + ) + + def _create_backbone_component( + self, model: torch.nn.Module, input_features: torch.Tensor, voxel_dict: dict + ) -> ExportableComponent: + """Create exportable backbone+neck+head component.""" + # Prepare backbone input by running through voxel and middle encoders + backbone_input = self._prepare_backbone_input(model, input_features, voxel_dict) + + # Create combined backbone+neck+head module + backbone_module = self._create_backbone_module(model) + + # Get output names + output_names = self._get_output_names(model) + + return ExportableComponent( + name="pts_backbone_neck_head", + module=backbone_module, + sample_input=backbone_input, + config_override=ONNXExportConfig( + input_names=("spatial_features",), + output_names=output_names, + dynamic_axes={ + "spatial_features": {0: "batch_size", 2: "height", 3: "width"}, + }, + opset_version=16, + do_constant_folding=True, + simplify=True, + save_file="pts_backbone_neck_head.onnx", + ), + ) + + def _prepare_backbone_input( + self, model: torch.nn.Module, input_features: torch.Tensor, voxel_dict: dict + ) -> torch.Tensor: + """ + Prepare input tensor for backbone export by running inference. + + This runs the voxel encoder and middle encoder to generate + spatial features that will be the input to the backbone. + """ + with torch.no_grad(): + # Run voxel encoder + voxel_features = model.pts_voxel_encoder(input_features).squeeze(1) + + # Get coordinates and batch size + coors = voxel_dict["coors"] + batch_size = int(coors[-1, 0].item()) + 1 if len(coors) > 0 else 1 + + # Run middle encoder (sparse convolution) + 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: + """ + Create combined backbone+neck+head module for ONNX export. + + This imports CenterPoint-specific model classes from projects/. + """ + from projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX + + return CenterPointHeadONNX(model.pts_backbone, model.pts_neck, model.pts_bbox_head) + + def _get_output_names(self, model: torch.nn.Module) -> Tuple[str, ...]: + """Get output names from model or use defaults.""" + 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 ("heatmap", "reg", "height", "dim", "rot", "vel") + + def extract_features(self, model: torch.nn.Module, data_loader: Any, sample_idx: int) -> Tuple[torch.Tensor, dict]: + """ + Extract features using model's internal method. + + This is a helper method that wraps the model's _extract_features method, + which is used during ONNX export to get sample data. + + Args: + model: CenterPoint model + data_loader: Data loader + sample_idx: Sample index + + Returns: + Tuple of (input_features, voxel_dict) + """ + if hasattr(model, "_extract_features"): + return model._extract_features(data_loader, sample_idx) + else: + raise AttributeError( + "CenterPoint model must have _extract_features method for ONNX export. " + "Please ensure the model is built with ONNX compatibility." + ) diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py new file mode 100644 index 000000000..70cd091ca --- /dev/null +++ b/projects/CenterPoint/deploy/configs/deploy_config.py @@ -0,0 +1,241 @@ +""" +CenterPoint Deployment Configuration (v2). + +This config is designed to: +- Make export mode behavior explicit and easy to reason about. +- Separate "what to do" (mode, which backends) from "how to do it" (paths, devices). +- Make verification & evaluation rules depend on export.mode without hardcoding them in code. +""" + +# ============================================================================ +# Task type for pipeline building +# Options: 'detection2d', 'detection3d', 'classification', 'segmentation' +# ============================================================================ +task_type = "detection3d" + +# ============================================================================ +# Checkpoint Path - Single source of truth for PyTorch model +# ============================================================================ +# This is the main checkpoint path used by: +# - Export workflow: to load the PyTorch model for ONNX conversion +# - Evaluation: for PyTorch backend evaluation +# - Verification: when PyTorch is used as reference or test backend +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( + # Export mode: + # - 'onnx' : export PyTorch -> ONNX + # - 'trt' : build TensorRT engine from an existing ONNX + # - 'both' : export PyTorch -> ONNX -> TensorRT + # - 'none' : no export (only evaluation / verification on existing artifacts) + mode="both", + # ---- Common options ---------------------------------------------------- + work_dir="work_dirs/centerpoint_deployment", + # ---- ONNX source when building TensorRT only --------------------------- + # Rule: + # - mode == 'trt' -> onnx_path MUST be provided (file or directory) + # - mode in ['onnx', 'both'] -> onnx_path can be None (pipeline uses newly exported ONNX) + onnx_path=None, # e.g. "work_dirs/centerpoint_deployment/centerpoint.onnx" +) + +# ============================================================================ +# Runtime I/O settings +# ============================================================================ +runtime_io = dict( + # Path to info.pkl file + info_file="data/t4dataset/info/t4dataset_j6gen2_infos_val.pkl", + # Sample index for export (use first sample) + sample_idx=1, +) + +# ============================================================================ +# Model Input/Output Configuration +# ============================================================================ +model_io = dict( + # Primary input configuration for 3D detection + input_name="voxels", + input_shape=(32, 4), # (max_points_per_voxel, point_dim); batch dim added automatically + input_dtype="float32", + # Additional inputs for 3D detection + additional_inputs=[ + dict(name="num_points", shape=(-1,), dtype="int32"), # (num_voxels,) + dict(name="coors", shape=(-1, 4), dtype="int32"), # (num_voxels, 4) = (batch, z, y, x) + ], + # Outputs (head tensors) + output_name="reg", # Primary output name + additional_outputs=["height", "dim", "rot", "vel", "hm"], + # Batch size configuration + # - int : fixed batch size + # - None : dynamic batch size with dynamic_axes + batch_size=None, + # Dynamic axes when 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, + save_file="centerpoint.onnx", + export_params=True, + keep_initializers_as_inputs=False, + simplify=True, + # CenterPoint uses multi-file ONNX (voxel encoder + backbone/head) + # When True, model_path should be a directory containing multiple .onnx files + # When False (default), model_path should be a single .onnx file + multi_file=True, +) + +# ============================================================================ +# Backend Configuration (mainly for TensorRT) +# ============================================================================ +backend_config = dict( + common_config=dict( + # Precision policy for TensorRT + # Options: 'auto', 'fp16', 'fp32_tf32', 'strongly_typed' + precision_policy="auto", + # TensorRT workspace size (bytes) + max_workspace_size=2 << 30, # 2 GB + ), + model_inputs=[ + dict( + input_shapes=dict( + input_features=dict( + min_shape=[1000, 32, 11], # Minimum supported input shape + opt_shape=[20000, 32, 11], # Optimal shape for performance tuning + max_shape=[64000, 32, 11], # Maximum supported input shape + ), + 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, # Number of samples to evaluate + verbose=True, + # Decide which backends to evaluate and on which devices. + # Note: + # - tensorrt.device MUST be a CUDA device (e.g., 'cuda:0') + # - For 'none' export mode, all models must already exist on disk. + # - PyTorch backend uses top-level checkpoint_path (no need to specify here) + backends=dict( + # PyTorch evaluation (uses top-level checkpoint_path) + pytorch=dict( + enabled=True, + device=devices["cuda"], # or 'cpu' + ), + # ONNX evaluation + onnx=dict( + enabled=True, + device=devices["cuda"], # 'cpu' or 'cuda:0' + # If None: pipeline will infer from export.work_dir / onnx_config.save_file + # model_dir=None, + model_dir="work_dirs/centerpoint_deployment/onnx/", + ), + # TensorRT evaluation + tensorrt=dict( + enabled=True, + device=devices["cuda"], # must be CUDA + # If None: pipeline will infer from export.work_dir + "/tensorrt" + # engine_dir=None, + engine_dir="work_dirs/centerpoint_deployment/tensorrt/", + ), + ), +) + +# ============================================================================ +# Verification Configuration +# ============================================================================ +# This block defines *scenarios* per export.mode, so the pipeline does not +# need many if/else branches; it just chooses the policy based on export["mode"]. +# ---------------------------------------------------------------------------- +verification = dict( + # Master switch to enable/disable verification + enabled=False, + tolerance=1e-1, + num_verify_samples=1, + # Device aliases for flexible device management + # + # Benefits of using aliases: + # - Change all CPU verifications to "cuda:1"? Just update devices["cpu"] = "cuda:1" + # - Switch ONNX verification device? Just update devices["cuda"] = "cuda:1" + # - Scenarios reference these aliases (e.g., ref_device="cpu", test_device="cuda") + devices=devices, + # Verification scenarios per export mode + # + # Each policy is a list of comparison pairs: + # - ref_backend : reference backend ('pytorch' or 'onnx') + # - ref_device : device alias (e.g., "cpu", "cuda") - resolved via devices dict above + # - test_backend : backend under test ('onnx' or 'tensorrt') + # - test_device : device alias (e.g., "cpu", "cuda") - resolved via devices dict above + # + # Pipeline resolves devices like: actual_device = verification["devices"][policy["ref_device"]] + # + # This structure encodes: + # - 'both': + # 1) PyTorch(cpu) vs ONNX(cpu) + # 2) ONNX(cuda) vs TensorRT(cuda) + # - 'onnx': + # 1) PyTorch(cpu) vs ONNX(cpu) + # - 'trt': + # 1) ONNX(cuda) vs TensorRT(cuda) (using provided ONNX) + 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/projects/CenterPoint/deploy/data_loader.py b/projects/CenterPoint/deploy/data_loader.py new file mode 100644 index 000000000..b628fd8eb --- /dev/null +++ b/projects/CenterPoint/deploy/data_loader.py @@ -0,0 +1,332 @@ +""" +CenterPoint DataLoader for deployment. + +This module implements the BaseDataLoader interface for CenterPoint 3D detection +using MMDet3D's preprocessing pipeline. +""" + +import os +import pickle +from typing import Any, Dict, Optional, Union + +import numpy as np +import torch +from mmengine.config import Config + +from deployment.core import BaseDataLoader, build_preprocessing_pipeline + + +class CenterPointDataLoader(BaseDataLoader): + """ + DataLoader for CenterPoint 3D object detection. + + This loader uses MMDet3D's preprocessing pipeline to ensure consistency + between training and deployment. + + Attributes: + info_file: Path to info.pkl file containing dataset information + pipeline: MMDet3D preprocessing pipeline + data_infos: List of data information dictionaries + """ + + def __init__( + self, + info_file: str, + model_cfg: Config, + device: str = "cpu", + task_type: Optional[str] = None, + ): + """ + Initialize CenterPoint DataLoader. + + Args: + info_file: Path to info.pkl file (e.g., centerpoint_infos_val.pkl) + model_cfg: Model configuration containing test pipeline + device: Device to load tensors on ('cpu', 'cuda', etc.) + task_type: Task type for pipeline building. If None, will try to get from + model_cfg.task_type or model_cfg.deploy.task_type. + + Raises: + FileNotFoundError: If info_file doesn't exist + ValueError: If info file format is invalid + """ + super().__init__( + config={ + "info_file": info_file, + "device": device, + } + ) + + # Validate info file + 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 + + # Load info.pkl + self.data_infos = self._load_info_file() + + # Build preprocessing pipeline + # task_type should be provided from deploy_config + self.pipeline = build_preprocessing_pipeline(model_cfg, task_type=task_type) + + def _load_info_file(self) -> list: + """ + Load and parse info.pkl file. + + Returns: + List of data information dictionaries + + Raises: + ValueError: If file format is invalid + """ + 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 + + # Extract data_list + if isinstance(data, dict): + if "data_list" in data: + data_list = data["data_list"] + elif "infos" in data: + # Alternative key name + data_list = data["infos"] + else: + raise ValueError( + f"Expected 'data_list' or 'infos' key in info file, " f"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]: + """ + Load a single sample with point cloud and annotations. + + Args: + index: Sample index to load + + Returns: + Dictionary containing: + - lidar_points: Dict with lidar_path + - gt_bboxes_3d: 3D bounding boxes (if available) + - gt_labels_3d: 3D labels (if available) + - Additional metadata + + Raises: + IndexError: If index is out of range + """ + 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] + + # Extract lidar points info + lidar_points = info.get("lidar_points", {}) + if not lidar_points: + # Try alternative key + lidar_path = info.get("lidar_path", info.get("velodyne_path", "")) + lidar_points = {"lidar_path": lidar_path} + + # Add data_root to lidar_path if it's relative + if "lidar_path" in lidar_points and not lidar_points["lidar_path"].startswith("/"): + # Get data_root from model config + data_root = getattr(self.model_cfg, "data_root", "data/t4dataset/") + # Ensure data_root ends with '/' + if not data_root.endswith("/"): + data_root += "/" + # Check if the path already starts with data_root to avoid duplication + if not lidar_points["lidar_path"].startswith(data_root): + lidar_points["lidar_path"] = data_root + lidar_points["lidar_path"] + + # Extract annotations (if available) + instances = info.get("instances", []) + + sample = { + "lidar_points": lidar_points, + "sample_idx": info.get("sample_idx", index), + "timestamp": info.get("timestamp", 0), # Add timestamp for pipeline + } + + # Add ground truth if available + if instances: + # Extract 3D bounding boxes and labels from instances + gt_bboxes_3d = [] + gt_labels_3d = [] + + for instance in instances: + if "bbox_3d" in instance and "bbox_label_3d" in instance: + # Check if bbox is valid + 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) + + # Add camera info if available (for multi-modal models) + 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]: + """ + Preprocess using MMDet3D pipeline. + + For CenterPoint, the test pipeline typically outputs only 'points' (not voxelized). + Voxelization is performed by the model's data_preprocessor during inference. + This method returns the point cloud tensor for use by the deployment pipeline. + + Args: + sample: Sample dictionary from load_sample() + + Returns: + Dictionary containing: + - points: Point cloud tensor [N, point_features] (typically [N, 5] for x, y, z, intensity, timestamp) + + Raises: + ValueError: If pipeline output format is unexpected + """ + # Apply pipeline + results = self.pipeline(sample) + + # Validate expected format (MMDet3D 3.x format) + if "inputs" not in results: + raise ValueError( + f"Expected 'inputs' key in pipeline results (MMDet3D 3.x format). " + f"Found keys: {list(results.keys())}. " + f"Please ensure your test pipeline includes Pack3DDetInputs transform." + ) + + pipeline_inputs = results["inputs"] + + # For CenterPoint, pipeline should output 'points' (voxelization happens in data_preprocessor) + if "points" not in pipeline_inputs: + available_keys = list(pipeline_inputs.keys()) + raise ValueError( + f"Expected 'points' key in pipeline inputs for CenterPoint. " + f"Available keys: {available_keys}. " + f"Note: For CenterPoint, voxelization is performed by the model's data_preprocessor, " + f"not in the test pipeline. The pipeline should output raw points using Pack3DDetInputs." + ) + + # Extract points + points = pipeline_inputs["points"] + if isinstance(points, torch.Tensor): + points_tensor = points.to(self.device) + elif isinstance(points, np.ndarray): + points_tensor = torch.from_numpy(points).to(self.device) + elif isinstance(points, list): + # Handle list of point clouds (batch format) + if len(points) > 0: + if isinstance(points[0], torch.Tensor): + points_tensor = points[0].to(self.device) + elif isinstance(points[0], np.ndarray): + points_tensor = torch.from_numpy(points[0]).to(self.device) + else: + raise ValueError( + f"Unexpected type for points[0]: {type(points[0])}. " f"Expected torch.Tensor or np.ndarray." + ) + else: + raise ValueError("Empty points list in pipeline output.") + else: + raise ValueError( + f"Unexpected type for 'points': {type(points)}. " + f"Expected torch.Tensor, np.ndarray, or list of tensors/arrays." + ) + + # Validate points shape + if points_tensor.ndim != 2: + raise ValueError( + f"Expected points tensor with shape [N, point_features], " f"got shape {points_tensor.shape}" + ) + + return {"points": points_tensor} + + 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, 4) where 4 = (x, y, z, intensity) + """ + if lidar_path.endswith(".bin"): + # Load binary point cloud (KITTI/nuScenes format) + points = np.fromfile(lidar_path, dtype=np.float32).reshape(-1, 4) + 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 get_num_samples(self) -> int: + """ + Get total number of samples. + + Returns: + Number of samples in the dataset + """ + return len(self.data_infos) + + def get_ground_truth(self, index: int) -> Dict[str, Any]: + """ + Get ground truth annotations for evaluation. + + Args: + index: Sample index + + Returns: + Dictionary containing: + - gt_bboxes_3d: 3D bounding boxes (N, 7) where 7 = (x, y, z, w, l, h, yaw) + - gt_labels_3d: 3D class labels (N,) + - sample_idx: Sample identifier + """ + 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)) + + # Convert to numpy if needed + 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: + """ + Get class names from config. + + Returns: + List of class names + """ + # Try to get from model config + if hasattr(self.model_cfg, "class_names"): + return self.model_cfg.class_names + + # Default for T4Dataset + return ["VEHICLE", "PEDESTRIAN", "CYCLIST"] diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py new file mode 100644 index 000000000..5ebd8c76c --- /dev/null +++ b/projects/CenterPoint/deploy/evaluator.py @@ -0,0 +1,212 @@ +""" +CenterPoint Evaluator for deployment. + +This module implements evaluation for CenterPoint 3D object detection models. +Uses autoware_perception_evaluation via Detection3DMetricsAdapter for consistent +metric computation between training (T4MetricV2) and deployment. +""" + +import logging +from typing import Any, Dict, List, Optional, Tuple + +import torch +from mmengine.config import Config + +from deployment.core import ( + BaseEvaluator, + Detection3DMetricsAdapter, + Detection3DMetricsConfig, + EvalResultDict, + ModelSpec, + TaskProfile, +) +from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.pipelines import PipelineFactory + +logger = logging.getLogger(__name__) + + +class CenterPointEvaluator(BaseEvaluator): + """ + Evaluator for CenterPoint 3D object detection. + + Extends BaseEvaluator with CenterPoint-specific: + - Pipeline creation (multi-stage 3D detection) + - Point cloud input preparation + - 3D bounding box ground truth parsing + - Detection3DMetricsAdapter integration + """ + + def __init__( + self, + model_cfg: Config, + class_names: Optional[List[str]] = None, + metrics_config: Optional[Detection3DMetricsConfig] = None, + ): + """ + Initialize CenterPoint evaluator. + + Args: + model_cfg: Model configuration. + class_names: List of class names (optional). + metrics_config: Optional configuration for the metrics adapter. + """ + # Determine class names + if class_names is not None: + names = class_names + elif hasattr(model_cfg, "class_names"): + names = model_cfg.class_names + else: + names = ["car", "truck", "bus", "bicycle", "pedestrian"] + + # Create task profile + task_profile = TaskProfile( + task_name="centerpoint_3d_detection", + display_name="CenterPoint 3D Object Detection", + class_names=tuple(names), + num_classes=len(names), + ) + + # Create metrics adapter + if metrics_config is None: + metrics_config = Detection3DMetricsConfig( + class_names=list(names), + frame_id="base_link", + ) + metrics_adapter = Detection3DMetricsAdapter(metrics_config) + + super().__init__( + metrics_adapter=metrics_adapter, + task_profile=task_profile, + model_cfg=model_cfg, + ) + + def set_onnx_config(self, model_cfg: Config) -> None: + """Set ONNX-compatible model config.""" + self.model_cfg = model_cfg + + # ================== VerificationMixin Override ================== + + def _get_output_names(self) -> List[str]: + """Provide meaningful names for CenterPoint head outputs.""" + return ["heatmap", "reg", "height", "dim", "rot", "vel"] + + # ================== BaseEvaluator Implementation ================== + + def _create_pipeline(self, model_spec: ModelSpec, device: str) -> Any: + """Create CenterPoint pipeline.""" + return PipelineFactory.create_centerpoint_pipeline( + 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]]: + """Prepare point cloud input for CenterPoint.""" + 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]: + """Parse CenterPoint predictions (already in standard format).""" + # Pipeline already returns list of dicts with bbox_3d, score, label + return pipeline_output if isinstance(pipeline_output, list) else [] + + def _parse_ground_truths(self, gt_data: Dict[str, Any]) -> List[Dict]: + """Parse 3D ground truth bounding boxes.""" + 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_adapter(self, predictions: List[Dict], ground_truths: List[Dict]) -> None: + """Add frame to Detection3DMetricsAdapter.""" + self.metrics_adapter.add_frame(predictions, ground_truths) + + def _build_results( + self, + latencies: List[float], + latency_breakdowns: List[Dict[str, float]], + num_samples: int, + ) -> EvalResultDict: + """Build CenterPoint evaluation results.""" + # Compute latency statistics + latency_stats = self.compute_latency_stats(latencies) + + # Add stage-wise breakdown if available + if latency_breakdowns: + latency_stats["latency_breakdown"] = self._compute_latency_breakdown(latency_breakdowns) + + # Get metrics from adapter + map_results = self.metrics_adapter.compute_metrics() + summary = self.metrics_adapter.get_summary() + + return { + "mAP": summary.get("mAP", 0.0), + "mAPH": summary.get("mAPH", 0.0), + "per_class_ap": summary.get("per_class_ap", {}), + "detailed_metrics": map_results, + "latency": latency_stats, + "num_samples": num_samples, + } + + def print_results(self, results: EvalResultDict) -> None: + """Pretty print evaluation results.""" + print("\n" + "=" * 80) + print(f"{self.task_profile.display_name} - Evaluation Results") + print("(Using autoware_perception_evaluation for consistent metrics)") + print("=" * 80) + + print(f"\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(f"\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(f"\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(f"\n Stage-wise Latency Breakdown:") + for stage, stats in breakdown.items(): + stage_name = stage.replace("_ms", "").replace("_", " ").title() + 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/projects/CenterPoint/deploy/main.py b/projects/CenterPoint/deploy/main.py new file mode 100644 index 000000000..866cd5b31 --- /dev/null +++ b/projects/CenterPoint/deploy/main.py @@ -0,0 +1,128 @@ +""" +CenterPoint Deployment Main Script (Unified Runner Architecture). + +This script uses the unified deployment runner to handle the complete deployment workflow: +- Export to ONNX and/or TensorRT +- Verify outputs across backends +- Evaluate model performance +""" + +import sys +from pathlib import Path + +from mmengine.config import Config + +# Add project root to path +project_root = Path(__file__).parent.parent.parent.parent +sys.path.insert(0, str(project_root)) + +from deployment.core import BaseDeploymentConfig, setup_logging +from deployment.core.config.base_config import parse_base_args +from deployment.core.contexts import CenterPointExportContext +from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper +from deployment.runners import CenterPointDeploymentRunner +from projects.CenterPoint.deploy.data_loader import CenterPointDataLoader +from projects.CenterPoint.deploy.evaluator import CenterPointEvaluator +from projects.CenterPoint.deploy.utils import extract_t4metric_v2_config + + +def parse_args(): + """Parse command line arguments.""" + parser = parse_base_args() + + # Add CenterPoint-specific arguments + parser.add_argument( + "--rot-y-axis-reference", action="store_true", help="Convert rotation to y-axis clockwise reference" + ) + + args = parser.parse_args() + return args + + +def main(): + """Main deployment pipeline using unified runner.""" + # Parse arguments + args = parse_args() + + # Setup logging + logger = setup_logging(args.log_level) + + # Load configs + 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") + logger.info("=" * 80) + logger.info("Deployment Configuration:") + logger.info(f" Export mode: {config.export_config.mode.value}") + logger.info(f" Work dir: {config.export_config.work_dir}") + logger.info(f" Verify: {config.verification_config.enabled}") + logger.info(f" CUDA device (TensorRT): {config.devices.cuda}") + eval_devices_cfg = config.evaluation_config.devices + logger.info(" Evaluation devices:") + logger.info(f" PyTorch: {eval_devices_cfg.get('pytorch', 'cpu')}") + logger.info(f" ONNX: {eval_devices_cfg.get('onnx', 'cpu')}") + logger.info(f" TensorRT: {eval_devices_cfg.get('tensorrt', config.devices.cuda)}") + logger.info(f" Y-axis rotation: {args.rot_y_axis_reference}") + logger.info(f" Runner will build ONNX-compatible model internally") + + # Validate checkpoint path for export + if config.export_config.should_export_onnx(): + checkpoint_path = config.checkpoint_path + if not checkpoint_path: + logger.error("Checkpoint path must be provided in export.checkpoint_path for ONNX/TensorRT export.") + return + + # Create data loader + logger.info("\nCreating data loader...") + 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") + + # Extract T4MetricV2 config from model_cfg (if available) + # This ensures deployment evaluation uses the same settings as training evaluation + logger.info("\nExtracting T4MetricV2 config from model config...") + metrics_config = extract_t4metric_v2_config(model_cfg, logger=logger) + if metrics_config is None: + logger.warning( + "T4MetricV2 config not found in model_cfg. " + "Using default metrics configuration for deployment evaluation." + ) + else: + logger.info("Successfully extracted T4MetricV2 config from model config") + + # Create evaluator with original model_cfg and extracted metrics_config + # Runner will convert model_cfg to ONNX-compatible config and inject both model_cfg and pytorch_model + evaluator = CenterPointEvaluator( + model_cfg=model_cfg, # original cfg; will be updated to ONNX cfg by runner + metrics_config=metrics_config, # extracted from model_cfg or None (will use defaults) + ) + + # Create CenterPoint-specific runner + # Runner will load model and inject it into evaluator + runner = CenterPointDeploymentRunner( + data_loader=data_loader, + evaluator=evaluator, + config=config, + model_cfg=model_cfg, # original cfg; runner will convert to ONNX cfg in load_pytorch_model() + logger=logger, + onnx_wrapper_cls=CenterPointONNXWrapper, + ) + + # Execute deployment workflow with typed context + context = CenterPointExportContext(rot_y_axis_reference=args.rot_y_axis_reference) + runner.run(context=context) + + logger.info("\n" + "=" * 80) + logger.info("Deployment Complete!") + logger.info("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/projects/CenterPoint/deploy/utils.py b/projects/CenterPoint/deploy/utils.py new file mode 100644 index 000000000..506cd0aec --- /dev/null +++ b/projects/CenterPoint/deploy/utils.py @@ -0,0 +1,228 @@ +""" +CenterPoint deployment utilities. + +This module provides utility functions for CenterPoint model deployment, +including ONNX-compatible config creation and model building. +""" + +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 import Detection3DMetricsConfig + + +def create_onnx_model_cfg( + model_cfg: Config, + device: str, + rot_y_axis_reference: bool = False, +) -> Config: + """ + Create an ONNX-friendly CenterPoint config. + + Args: + model_cfg: Original model configuration + device: Device string (e.g., "cpu", "cuda:0") + rot_y_axis_reference: Whether to use y-axis rotation reference + + Returns: + ONNX-compatible model configuration + """ + 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 and load a model from config + checkpoint on the given device. + + Args: + model_cfg: Model configuration + checkpoint_path: Path to checkpoint file + device: Device string (e.g., "cpu", "cuda:0") + + Returns: + Loaded PyTorch model + """ + 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]: + """ + Build an ONNX-friendly CenterPoint model from the *original* model_cfg. + + This is the single source of truth for building CenterPoint models from + original config + checkpoint to ONNX-compatible model. + + Args: + base_model_cfg: Original model configuration (mmdet3d config) + checkpoint_path: Path to checkpoint file + device: Device string (e.g., "cpu", "cuda:0") + rot_y_axis_reference: Whether to use y-axis rotation reference + + Returns: + Tuple of: + - model: loaded torch.nn.Module (ONNX-compatible) + - onnx_cfg: ONNX-compatible Config actually used to build the model + """ + # 1) Convert original cfg to ONNX-friendly cfg + onnx_cfg = create_onnx_model_cfg( + base_model_cfg, + device=device, + rot_y_axis_reference=rot_y_axis_reference, + ) + + # 2) Use shared build_model_from_cfg to load checkpoint + 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, +) -> Optional[Detection3DMetricsConfig]: + """ + Extract T4MetricV2 configuration from model config. + + This function extracts evaluation settings from T4MetricV2 evaluator config + in the model config to ensure deployment evaluation uses the same settings + as training evaluation. + + Args: + model_cfg: Model configuration (may contain val_evaluator or test_evaluator with T4MetricV2 settings) + class_names: Optional list of class names. If not provided, will be extracted from model_cfg. + logger: Optional logger instance for logging + + Returns: + Detection3DMetricsConfig with settings from model_cfg, or None if T4MetricV2 config not found + + Note: + Only supports T4MetricV2. T4Metric (v1) is not supported. + """ + if logger is None: + logger = logging.getLogger(__name__) + + # Get class names + if class_names is None: + if hasattr(model_cfg, "class_names"): + class_names = model_cfg.class_names + else: + # Default for T4Dataset + class_names = ["car", "truck", "bus", "bicycle", "pedestrian"] + + # Try to extract T4MetricV2 configs from val_evaluator or test_evaluator + 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: + logger.warning("No val_evaluator or test_evaluator found in model_cfg") + return None + + # Helper to get value from dict or ConfigDict + 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) + + # Check if evaluator is T4MetricV2 + evaluator_type = get_cfg_value(evaluator_cfg, "type") + if evaluator_type != "T4MetricV2": + logger.warning( + f"Evaluator type is '{evaluator_type}', not 'T4MetricV2'. " "Only T4MetricV2 is supported. Returning None." + ) + return None + + logger.info("=" * 60) + logger.info("Detected T4MetricV2 config!") + logger.info("Extracting evaluation settings for deployment...") + logger.info("=" * 60) + + # Extract perception_evaluator_configs + 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") + + # Extract critical_object_filter_config + critical_object_filter_config = get_cfg_value(evaluator_cfg, "critical_object_filter_config") + + # Extract frame_pass_fail_config + frame_pass_fail_config = get_cfg_value(evaluator_cfg, "frame_pass_fail_config") + + # Convert ConfigDict to regular dict if needed + 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) + + logger.info(f"Extracted settings:") + logger.info(f" - frame_id: {frame_id}") + if evaluation_config_dict: + logger.info(f" - evaluation_config_dict: {list(evaluation_config_dict.keys())}") + if "center_distance_bev_thresholds" in evaluation_config_dict: + logger.info( + f" - center_distance_bev_thresholds: " f"{evaluation_config_dict['center_distance_bev_thresholds']}" + ) + if critical_object_filter_config: + logger.info(f" - critical_object_filter_config: enabled") + if "max_distance_list" in critical_object_filter_config: + logger.info(f" - max_distance_list: " f"{critical_object_filter_config['max_distance_list']}") + if frame_pass_fail_config: + logger.info(f" - frame_pass_fail_config: enabled") + if "matching_threshold_list" in frame_pass_fail_config: + logger.info(f" - matching_threshold_list: " f"{frame_pass_fail_config['matching_threshold_list']}") + logger.info("=" * 60) + + 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/projects/CenterPoint/models/detectors/centerpoint_onnx.py b/projects/CenterPoint/models/detectors/centerpoint_onnx.py index ff568a00d..be193371d 100644 --- a/projects/CenterPoint/models/detectors/centerpoint_onnx.py +++ b/projects/CenterPoint/models/detectors/centerpoint_onnx.py @@ -1,6 +1,7 @@ import os from typing import Callable, Dict, List, Tuple +import numpy as np import torch from mmdet3d.models.detectors.centerpoint import CenterPoint from mmdet3d.registry import MODELS @@ -46,50 +47,123 @@ 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") + # 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_random_inputs(self): + def _get_real_inputs(self, data_loader=None, sample_idx=0): """ - Generate random inputs and preprocess it to feed it to onnx. + Generate real inputs from data loader instead of random inputs. + This ensures ONNX export uses realistic data distribution. + """ + if data_loader is None: + # Fallback to random inputs if no data loader provided + return self._get_random_inputs() + + try: + # Get real sample from data loader + sample = data_loader.load_sample(sample_idx) + + # Check if sample has lidar_points + if "lidar_points" in sample: + lidar_path = sample["lidar_points"].get("lidar_path") + if lidar_path and os.path.exists(lidar_path): + # Load point cloud from file + points = self._load_point_cloud(lidar_path) + # Convert to torch tensor + points = torch.from_numpy(points).to(self._torch_device) + # Convert to list format expected by voxelize + points = [points] + return {"points": points, "data_samples": None} + else: + self._logger.warning(f"Lidar path not found or file doesn't exist: {lidar_path}") + else: + self._logger.warning(f"Sample doesn't contain lidar_points: {sample.keys()}") + + # Fallback to random inputs if real data loading fails + self._logger.warning("Failed to load real data, falling back to random inputs") + return self._get_random_inputs() + + except Exception as e: + self._logger.warning(f"Failed to load real data, falling back to random inputs: {e}") + return self._get_random_inputs() + + 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=None, sample_idx=0): + """ + Extract features using real data if available, otherwise fallback to random data. """ - # 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() + # Ensure data preprocessor is on the correct device + if hasattr(self.data_preprocessor, "to"): + self.data_preprocessor.to(self._torch_device) + + # Get inputs (real data if available, otherwise random) + inputs = self._get_real_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 + # TODO(vividff): this is moved to centerpoint onnx exporter. def save_onnx( self, save_dir: str, verbose=False, onnx_opset_version=13, + data_loader=None, + sample_idx=0, ): """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) + data_loader: Optional data loader to use real data for export + sample_idx: Index of sample to use for export """ print_log(f"Running onnx_opset_version: {onnx_opset_version}") - # Get features - input_features, voxel_dict = self._extract_random_features() + # Get features using real data if available + input_features, voxel_dict = self._extract_features(data_loader, sample_idx) # === pts_voxel_encoder === pth_onnx_pve = os.path.join(save_dir, "pts_voxel_encoder.onnx") @@ -125,6 +199,7 @@ def save_onnx( ) # 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,), @@ -140,37 +215,114 @@ def save_onnx( ) 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() + # TODO(vividf): remove this since torchscript is deprecated + # 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) + # 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() + # 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) + # # 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) + # 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) + + # # TODO(vividf): this can be removed after the numerical consistency issue is resolved + # def save_onnx_with_intermediate_outputs(self, save_dir: str, onnx_opset_version: int = 13, verbose: bool = False): + # """Export CenterPoint model to ONNX format with intermediate outputs for debugging.""" + # import os + # import torch.onnx + + # print_log(f"Running onnx_opset_version: {onnx_opset_version}") + # print_log("Exporting with intermediate outputs for debugging...") + + # # Create output directory + # os.makedirs(save_dir, exist_ok=True) + + # # 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) + + # # === Create backbone with intermediate outputs === + # class BackboneWithIntermediateOutputs(torch.nn.Module): + # def __init__(self, backbone): + # super().__init__() + # self.backbone = backbone + + # def forward(self, x): + # outs = [] + # for i in range(len(self.backbone.blocks)): + # x = self.backbone.blocks[i](x) + # outs.append(x) + # return tuple(outs) + + # backbone_with_outputs = BackboneWithIntermediateOutputs(self.pts_backbone) + + # # Export backbone with intermediate outputs + # pth_onnx_backbone = os.path.join(save_dir, "pts_backbone_with_intermediate.onnx") + # torch.onnx.export( + # backbone_with_outputs, + # (x,), + # f=pth_onnx_backbone, + # input_names=("spatial_features",), + # output_names=("stage_0", "stage_1", "stage_2"), + # dynamic_axes={ + # "spatial_features": {0: "batch_size", 2: "H", 3: "W"}, + # "stage_0": {0: "batch_size", 2: "H", 3: "W"}, + # "stage_1": {0: "batch_size", 2: "H", 3: "W"}, + # "stage_2": {0: "batch_size", 2: "H", 3: "W"}, + # }, + # verbose=verbose, + # opset_version=onnx_opset_version, + # do_constant_folding=True, + # ) + # print_log(f"Saved pts_backbone with intermediate outputs: {pth_onnx_backbone}") + + # return save_dir diff --git a/projects/CenterPoint/models/detectors/centerpoint_onnx_old.py b/projects/CenterPoint/models/detectors/centerpoint_onnx_old.py new file mode 100644 index 000000000..b576728a5 --- /dev/null +++ b/projects/CenterPoint/models/detectors/centerpoint_onnx_old.py @@ -0,0 +1,182 @@ +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. + """ + num_points = 200000 + points_tensor = torch.rand(num_points, self._point_channels, device=self._torch_device) + + point_cloud_range = getattr(self.pts_voxel_encoder, "point_cloud_range", None) + if point_cloud_range is not None and len(point_cloud_range) >= 6: + mins = torch.tensor(point_cloud_range[:3], device=self._torch_device, dtype=points_tensor.dtype) + maxs = torch.tensor(point_cloud_range[3:6], device=self._torch_device, dtype=points_tensor.dtype) + spatial = points_tensor[:, :3] * (maxs - mins) + mins + points_tensor[:, :3] = spatial + + points = [points_tensor] + # 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) From a356c3d385488021c98cd1fedab0a22f26a1d193 Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 13:45:27 +0900 Subject: [PATCH 40/62] chore: comment unused import Signed-off-by: vividf --- deployment/runners/__init__.py | 6 ++++-- deployment/runners/projects/__init__.py | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/deployment/runners/__init__.py b/deployment/runners/__init__.py index cfbebdd68..36d3dd765 100644 --- a/deployment/runners/__init__.py +++ b/deployment/runners/__init__.py @@ -4,9 +4,11 @@ from deployment.runners.common.deployment_runner import BaseDeploymentRunner from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator from deployment.runners.common.verification_orchestrator import VerificationOrchestrator -from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner + +# from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner from deployment.runners.projects.centerpoint_runner import CenterPointDeploymentRunner -from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner + +# from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner __all__ = [ # Base runner diff --git a/deployment/runners/projects/__init__.py b/deployment/runners/projects/__init__.py index 7a42b7b47..a48acbe78 100644 --- a/deployment/runners/projects/__init__.py +++ b/deployment/runners/projects/__init__.py @@ -1,11 +1,12 @@ """Project-specific deployment runners.""" -from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner +# from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner from deployment.runners.projects.centerpoint_runner import CenterPointDeploymentRunner -from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner + +# from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner __all__ = [ - "CalibrationDeploymentRunner", + # "CalibrationDeploymentRunner", "CenterPointDeploymentRunner", - "YOLOXOptElanDeploymentRunner", + # "YOLOXOptElanDeploymentRunner", ] From 2c8d263a620ea107d68d4eaddbf6c1476831355e Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 16:56:13 +0900 Subject: [PATCH 41/62] chore: fix onnx file layout and remove old deployment script Signed-off-by: vividf --- .../core/evaluation/verification_mixin.py | 2 +- .../exporters/centerpoint/onnx_workflow.py | 4 +- .../centerpoint/tensorrt_workflow.py | 2 +- .../runners/projects/centerpoint_runner.py | 3 +- projects/CenterPoint/deploy/README.md | 346 +++++++----------- .../CenterPoint/deploy/component_extractor.py | 18 +- .../deploy/configs/deploy_config.py | 2 +- .../models/detectors/centerpoint_onnx_old.py | 182 --------- .../CenterPoint/runners/deployment_runner.py | 103 ------ projects/CenterPoint/scripts/deploy.py | 87 ----- 10 files changed, 146 insertions(+), 603 deletions(-) delete mode 100644 projects/CenterPoint/models/detectors/centerpoint_onnx_old.py delete mode 100644 projects/CenterPoint/runners/deployment_runner.py delete mode 100644 projects/CenterPoint/scripts/deploy.py diff --git a/deployment/core/evaluation/verification_mixin.py b/deployment/core/evaluation/verification_mixin.py index d1977f2c9..9b44c2110 100644 --- a/deployment/core/evaluation/verification_mixin.py +++ b/deployment/core/evaluation/verification_mixin.py @@ -472,7 +472,7 @@ def _log_verification_summary(self, results: VerifyResultDict, logger: logging.L logger.info("=" * 60) for key, value in results["samples"].items(): - status = "✓ PASSED" if value else "✗ FAILED" + status = "PASSED" if value else "FAILED" logger.info(f" {key}: {status}") summary = results["summary"] diff --git a/deployment/exporters/centerpoint/onnx_workflow.py b/deployment/exporters/centerpoint/onnx_workflow.py index c1a640702..c7d4480e1 100644 --- a/deployment/exporters/centerpoint/onnx_workflow.py +++ b/deployment/exporters/centerpoint/onnx_workflow.py @@ -128,14 +128,14 @@ def export( config_override=component.config_override, ) exported_paths.append(output_path) - self.logger.info(f"✓ Exported {component.name}: {output_path}") + self.logger.info(f"Exported {component.name}: {output_path}") except Exception as exc: self.logger.error(f"Failed to export {component.name}") raise RuntimeError(f"{component.name} export failed") from exc # Log summary self.logger.info("\n" + "=" * 80) - self.logger.info("✅ CenterPoint ONNX export successful") + 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/exporters/centerpoint/tensorrt_workflow.py b/deployment/exporters/centerpoint/tensorrt_workflow.py index c94c88b1d..173b9a3b7 100644 --- a/deployment/exporters/centerpoint/tensorrt_workflow.py +++ b/deployment/exporters/centerpoint/tensorrt_workflow.py @@ -121,7 +121,7 @@ def export( output_path=trt_path, onnx_path=onnx_file_path, ) - self.logger.info(f"✓ TensorRT engine saved: {artifact.path}") + self.logger.info(f"TensorRT engine saved: {artifact.path}") except Exception as exc: self.logger.error(f"Failed to convert {onnx_file} to TensorRT") raise RuntimeError(f"TensorRT export failed for {onnx_file}") from exc diff --git a/deployment/runners/projects/centerpoint_runner.py b/deployment/runners/projects/centerpoint_runner.py index acdd14a93..c151bfe12 100644 --- a/deployment/runners/projects/centerpoint_runner.py +++ b/deployment/runners/projects/centerpoint_runner.py @@ -58,7 +58,8 @@ def __init__( tensorrt_workflow: Optional custom TensorRT workflow """ # Create component extractor for model-specific logic - component_extractor = CenterPointComponentExtractor(logger=logger) + simplify_onnx = config.get_onnx_settings().simplify + component_extractor = CenterPointComponentExtractor(logger=logger, simplify=simplify_onnx) # Initialize base runner super().__init__( diff --git a/projects/CenterPoint/deploy/README.md b/projects/CenterPoint/deploy/README.md index 9c834a5b1..768a9a0f9 100644 --- a/projects/CenterPoint/deploy/README.md +++ b/projects/CenterPoint/deploy/README.md @@ -1,15 +1,14 @@ # CenterPoint Deployment -Complete deployment pipeline for CenterPoint 3D object detection. +Complete deployment pipeline for CenterPoint 3D object detection using the unified deployment framework. ## Features -- ✅ Export to ONNX and TensorRT -- ✅ Full evaluation with 3D detection metrics +- ✅ Export to ONNX and TensorRT (multi-file architecture) +- ✅ Full evaluation with 3D detection metrics (autoware_perception_evaluation) - ✅ Latency benchmarking -- ✅ Cross-backend verification - ✅ Uses MMDet3D pipeline for consistency with training -- ✅ Modernized from legacy DeploymentRunner +- ✅ Unified runner architecture with composition-based design ## Quick Start @@ -24,49 +23,52 @@ data/t4dataset/ └── *.bin ``` + ### 2. Export and Evaluate ```bash # Export to ONNX and TensorRT with evaluation python projects/CenterPoint/deploy/main.py \ - projects/CenterPoint/deploy/deploy_config.py \ - projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_base_amp.py \ - path/to/checkpoint.pth \ - --work-dir work_dirs/centerpoint_deployment \ - --replace-onnx-models # Important for ONNX export + projects/CenterPoint/deploy/configs/deploy_config.py \ + projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_base_amp.py ``` -### 3. Evaluation Only +### 3. Export Modes + +The pipeline supports different export modes configured in `deploy_config.py`: ```bash -# Evaluate PyTorch model only -python projects/CenterPoint/deploy/main.py \ - projects/CenterPoint/deploy/deploy_config.py \ - projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_base_amp.py \ - path/to/checkpoint.pth +# ONNX only +# Set export.mode = "onnx" in deploy_config.py + +# TensorRT only (requires existing ONNX files) +# Set export.mode = "trt" and export.onnx_path = "path/to/onnx/dir" + +# Both ONNX and TensorRT +# Set export.mode = "both" + +# Evaluation only (no export) +# Set export.mode = "none" ``` ## Configuration -### Export Settings +All configuration is done through `deploy_config.py`. Key sections: + +### Checkpoint Path ```python -export = dict( - mode='both', # 'onnx', 'trt', 'both', 'none' - verify=True, # Cross-backend verification - device='cuda:0', # Device - work_dir='work_dirs/centerpoint_deployment' -) +# Single source of truth for PyTorch model +checkpoint_path = "work_dirs/centerpoint/best_checkpoint.pth" ``` -### Evaluation Settings +### Export Settings ```python -evaluation = dict( - enabled=True, - num_samples=50, # 3D is slower, use fewer samples - verbose=False, - models_to_evaluate=['pytorch'] # Add 'onnx' after export +export = dict( + mode="both", # 'onnx', 'trt', 'both', 'none' + work_dir="work_dirs/centerpoint_deployment", + onnx_path=None, # Required when mode='trt' ) ``` @@ -79,7 +81,23 @@ onnx_config = dict( save_file="centerpoint.onnx", export_params=True, keep_initializers_as_inputs=False, - simplify=True, + simplify=False, # Set to True to run onnx-simplifier + multi_file=True, # CenterPoint uses multi-file ONNX +) +``` + +### Evaluation Settings + +```python +evaluation = dict( + enabled=True, + num_samples=1, # Number of samples to evaluate + verbose=True, + backends=dict( + pytorch=dict(enabled=True, device="cuda:0"), + onnx=dict(enabled=True, device="cuda:0", model_dir="..."), + tensorrt=dict(enabled=True, device="cuda:0", engine_dir="..."), + ), ) ``` @@ -88,237 +106,127 @@ onnx_config = dict( ```python backend_config = dict( common_config=dict( - # Precision policy for TensorRT - # Options: 'auto', 'fp16', 'fp32_tf32', 'strongly_typed' - precision_policy="auto", - # TensorRT workspace size (bytes) - max_workspace_size=2 << 30, # 2 GB (3D models need more memory) + precision_policy="auto", # 'auto', 'fp16', 'fp32_tf32', 'strongly_typed' + max_workspace_size=2 << 30, # 2 GB ), + 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], + ), + ) + ) + ], ) ``` ### Verification Settings ```python -# Note: Verification is controlled by export.verify (see export section above) -# This section only contains verification parameters (tolerance, devices, etc.) verification = dict( - tolerance=1e-1, # Slightly higher tolerance for 3D detection - num_verify_samples=1, # Fewer samples for 3D (slower) - devices=dict( - pytorch="cpu", # PyTorch reference device (should be 'cpu') - onnx_cpu="cpu", # ONNX verification device for PyTorch comparison - onnx_cuda="cuda:0", # ONNX verification device for TensorRT comparison - tensorrt="cuda:0", # TensorRT verification device (must be 'cuda:0') + enabled=True, + tolerance=1e-1, + num_verify_samples=1, + devices=devices, # Reference to top-level devices dict + 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=[...], + trt=[...], + none=[], ), ) ``` -## TensorRT Architecture +## Architecture + +CenterPoint uses a multi-file ONNX/TensorRT architecture: + +``` +CenterPoint Model +├── pts_voxel_encoder → pts_voxel_encoder.onnx / .engine +└── pts_backbone_neck_head → pts_backbone_neck_head.onnx / .engine +``` + +### Component Extractor -CenterPoint uses a multi-engine TensorRT setup: +The `CenterPointComponentExtractor` handles model-specific logic: +- Extracts voxel encoder and backbone+neck+head components +- Prepares sample inputs for each component +- Configures per-component ONNX export settings -1. **pts_voxel_encoder.engine** - Voxel feature extraction -2. **pts_backbone_neck_head.engine** - Backbone, neck, and head processing +### Deployment Runner -The TensorRT backend automatically handles the pipeline between these engines, including: -- Voxel encoder inference -- Middle encoder processing (PyTorch) -- Backbone/neck/head inference -- Output formatting +`CenterPointDeploymentRunner` orchestrates the workflow: +- Loads ONNX-compatible CenterPoint model +- Injects model and config to evaluator +- Delegates export to `CenterPointONNXExportWorkflow` and `CenterPointTensorRTExportWorkflow` ## Output Structure -After deployment, you'll find: +After deployment: ``` work_dirs/centerpoint_deployment/ -├── pts_voxel_encoder.onnx -├── pts_backbone_neck_head.onnx +├── onnx/ +│ ├── pts_voxel_encoder.onnx +│ └── pts_backbone_neck_head.onnx └── tensorrt/ ├── pts_voxel_encoder.engine └── pts_backbone_neck_head.engine ``` +## Command Line Options + +```bash +python projects/CenterPoint/deploy/main.py \ + \ + \ + [--rot-y-axis-reference] # Convert rotation to y-axis clockwise reference + [--log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL}] +``` + ## Troubleshooting ### TensorRT Build Issues -If TensorRT engine building fails: - -1. **Memory Issues**: Increase `max_workspace_size` in config -2. **Shape Issues**: Check input shapes match your data +1. **Memory Issues**: Increase `max_workspace_size` in `backend_config` +2. **Shape Issues**: Verify `model_inputs` shapes match your data 3. **Precision Issues**: Try different `precision_policy` settings ### Verification Failures -If cross-backend verification fails: - 1. **Tolerance**: Increase `tolerance` in verification config 2. **Samples**: Reduce `num_verify_samples` for faster testing -3. **Device**: Ensure all backends use the same device - -### Performance Issues - -For better TensorRT performance: -1. **Precision**: Use `fp16` for faster inference -2. **Batch Size**: Optimize for your typical batch sizes -3. **Profiling**: Use TensorRT profiling tools for optimization -### Important Flags +## File Structure -- `--replace-onnx-models`: Replace model components with ONNX-compatible versions - - Changes `CenterPoint` → `CenterPointONNX` - - Changes `PillarFeatureNet` → `PillarFeatureNetONNX` - - Changes `CenterHead` → `CenterHeadONNX` - -- `--rot-y-axis-reference`: Convert rotation to y-axis clockwise reference - -## Output - -The deployment pipeline will generate: - -``` -work_dirs/centerpoint_deployment/ -├── pillar_encoder.onnx -├── backbone.onnx -├── neck.onnx -└── head.onnx -``` - -And print evaluation results: -``` -================================================================================ -CenterPoint Evaluation Results -================================================================================ - -Detection Statistics: - Total Predictions: 1234 - Total Ground Truths: 1180 - -Per-Class Statistics: - VEHICLE: - Predictions: 890 - Ground Truths: 856 - PEDESTRIAN: - Predictions: 234 - Ground Truths: 218 - CYCLIST: - Predictions: 110 - Ground Truths: 106 - -Latency Statistics: - Mean: 45.23 ms - Std: 3.45 ms - Min: 41.82 ms - Max: 58.31 ms - Median: 44.18 ms - -Total Samples: 50 -================================================================================ -``` - -## Architecture - -``` -CenterPointDataLoader (from data_loader.py) - ├── Uses MMDet3D test pipeline - ├── Loads info.pkl - ├── Handles voxelization - └── Preprocesses point clouds - -CenterPointEvaluator (from evaluator.py) - ├── Supports PyTorch, ONNX (TensorRT coming) - ├── Computes detection statistics - └── Measures latency - -main.py - ├── Replaces legacy DeploymentRunner - ├── Exports to ONNX - ├── Verifies outputs (TODO) - └── Runs evaluation -``` - -## Migration from Legacy Code - -This new implementation replaces the old `DeploymentRunner`: - -### Old Way (scripts/deploy.py) -```python -from projects.CenterPoint.runners.deployment_runner import DeploymentRunner - -runner = DeploymentRunner( - experiment_name=experiment_name, - model_cfg_path=model_cfg_path, - checkpoint_path=checkpoint_path, - work_dir=work_dir, - replace_onnx_models=True, - device='gpu', - onnx_opset_version=13 -) -runner.run() ``` - -### New Way (deploy/main.py) -```bash -python projects/CenterPoint/deploy/main.py \ - deploy_config.py \ - model_config.py \ - checkpoint.pth \ - --replace-onnx-models +projects/CenterPoint/deploy/ +├── main.py # Entry point +├── configs/ +│ └── deploy_config.py # Deployment configuration +├── component_extractor.py # Model-specific component extraction +├── data_loader.py # CenterPoint data loader +├── evaluator.py # CenterPoint evaluator +├── utils.py # Utility functions +└── README.md # This file ``` -**Benefits of New Approach**: -- ✅ Integrated with unified deployment framework -- ✅ Supports verification and evaluation -- ✅ Consistent with other projects (YOLOX, etc.) -- ✅ Better configuration management -- ✅ More modular and maintainable - -## Troubleshooting - -### Issue: Dataset not found - -**Solution**: Update `runtime_io.info_file` in deploy_config.py - -### Issue: ONNX export fails without --replace-onnx-models - -**Solution**: Always use `--replace-onnx-models` flag for ONNX export - -### Issue: Out of memory - -**Solution**: -1. Reduce `evaluation.num_samples` -2. Reduce point cloud range -3. Increase `backend_config.common_config.max_workspace_size` - -### Issue: Different results between training and deployment - -**Solution**: -1. Make sure using same config file -2. Verify pipeline is correctly built -3. Check voxelization parameters - -## Known Limitations - -1. **3D mAP Metrics**: Current implementation uses simplified metrics. For production, integrate with `mmdet3d.core.evaluation` for proper 3D detection metrics (mAP, NDS, mATE, etc.) - -2. **TensorRT Support**: TensorRT export for multi-file ONNX models needs custom implementation - -3. **Batch Inference**: Currently supports single sample inference - -## TODO - -- [ ] Integrate with mmdet3d evaluation for proper mAP/NDS metrics -- [ ] Implement TensorRT multi-file export -- [ ] Add cross-backend verification -- [ ] Support batch inference -- [ ] Add visualization tools - ## References -- [Deployment Framework Design](../../../docs/design/deploy_pipeline_design.md) -- [DataLoader Tutorial](../../../docs/tutorial/tutorial_deployment_dataloader.md) +- [Deployment Framework Documentation](../../../deployment/README.md) - [CenterPoint Paper](https://arxiv.org/abs/2006.11275) -- [Old DeploymentRunner](../runners/deployment_runner.py) (deprecated) diff --git a/projects/CenterPoint/deploy/component_extractor.py b/projects/CenterPoint/deploy/component_extractor.py index 95d6cb4e6..87e267b45 100644 --- a/projects/CenterPoint/deploy/component_extractor.py +++ b/projects/CenterPoint/deploy/component_extractor.py @@ -32,14 +32,16 @@ class CenterPointComponentExtractor(ModelComponentExtractor): - Configuring ONNX export settings """ - def __init__(self, logger: logging.Logger = None): + def __init__(self, logger: logging.Logger = None, simplify: bool = True): """ Initialize extractor. Args: logger: Optional logger instance + simplify: Whether to run onnx-simplifier for the exported parts """ self.logger = logger or logging.getLogger(__name__) + self.simplify = simplify def extract_components(self, model: torch.nn.Module, sample_data: Any) -> List[ExportableComponent]: """ @@ -84,7 +86,7 @@ def _create_voxel_encoder_component( }, opset_version=16, do_constant_folding=True, - simplify=True, + simplify=self.simplify, save_file="pts_voxel_encoder.onnx", ), ) @@ -102,6 +104,12 @@ def _create_backbone_component( # Get output names 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"} + return ExportableComponent( name="pts_backbone_neck_head", module=backbone_module, @@ -109,12 +117,10 @@ def _create_backbone_component( config_override=ONNXExportConfig( input_names=("spatial_features",), output_names=output_names, - dynamic_axes={ - "spatial_features": {0: "batch_size", 2: "height", 3: "width"}, - }, + dynamic_axes=dynamic_axes, opset_version=16, do_constant_folding=True, - simplify=True, + simplify=self.simplify, save_file="pts_backbone_neck_head.onnx", ), ) diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py index 70cd091ca..e90aa796d 100644 --- a/projects/CenterPoint/deploy/configs/deploy_config.py +++ b/projects/CenterPoint/deploy/configs/deploy_config.py @@ -96,7 +96,7 @@ save_file="centerpoint.onnx", export_params=True, keep_initializers_as_inputs=False, - simplify=True, + simplify=False, # CenterPoint uses multi-file ONNX (voxel encoder + backbone/head) # When True, model_path should be a directory containing multiple .onnx files # When False (default), model_path should be a single .onnx file diff --git a/projects/CenterPoint/models/detectors/centerpoint_onnx_old.py b/projects/CenterPoint/models/detectors/centerpoint_onnx_old.py deleted file mode 100644 index b576728a5..000000000 --- a/projects/CenterPoint/models/detectors/centerpoint_onnx_old.py +++ /dev/null @@ -1,182 +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. - """ - num_points = 200000 - points_tensor = torch.rand(num_points, self._point_channels, device=self._torch_device) - - point_cloud_range = getattr(self.pts_voxel_encoder, "point_cloud_range", None) - if point_cloud_range is not None and len(point_cloud_range) >= 6: - mins = torch.tensor(point_cloud_range[:3], device=self._torch_device, dtype=points_tensor.dtype) - maxs = torch.tensor(point_cloud_range[3:6], device=self._torch_device, dtype=points_tensor.dtype) - spatial = points_tensor[:, :3] * (maxs - mins) + mins - points_tensor[:, :3] = spatial - - points = [points_tensor] - # 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() From 9eb37278c3fbd277040252f1bd1502443afe07fe Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 17:33:01 +0900 Subject: [PATCH 42/62] chore: fix layout Signed-off-by: vividf --- deployment/core/evaluation/base_evaluator.py | 4 +- .../deploy/configs/deploy_config.py | 2 +- projects/CenterPoint/deploy/evaluator.py | 10 +- .../models/detectors/centerpoint_onnx.py | 249 +++--------------- 4 files changed, 41 insertions(+), 224 deletions(-) diff --git a/deployment/core/evaluation/base_evaluator.py b/deployment/core/evaluation/base_evaluator.py index f5a3c8721..23d251ae9 100644 --- a/deployment/core/evaluation/base_evaluator.py +++ b/deployment/core/evaluation/base_evaluator.py @@ -303,9 +303,7 @@ def _compute_latency_breakdown( if not latency_breakdowns: return LatencyBreakdown.empty() - all_stages = set() - for breakdown in latency_breakdowns: - all_stages.update(breakdown.keys()) + stage_order = list(dict.fromkeys(stage for breakdown in latency_breakdowns for stage in breakdown.keys())) return LatencyBreakdown( stages={ diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py index e90aa796d..75864b10b 100644 --- a/projects/CenterPoint/deploy/configs/deploy_config.py +++ b/projects/CenterPoint/deploy/configs/deploy_config.py @@ -39,7 +39,7 @@ # - 'trt' : build TensorRT engine from an existing ONNX # - 'both' : export PyTorch -> ONNX -> TensorRT # - 'none' : no export (only evaluation / verification on existing artifacts) - mode="both", + mode="none", # ---- Common options ---------------------------------------------------- work_dir="work_dirs/centerpoint_deployment", # ---- ONNX source when building TensorRT only --------------------------- diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index 5ebd8c76c..b330b8510 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -203,10 +203,16 @@ def print_results(self, results: EvalResultDict) -> None: if "latency_breakdown" in latency: breakdown = latency["latency_breakdown"] - print(f"\n Stage-wise Latency Breakdown:") + print(f"\nStage-wise Latency Breakdown:") + # Sub-stages that belong under "Model" + model_substages = {"voxel_encoder_ms", "middle_encoder_ms", "backbone_head_ms"} for stage, stats in breakdown.items(): stage_name = stage.replace("_ms", "").replace("_", " ").title() - print(f" {stage_name:18s}: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms") + # Use extra indentation for model sub-stages + 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/projects/CenterPoint/models/detectors/centerpoint_onnx.py b/projects/CenterPoint/models/detectors/centerpoint_onnx.py index be193371d..7420a110e 100644 --- a/projects/CenterPoint/models/detectors/centerpoint_onnx.py +++ b/projects/CenterPoint/models/detectors/centerpoint_onnx.py @@ -1,11 +1,11 @@ import os -from typing import Callable, Dict, List, Tuple +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, print_log +from mmengine.logging import MMLogger from torch import nn @@ -55,42 +55,36 @@ def __init__(self, point_channels: int = 5, device: str = "cpu", **kwargs): self._logger = MMLogger.get_current_instance() self._logger.info("Running CenterPointONNX!") - def _get_real_inputs(self, data_loader=None, sample_idx=0): + def _get_inputs(self, data_loader, sample_idx=0): """ - Generate real inputs from data loader instead of random inputs. - This ensures ONNX export uses realistic data distribution. + 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: - # Fallback to random inputs if no data loader provided - return self._get_random_inputs() + 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) - try: - # Get real sample from data loader - sample = data_loader.load_sample(sample_idx) + if "lidar_points" not in sample: + raise KeyError("Sample does not contain 'lidar_points'") - # Check if sample has lidar_points - if "lidar_points" in sample: - lidar_path = sample["lidar_points"].get("lidar_path") - if lidar_path and os.path.exists(lidar_path): - # Load point cloud from file - points = self._load_point_cloud(lidar_path) - # Convert to torch tensor - points = torch.from_numpy(points).to(self._torch_device) - # Convert to list format expected by voxelize - points = [points] - return {"points": points, "data_samples": None} - else: - self._logger.warning(f"Lidar path not found or file doesn't exist: {lidar_path}") - else: - self._logger.warning(f"Sample doesn't contain lidar_points: {sample.keys()}") + lidar_path = sample["lidar_points"].get("lidar_path") + if not lidar_path: + raise ValueError("Sample must provide 'lidar_path' inside 'lidar_points'") - # Fallback to random inputs if real data loading fails - self._logger.warning("Failed to load real data, falling back to random inputs") - return self._get_random_inputs() + if not os.path.exists(lidar_path): + raise FileNotFoundError(f"Lidar path not found: {lidar_path}") - except Exception as e: - self._logger.warning(f"Failed to load real data, falling back to random inputs: {e}") - return self._get_random_inputs() + 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: """ @@ -119,18 +113,20 @@ def _load_point_cloud(self, lidar_path: str) -> np.ndarray: return points - def _extract_features(self, data_loader=None, sample_idx=0): + def _extract_features(self, data_loader, sample_idx=0): """ - Extract features using real data if available, otherwise fallback to random data. + 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) - # Get inputs (real data if available, otherwise random) - inputs = self._get_real_inputs(data_loader, sample_idx) + 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 @@ -143,186 +139,3 @@ def _extract_features(self, data_loader=None, sample_idx=0): voxel_dict["voxels"], voxel_dict["num_points"], voxel_dict["coors"] ) return input_features, voxel_dict - - # TODO(vividff): this is moved to centerpoint onnx exporter. - def save_onnx( - self, - save_dir: str, - verbose=False, - onnx_opset_version=13, - data_loader=None, - sample_idx=0, - ): - """Save onnx model - Args: - save_dir (str): directory path to save onnx models - verbose (bool, optional) - onnx_opset_version (int, optional) - data_loader: Optional data loader to use real data for export - sample_idx: Index of sample to use for export - """ - print_log(f"Running onnx_opset_version: {onnx_opset_version}") - # Get features using real data if available - input_features, voxel_dict = self._extract_features(data_loader, sample_idx) - - # === 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}") - - # TODO(vividf): remove this since torchscript is deprecated - # 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) - - # # TODO(vividf): this can be removed after the numerical consistency issue is resolved - # def save_onnx_with_intermediate_outputs(self, save_dir: str, onnx_opset_version: int = 13, verbose: bool = False): - # """Export CenterPoint model to ONNX format with intermediate outputs for debugging.""" - # import os - # import torch.onnx - - # print_log(f"Running onnx_opset_version: {onnx_opset_version}") - # print_log("Exporting with intermediate outputs for debugging...") - - # # Create output directory - # os.makedirs(save_dir, exist_ok=True) - - # # 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) - - # # === Create backbone with intermediate outputs === - # class BackboneWithIntermediateOutputs(torch.nn.Module): - # def __init__(self, backbone): - # super().__init__() - # self.backbone = backbone - - # def forward(self, x): - # outs = [] - # for i in range(len(self.backbone.blocks)): - # x = self.backbone.blocks[i](x) - # outs.append(x) - # return tuple(outs) - - # backbone_with_outputs = BackboneWithIntermediateOutputs(self.pts_backbone) - - # # Export backbone with intermediate outputs - # pth_onnx_backbone = os.path.join(save_dir, "pts_backbone_with_intermediate.onnx") - # torch.onnx.export( - # backbone_with_outputs, - # (x,), - # f=pth_onnx_backbone, - # input_names=("spatial_features",), - # output_names=("stage_0", "stage_1", "stage_2"), - # dynamic_axes={ - # "spatial_features": {0: "batch_size", 2: "H", 3: "W"}, - # "stage_0": {0: "batch_size", 2: "H", 3: "W"}, - # "stage_1": {0: "batch_size", 2: "H", 3: "W"}, - # "stage_2": {0: "batch_size", 2: "H", 3: "W"}, - # }, - # verbose=verbose, - # opset_version=onnx_opset_version, - # do_constant_folding=True, - # ) - # print_log(f"Saved pts_backbone with intermediate outputs: {pth_onnx_backbone}") - - # return save_dir From 31aedc0d7442230315ac67f7f30adf99c016248a Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 28 Nov 2025 17:48:16 +0900 Subject: [PATCH 43/62] chore: fix import Signed-off-by: vividf --- deployment/pipelines/centerpoint/centerpoint_pipeline.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/deployment/pipelines/centerpoint/centerpoint_pipeline.py b/deployment/pipelines/centerpoint/centerpoint_pipeline.py index 3fa9001ae..b80b8720a 100644 --- a/deployment/pipelines/centerpoint/centerpoint_pipeline.py +++ b/deployment/pipelines/centerpoint/centerpoint_pipeline.py @@ -9,10 +9,10 @@ import logging import time from abc import abstractmethod -from typing import Any, Dict, List, Tuple +from typing import Dict, List, Tuple -import numpy as np import torch +from mmdet3d.structures import Det3DDataSample, LiDARInstance3DBoxes from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline @@ -95,7 +95,6 @@ def preprocess(self, points: torch.Tensor, **kwargs) -> Tuple[Dict[str, torch.Te - 'coors': Voxel coordinates [N_voxels, 4] (batch_idx, z, y, x) - metadata: Empty dict (for compatibility with base class) """ - from mmdet3d.structures import Det3DDataSample # Ensure points are on correct device points_tensor = points.to(self.device) @@ -197,7 +196,6 @@ def postprocess(self, head_outputs: List[torch.Tensor], sample_meta: Dict) -> Li preds_dicts = ([preds_dict],) # Tuple[List[dict]] format # Prepare metadata - from mmdet3d.structures import LiDARInstance3DBoxes if "box_type_3d" not in sample_meta: sample_meta["box_type_3d"] = LiDARInstance3DBoxes From 5eaebf12732cfcf3e1cd2c137ddb5226de291e17 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 1 Dec 2025 16:43:15 +0900 Subject: [PATCH 44/62] chore: remove unused function Signed-off-by: vividf --- projects/CenterPoint/deploy/data_loader.py | 23 +--------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/projects/CenterPoint/deploy/data_loader.py b/projects/CenterPoint/deploy/data_loader.py index b628fd8eb..07710a1af 100644 --- a/projects/CenterPoint/deploy/data_loader.py +++ b/projects/CenterPoint/deploy/data_loader.py @@ -257,27 +257,6 @@ def preprocess(self, sample: Dict[str, Any]) -> Union[Dict[str, torch.Tensor], t return {"points": points_tensor} - 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, 4) where 4 = (x, y, z, intensity) - """ - if lidar_path.endswith(".bin"): - # Load binary point cloud (KITTI/nuScenes format) - points = np.fromfile(lidar_path, dtype=np.float32).reshape(-1, 4) - 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 get_num_samples(self) -> int: """ Get total number of samples. @@ -329,4 +308,4 @@ def get_class_names(self) -> list: return self.model_cfg.class_names # Default for T4Dataset - return ["VEHICLE", "PEDESTRIAN", "CYCLIST"] + return ["car", "truck", "bus", "bicycle", "pedestrian"] From 2f97ad55ad9455a3cef801929a00943a76799312 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 1 Dec 2025 17:21:45 +0900 Subject: [PATCH 45/62] chore: clean code Signed-off-by: vividf --- deployment/exporters/centerpoint/__init__.py | 22 ++++- deployment/exporters/centerpoint/constants.py | 38 ++++++++ .../exporters/centerpoint/model_wrappers.py | 15 ---- .../exporters/centerpoint/onnx_workflow.py | 9 +- .../centerpoint/tensorrt_workflow.py | 59 ++++++++++--- .../runners/projects/centerpoint_runner.py | 12 +-- .../CenterPoint/deploy/component_extractor.py | 22 +++-- projects/CenterPoint/deploy/constants.py | 27 ++++++ projects/CenterPoint/deploy/data_loader.py | 87 ++++++++++++------- projects/CenterPoint/deploy/evaluator.py | 12 ++- projects/CenterPoint/deploy/main.py | 2 - projects/CenterPoint/deploy/utils.py | 8 +- 12 files changed, 225 insertions(+), 88 deletions(-) create mode 100644 deployment/exporters/centerpoint/constants.py delete mode 100644 deployment/exporters/centerpoint/model_wrappers.py create mode 100644 projects/CenterPoint/deploy/constants.py diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py index 3c6ea8053..0f7e432f6 100644 --- a/deployment/exporters/centerpoint/__init__.py +++ b/deployment/exporters/centerpoint/__init__.py @@ -1,11 +1,27 @@ -"""CenterPoint-specific exporter workflows and model wrappers.""" +"""CenterPoint-specific exporter workflows and constants.""" -from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper +from deployment.exporters.centerpoint.constants import ( + BACKBONE_HEAD_ENGINE, + BACKBONE_HEAD_NAME, + BACKBONE_HEAD_ONNX, + ONNX_TO_TRT_MAPPINGS, + VOXEL_ENCODER_ENGINE, + VOXEL_ENCODER_NAME, + VOXEL_ENCODER_ONNX, +) from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow __all__ = [ - "CenterPointONNXWrapper", + # Workflows "CenterPointONNXExportWorkflow", "CenterPointTensorRTExportWorkflow", + # Constants + "VOXEL_ENCODER_NAME", + "BACKBONE_HEAD_NAME", + "VOXEL_ENCODER_ONNX", + "BACKBONE_HEAD_ONNX", + "VOXEL_ENCODER_ENGINE", + "BACKBONE_HEAD_ENGINE", + "ONNX_TO_TRT_MAPPINGS", ] diff --git a/deployment/exporters/centerpoint/constants.py b/deployment/exporters/centerpoint/constants.py new file mode 100644 index 000000000..bff07cd15 --- /dev/null +++ b/deployment/exporters/centerpoint/constants.py @@ -0,0 +1,38 @@ +""" +Constants for CenterPoint export workflows. + +These constants define the export file structure for CenterPoint models. +They are kept in the deployment package since they are part of the +export interface, not project-specific configuration. +""" + +from typing import Tuple + +# CenterPoint component names for multi-file ONNX export +# These match the model architecture (voxel encoder + backbone/neck/head) +VOXEL_ENCODER_NAME: str = "pts_voxel_encoder" +BACKBONE_HEAD_NAME: str = "pts_backbone_neck_head" + +# ONNX file names +VOXEL_ENCODER_ONNX: str = f"{VOXEL_ENCODER_NAME}.onnx" +BACKBONE_HEAD_ONNX: str = f"{BACKBONE_HEAD_NAME}.onnx" + +# TensorRT engine file names +VOXEL_ENCODER_ENGINE: str = f"{VOXEL_ENCODER_NAME}.engine" +BACKBONE_HEAD_ENGINE: str = f"{BACKBONE_HEAD_NAME}.engine" + +# Ordered list of ONNX to TensorRT file mappings +ONNX_TO_TRT_MAPPINGS: Tuple[Tuple[str, str], ...] = ( + (VOXEL_ENCODER_ONNX, VOXEL_ENCODER_ENGINE), + (BACKBONE_HEAD_ONNX, BACKBONE_HEAD_ENGINE), +) + +__all__ = [ + "VOXEL_ENCODER_NAME", + "BACKBONE_HEAD_NAME", + "VOXEL_ENCODER_ONNX", + "BACKBONE_HEAD_ONNX", + "VOXEL_ENCODER_ENGINE", + "BACKBONE_HEAD_ENGINE", + "ONNX_TO_TRT_MAPPINGS", +] diff --git a/deployment/exporters/centerpoint/model_wrappers.py b/deployment/exporters/centerpoint/model_wrappers.py deleted file mode 100644 index 590a137eb..000000000 --- a/deployment/exporters/centerpoint/model_wrappers.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -CenterPoint-specific model wrappers for ONNX export. - -CenterPoint models don't require special output format conversion, -so we use IdentityWrapper (no modification to model output). -""" - -from deployment.exporters.common.model_wrappers import BaseModelWrapper, IdentityWrapper - -# CenterPoint doesn't need special wrapper, use IdentityWrapper -CenterPointONNXWrapper = IdentityWrapper - -__all__ = [ - "CenterPointONNXWrapper", -] diff --git a/deployment/exporters/centerpoint/onnx_workflow.py b/deployment/exporters/centerpoint/onnx_workflow.py index c7d4480e1..dd5e4368f 100644 --- a/deployment/exporters/centerpoint/onnx_workflow.py +++ b/deployment/exporters/centerpoint/onnx_workflow.py @@ -79,9 +79,14 @@ def export( Returns: Artifact pointing to output directory with multi_file=True + + Raises: + AttributeError: If component extractor doesn't have extract_features method + RuntimeError: If feature extraction or export fails """ - # context available for future extensions - _ = context + # Note: context available for future extensions (e.g., precision hints, debug flags) + del context # Explicitly unused + # Ensure output directory exists os.makedirs(output_dir, exist_ok=True) diff --git a/deployment/exporters/centerpoint/tensorrt_workflow.py b/deployment/exporters/centerpoint/tensorrt_workflow.py index 173b9a3b7..9c6259b4f 100644 --- a/deployment/exporters/centerpoint/tensorrt_workflow.py +++ b/deployment/exporters/centerpoint/tensorrt_workflow.py @@ -9,12 +9,14 @@ import logging import os -from typing import Optional +import re +from typing import Optional, Tuple import torch from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig from deployment.core.contexts import ExportContext +from deployment.exporters.centerpoint.constants import ONNX_TO_TRT_MAPPINGS from deployment.exporters.common.factory import ExporterFactory from deployment.exporters.workflows.base import TensorRTExportWorkflow @@ -26,13 +28,19 @@ class CenterPointTensorRTExportWorkflow(TensorRTExportWorkflow): Converts CenterPoint ONNX files to multiple TensorRT engines: - pts_voxel_encoder.onnx → pts_voxel_encoder.engine - pts_backbone_neck_head.onnx → pts_backbone_neck_head.engine + + Uses TENSORRT_FILE_MAPPINGS from constants module for file name configuration. """ + # Pattern for validating CUDA device strings + _CUDA_DEVICE_PATTERN = re.compile(r"^cuda:\d+$") + def __init__( self, exporter_factory: type[ExporterFactory], config: BaseDeploymentConfig, logger: Optional[logging.Logger] = None, + file_mappings: Optional[Tuple[Tuple[str, str], ...]] = None, ): """ Initialize CenterPoint TensorRT export workflow. @@ -41,10 +49,32 @@ def __init__( exporter_factory: Factory class for creating exporters config: Deployment configuration logger: Optional logger instance + file_mappings: Optional tuple of (onnx_file, engine_file) pairs. + Defaults to ONNX_TO_TRT_MAPPINGS from exporter constants. """ self.exporter_factory = exporter_factory self.config = config self.logger = logger or logging.getLogger(__name__) + self.file_mappings = file_mappings or ONNX_TO_TRT_MAPPINGS + + def _validate_cuda_device(self, device: str) -> int: + """ + Validate CUDA device string and extract device ID. + + Args: + device: Device string (expected format: "cuda:N") + + Returns: + Device ID as integer + + Raises: + ValueError: If device format is invalid + """ + if not self._CUDA_DEVICE_PATTERN.match(device): + raise ValueError( + f"Invalid CUDA device format: '{device}'. " f"Expected format: 'cuda:N' (e.g., 'cuda:0', 'cuda:1')" + ) + return int(device.split(":")[1]) def export( self, @@ -70,9 +100,15 @@ def export( Returns: Artifact pointing to output directory with multi_file=True + + Raises: + ValueError: If device format is invalid or onnx_path is not a directory + FileNotFoundError: If ONNX files are missing + RuntimeError: If TensorRT conversion fails """ - # context available for future extensions - _ = context + # Note: context available for future extensions (e.g., precision hints) + del context # Explicitly unused + onnx_dir = onnx_path # Validate inputs @@ -85,22 +121,17 @@ def export( if not os.path.isdir(onnx_dir): raise ValueError(f"onnx_path must be a directory for multi-file export, got: {onnx_dir}") - # Set CUDA device - device_id = int(device.split(":", 1)[1]) + # Validate and set CUDA device + device_id = self._validate_cuda_device(device) torch.cuda.set_device(device_id) self.logger.info(f"Using CUDA device: {device}") # Create output directory os.makedirs(output_dir, exist_ok=True) - # Define ONNX → TensorRT file pairs - onnx_files = [ - ("pts_voxel_encoder.onnx", "pts_voxel_encoder.engine"), - ("pts_backbone_neck_head.onnx", "pts_backbone_neck_head.engine"), - ] - - # Convert each ONNX file to TensorRT - for i, (onnx_file, trt_file) in enumerate(onnx_files, 1): + # Convert each ONNX file to TensorRT using configured file mappings + num_files = len(self.file_mappings) + for i, (onnx_file, trt_file) in enumerate(self.file_mappings, 1): onnx_file_path = os.path.join(onnx_dir, onnx_file) trt_path = os.path.join(output_dir, trt_file) @@ -108,7 +139,7 @@ def export( if not os.path.exists(onnx_file_path): raise FileNotFoundError(f"ONNX file not found: {onnx_file_path}") - self.logger.info(f"\n[{i}/{len(onnx_files)}] Converting {onnx_file} to TensorRT...") + self.logger.info(f"\n[{i}/{num_files}] Converting {onnx_file} to TensorRT...") # Create fresh exporter (no caching) exporter = self.exporter_factory.create_tensorrt_exporter(config=self.config, logger=self.logger) diff --git a/deployment/runners/projects/centerpoint_runner.py b/deployment/runners/projects/centerpoint_runner.py index c151bfe12..c37e029a4 100644 --- a/deployment/runners/projects/centerpoint_runner.py +++ b/deployment/runners/projects/centerpoint_runner.py @@ -8,10 +8,10 @@ from typing import Any from deployment.core.contexts import CenterPointExportContext, ExportContext -from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow from deployment.exporters.common.factory import ExporterFactory +from deployment.exporters.common.model_wrappers import IdentityWrapper from deployment.runners.common.deployment_runner import BaseDeploymentRunner from projects.CenterPoint.deploy.component_extractor import CenterPointComponentExtractor from projects.CenterPoint.deploy.utils import build_centerpoint_onnx_model @@ -40,7 +40,6 @@ def __init__( config, model_cfg, logger: logging.Logger, - onnx_wrapper_cls=None, onnx_workflow=None, tensorrt_workflow=None, ): @@ -53,22 +52,25 @@ def __init__( config: Deployment configuration model_cfg: Model configuration logger: Logger instance - onnx_wrapper_cls: Optional ONNX wrapper (defaults to CenterPointONNXWrapper) onnx_workflow: Optional custom ONNX workflow tensorrt_workflow: Optional custom TensorRT workflow + + Note: + CenterPoint uses IdentityWrapper directly since no special + output format conversion is needed for ONNX export. """ # Create component extractor for model-specific logic simplify_onnx = config.get_onnx_settings().simplify component_extractor = CenterPointComponentExtractor(logger=logger, simplify=simplify_onnx) - # Initialize base runner + # Initialize base runner with IdentityWrapper super().__init__( data_loader=data_loader, evaluator=evaluator, config=config, model_cfg=model_cfg, logger=logger, - onnx_wrapper_cls=onnx_wrapper_cls or CenterPointONNXWrapper, + onnx_wrapper_cls=IdentityWrapper, onnx_workflow=onnx_workflow, tensorrt_workflow=tensorrt_workflow, ) diff --git a/projects/CenterPoint/deploy/component_extractor.py b/projects/CenterPoint/deploy/component_extractor.py index 87e267b45..b2ff89457 100644 --- a/projects/CenterPoint/deploy/component_extractor.py +++ b/projects/CenterPoint/deploy/component_extractor.py @@ -11,8 +11,16 @@ import torch +from deployment.exporters.centerpoint.constants import ( + BACKBONE_HEAD_NAME, + BACKBONE_HEAD_ONNX, + VOXEL_ENCODER_NAME, + VOXEL_ENCODER_ONNX, +) from deployment.exporters.common.configs import ONNXExportConfig from deployment.exporters.workflows.interfaces import ExportableComponent, ModelComponentExtractor +from projects.CenterPoint.deploy.constants import OUTPUT_NAMES +from projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX logger = logging.getLogger(__name__) @@ -74,7 +82,7 @@ def _create_voxel_encoder_component( ) -> ExportableComponent: """Create exportable voxel encoder component.""" return ExportableComponent( - name="pts_voxel_encoder", + name=VOXEL_ENCODER_NAME, module=model.pts_voxel_encoder, sample_input=input_features, config_override=ONNXExportConfig( @@ -87,7 +95,7 @@ def _create_voxel_encoder_component( opset_version=16, do_constant_folding=True, simplify=self.simplify, - save_file="pts_voxel_encoder.onnx", + save_file=VOXEL_ENCODER_ONNX, ), ) @@ -111,7 +119,7 @@ def _create_backbone_component( dynamic_axes[name] = {0: "batch_size", 2: "height", 3: "width"} return ExportableComponent( - name="pts_backbone_neck_head", + name=BACKBONE_HEAD_NAME, module=backbone_module, sample_input=backbone_input, config_override=ONNXExportConfig( @@ -121,7 +129,7 @@ def _create_backbone_component( opset_version=16, do_constant_folding=True, simplify=self.simplify, - save_file="pts_backbone_neck_head.onnx", + save_file=BACKBONE_HEAD_ONNX, ), ) @@ -151,21 +159,19 @@ def _create_backbone_module(self, model: torch.nn.Module) -> torch.nn.Module: """ Create combined backbone+neck+head module for ONNX export. - This imports CenterPoint-specific model classes from projects/. """ - from projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX return CenterPointHeadONNX(model.pts_backbone, model.pts_neck, model.pts_bbox_head) def _get_output_names(self, model: torch.nn.Module) -> Tuple[str, ...]: - """Get output names from model or use defaults.""" + """Get output names from model or use defaults from constants.""" 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 ("heatmap", "reg", "height", "dim", "rot", "vel") + return OUTPUT_NAMES def extract_features(self, model: torch.nn.Module, data_loader: Any, sample_idx: int) -> Tuple[torch.Tensor, dict]: """ diff --git a/projects/CenterPoint/deploy/constants.py b/projects/CenterPoint/deploy/constants.py new file mode 100644 index 000000000..63e97b3f7 --- /dev/null +++ b/projects/CenterPoint/deploy/constants.py @@ -0,0 +1,27 @@ +""" +Shared constants for CenterPoint deployment. + +This module centralizes default values used across the CenterPoint deployment +codebase. Note that many values (like class_names) should come from the +model config and should raise errors if missing - those are NOT defined here. + +Note: + This module only contains truly optional defaults. + Export-related constants (file names, component names) are defined in + deployment/exporters/centerpoint/ to maintain proper dependency direction. +""" + +from typing import Tuple + +# Default frame ID for evaluation metrics +# This is a reasonable default since most configs use "base_link" +DEFAULT_FRAME_ID: str = "base_link" + +# CenterPoint head output names (tied to model architecture) +# These are architectural constants, not dataset-dependent +OUTPUT_NAMES: Tuple[str, ...] = ("heatmap", "reg", "height", "dim", "rot", "vel") + +__all__ = [ + "DEFAULT_FRAME_ID", + "OUTPUT_NAMES", +] diff --git a/projects/CenterPoint/deploy/data_loader.py b/projects/CenterPoint/deploy/data_loader.py index 07710a1af..c19dd8eae 100644 --- a/projects/CenterPoint/deploy/data_loader.py +++ b/projects/CenterPoint/deploy/data_loader.py @@ -7,7 +7,7 @@ import os import pickle -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union import numpy as np import torch @@ -72,6 +72,49 @@ def __init__( # task_type should be provided from deploy_config 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: + """ + Convert various data types to a torch.Tensor on the target device. + + Args: + data: Input data (torch.Tensor, np.ndarray, or list of either) + name: Name of the data for error messages + + Returns: + torch.Tensor on self.device + + Raises: + ValueError: If data type is unsupported or list is empty + """ + 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)}. " f"Expected torch.Tensor or np.ndarray." + ) + + raise ValueError( + f"Unexpected type for '{name}': {type(data)}. " + f"Expected torch.Tensor, np.ndarray, or list of tensors/arrays." + ) + def _load_info_file(self) -> list: """ Load and parse info.pkl file. @@ -224,36 +267,12 @@ def preprocess(self, sample: Dict[str, Any]) -> Union[Dict[str, torch.Tensor], t f"not in the test pipeline. The pipeline should output raw points using Pack3DDetInputs." ) - # Extract points - points = pipeline_inputs["points"] - if isinstance(points, torch.Tensor): - points_tensor = points.to(self.device) - elif isinstance(points, np.ndarray): - points_tensor = torch.from_numpy(points).to(self.device) - elif isinstance(points, list): - # Handle list of point clouds (batch format) - if len(points) > 0: - if isinstance(points[0], torch.Tensor): - points_tensor = points[0].to(self.device) - elif isinstance(points[0], np.ndarray): - points_tensor = torch.from_numpy(points[0]).to(self.device) - else: - raise ValueError( - f"Unexpected type for points[0]: {type(points[0])}. " f"Expected torch.Tensor or np.ndarray." - ) - else: - raise ValueError("Empty points list in pipeline output.") - else: - raise ValueError( - f"Unexpected type for 'points': {type(points)}. " - f"Expected torch.Tensor, np.ndarray, or list of tensors/arrays." - ) + # Convert points to tensor using helper + points_tensor = self._to_tensor(pipeline_inputs["points"], name="points") # Validate points shape if points_tensor.ndim != 2: - raise ValueError( - f"Expected points tensor with shape [N, point_features], " f"got shape {points_tensor.shape}" - ) + raise ValueError(f"Expected points tensor with shape [N, point_features], got shape {points_tensor.shape}") return {"points": points_tensor} @@ -296,16 +315,20 @@ def get_ground_truth(self, index: int) -> Dict[str, Any]: "sample_idx": sample.get("sample_idx", index), } - def get_class_names(self) -> list: + def get_class_names(self) -> List[str]: """ Get class names from config. Returns: List of class names + + Raises: + ValueError: If class_names not found in model_cfg """ - # Try to get from model config if hasattr(self.model_cfg, "class_names"): return self.model_cfg.class_names - # Default for T4Dataset - return ["car", "truck", "bus", "bicycle", "pedestrian"] + raise ValueError( + "class_names must be defined in model_cfg. " + "Check your model config file includes class_names definition." + ) diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index b330b8510..42b7ad67f 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -22,6 +22,7 @@ ) from deployment.core.io.base_data_loader import BaseDataLoader from deployment.pipelines import PipelineFactory +from projects.CenterPoint.deploy.constants import DEFAULT_FRAME_ID, OUTPUT_NAMES logger = logging.getLogger(__name__) @@ -51,13 +52,16 @@ def __init__( class_names: List of class names (optional). metrics_config: Optional configuration for the metrics adapter. """ - # Determine class names + # Determine class names - must come from config or explicit parameter if class_names is not None: names = class_names elif hasattr(model_cfg, "class_names"): names = model_cfg.class_names else: - names = ["car", "truck", "bus", "bicycle", "pedestrian"] + raise ValueError( + "class_names must be provided either explicitly or via model_cfg.class_names. " + "Check your model config file includes class_names definition." + ) # Create task profile task_profile = TaskProfile( @@ -71,7 +75,7 @@ def __init__( if metrics_config is None: metrics_config = Detection3DMetricsConfig( class_names=list(names), - frame_id="base_link", + frame_id=DEFAULT_FRAME_ID, ) metrics_adapter = Detection3DMetricsAdapter(metrics_config) @@ -89,7 +93,7 @@ def set_onnx_config(self, model_cfg: Config) -> None: def _get_output_names(self) -> List[str]: """Provide meaningful names for CenterPoint head outputs.""" - return ["heatmap", "reg", "height", "dim", "rot", "vel"] + return list(OUTPUT_NAMES) # ================== BaseEvaluator Implementation ================== diff --git a/projects/CenterPoint/deploy/main.py b/projects/CenterPoint/deploy/main.py index 866cd5b31..d0d966780 100644 --- a/projects/CenterPoint/deploy/main.py +++ b/projects/CenterPoint/deploy/main.py @@ -19,7 +19,6 @@ from deployment.core import BaseDeploymentConfig, setup_logging from deployment.core.config.base_config import parse_base_args from deployment.core.contexts import CenterPointExportContext -from deployment.exporters.centerpoint.model_wrappers import CenterPointONNXWrapper from deployment.runners import CenterPointDeploymentRunner from projects.CenterPoint.deploy.data_loader import CenterPointDataLoader from projects.CenterPoint.deploy.evaluator import CenterPointEvaluator @@ -112,7 +111,6 @@ def main(): config=config, model_cfg=model_cfg, # original cfg; runner will convert to ONNX cfg in load_pytorch_model() logger=logger, - onnx_wrapper_cls=CenterPointONNXWrapper, ) # Execute deployment workflow with typed context diff --git a/projects/CenterPoint/deploy/utils.py b/projects/CenterPoint/deploy/utils.py index 506cd0aec..3c40bde7f 100644 --- a/projects/CenterPoint/deploy/utils.py +++ b/projects/CenterPoint/deploy/utils.py @@ -143,13 +143,15 @@ def extract_t4metric_v2_config( if logger is None: logger = logging.getLogger(__name__) - # Get class names + # Get class names - must come from config or explicit parameter if class_names is None: if hasattr(model_cfg, "class_names"): class_names = model_cfg.class_names else: - # Default for T4Dataset - class_names = ["car", "truck", "bus", "bicycle", "pedestrian"] + raise ValueError( + "class_names must be provided either explicitly or via model_cfg.class_names. " + "Check your model config file includes class_names definition." + ) # Try to extract T4MetricV2 configs from val_evaluator or test_evaluator evaluator_cfg = None From c4b270f6459838a68d0ffff4e7828cc1c32f2ed6 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 1 Dec 2025 17:30:44 +0900 Subject: [PATCH 46/62] chore: remove context from workflow Signed-off-by: vividf --- deployment/exporters/centerpoint/onnx_workflow.py | 7 ------- deployment/exporters/centerpoint/tensorrt_workflow.py | 7 ------- deployment/exporters/workflows/base.py | 10 +--------- deployment/runners/common/export_orchestrator.py | 2 -- 4 files changed, 1 insertion(+), 25 deletions(-) diff --git a/deployment/exporters/centerpoint/onnx_workflow.py b/deployment/exporters/centerpoint/onnx_workflow.py index dd5e4368f..60cf6361a 100644 --- a/deployment/exporters/centerpoint/onnx_workflow.py +++ b/deployment/exporters/centerpoint/onnx_workflow.py @@ -15,7 +15,6 @@ import torch from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig -from deployment.core.contexts import ExportContext from deployment.exporters.common.factory import ExporterFactory from deployment.exporters.common.model_wrappers import IdentityWrapper from deployment.exporters.workflows.base import OnnxExportWorkflow @@ -63,7 +62,6 @@ def export( output_dir: str, config: BaseDeploymentConfig, sample_idx: int = 0, - context: Optional[ExportContext] = None, ) -> Artifact: """ Export CenterPoint model to multi-file ONNX format. @@ -74,8 +72,6 @@ def export( output_dir: Output directory for ONNX files config: Deployment configuration (not used, kept for interface) sample_idx: Sample index to use for feature extraction - context: Export context with project-specific parameters (currently unused, - but available for future extensions) Returns: Artifact pointing to output directory with multi_file=True @@ -84,9 +80,6 @@ def export( AttributeError: If component extractor doesn't have extract_features method RuntimeError: If feature extraction or export fails """ - # Note: context available for future extensions (e.g., precision hints, debug flags) - del context # Explicitly unused - # Ensure output directory exists os.makedirs(output_dir, exist_ok=True) diff --git a/deployment/exporters/centerpoint/tensorrt_workflow.py b/deployment/exporters/centerpoint/tensorrt_workflow.py index 9c6259b4f..954fbcc3a 100644 --- a/deployment/exporters/centerpoint/tensorrt_workflow.py +++ b/deployment/exporters/centerpoint/tensorrt_workflow.py @@ -15,7 +15,6 @@ import torch from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig -from deployment.core.contexts import ExportContext from deployment.exporters.centerpoint.constants import ONNX_TO_TRT_MAPPINGS from deployment.exporters.common.factory import ExporterFactory from deployment.exporters.workflows.base import TensorRTExportWorkflow @@ -84,7 +83,6 @@ def export( config: BaseDeploymentConfig, device: str, data_loader: BaseDataLoader, - context: Optional[ExportContext] = None, ) -> Artifact: """ Export CenterPoint ONNX files to TensorRT engines. @@ -95,8 +93,6 @@ def export( config: Deployment configuration (not used, kept for interface) device: CUDA device string (e.g., "cuda:0") data_loader: Data loader (not used for TensorRT) - context: Export context with project-specific parameters (currently unused, - but available for future extensions) Returns: Artifact pointing to output directory with multi_file=True @@ -106,9 +102,6 @@ def export( FileNotFoundError: If ONNX files are missing RuntimeError: If TensorRT conversion fails """ - # Note: context available for future extensions (e.g., precision hints) - del context # Explicitly unused - onnx_dir = onnx_path # Validate inputs diff --git a/deployment/exporters/workflows/base.py b/deployment/exporters/workflows/base.py index ce278cac6..4deed15fa 100644 --- a/deployment/exporters/workflows/base.py +++ b/deployment/exporters/workflows/base.py @@ -5,11 +5,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Optional +from typing import Any from deployment.core.artifacts import Artifact from deployment.core.config.base_config import BaseDeploymentConfig -from deployment.core.contexts import ExportContext from deployment.core.io.base_data_loader import BaseDataLoader @@ -27,7 +26,6 @@ def export( output_dir: str, config: BaseDeploymentConfig, sample_idx: int = 0, - context: Optional[ExportContext] = None, ) -> Artifact: """ Execute the ONNX export workflow and return the produced artifact. @@ -38,9 +36,6 @@ def export( output_dir: Directory for output files config: Deployment configuration sample_idx: Sample index for tracing - context: Typed export context with project-specific parameters. - Use project-specific context subclasses (e.g., CenterPointExportContext) - for type-safe access to parameters. Returns: Artifact describing the exported ONNX output @@ -61,7 +56,6 @@ def export( config: BaseDeploymentConfig, device: str, data_loader: BaseDataLoader, - context: Optional[ExportContext] = None, ) -> Artifact: """ Execute the TensorRT export workflow and return the produced artifact. @@ -72,8 +66,6 @@ def export( config: Deployment configuration device: CUDA device string data_loader: Data loader for samples - context: Typed export context with project-specific parameters. - Use project-specific context subclasses for type-safe access. Returns: Artifact describing the exported TensorRT output diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index 229c9c0a6..a0fd36d77 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -376,7 +376,6 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A output_dir=onnx_dir, config=self.config, sample_idx=sample_idx, - context=context, ) except Exception: self.logger.exception("ONNX export workflow failed") @@ -481,7 +480,6 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A config=self.config, device=cuda_device, data_loader=self.data_loader, - context=context, ) except Exception: self.logger.exception("TensorRT export workflow failed") From ead0d8e22b31768049fb11671a87a2f11fdae927 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 1 Dec 2025 17:57:56 +0900 Subject: [PATCH 47/62] chore: clean more code Signed-off-by: vividf --- deployment/exporters/centerpoint/__init__.py | 5 +++- deployment/exporters/centerpoint/constants.py | 21 ++++++++++++--- .../CenterPoint/deploy/component_extractor.py | 2 +- .../deploy/configs/deploy_config.py | 18 +++++-------- projects/CenterPoint/deploy/constants.py | 27 ------------------- projects/CenterPoint/deploy/evaluator.py | 10 +++---- 6 files changed, 34 insertions(+), 49 deletions(-) delete mode 100644 projects/CenterPoint/deploy/constants.py diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py index 0f7e432f6..07bcfaac9 100644 --- a/deployment/exporters/centerpoint/__init__.py +++ b/deployment/exporters/centerpoint/__init__.py @@ -5,6 +5,7 @@ BACKBONE_HEAD_NAME, BACKBONE_HEAD_ONNX, ONNX_TO_TRT_MAPPINGS, + OUTPUT_NAMES, VOXEL_ENCODER_ENGINE, VOXEL_ENCODER_NAME, VOXEL_ENCODER_ONNX, @@ -16,7 +17,9 @@ # Workflows "CenterPointONNXExportWorkflow", "CenterPointTensorRTExportWorkflow", - # Constants + # Model architecture constants + "OUTPUT_NAMES", + # Export file structure constants "VOXEL_ENCODER_NAME", "BACKBONE_HEAD_NAME", "VOXEL_ENCODER_ONNX", diff --git a/deployment/exporters/centerpoint/constants.py b/deployment/exporters/centerpoint/constants.py index bff07cd15..80f5ac432 100644 --- a/deployment/exporters/centerpoint/constants.py +++ b/deployment/exporters/centerpoint/constants.py @@ -1,13 +1,25 @@ """ Constants for CenterPoint export workflows. -These constants define the export file structure for CenterPoint models. -They are kept in the deployment package since they are part of the -export interface, not project-specific configuration. +These constants define the export file structure and model architecture +for CenterPoint models. They are kept in the deployment package since +they are part of the export interface. """ from typing import Tuple +# ============================================================================= +# Model Architecture Constants +# ============================================================================= + +# CenterPoint head output names (tied to CenterHead architecture) +# Order matters for ONNX export +OUTPUT_NAMES: Tuple[str, ...] = ("heatmap", "reg", "height", "dim", "rot", "vel") + +# ============================================================================= +# Export File Structure Constants +# ============================================================================= + # CenterPoint component names for multi-file ONNX export # These match the model architecture (voxel encoder + backbone/neck/head) VOXEL_ENCODER_NAME: str = "pts_voxel_encoder" @@ -28,6 +40,9 @@ ) __all__ = [ + # Model architecture + "OUTPUT_NAMES", + # Export file structure "VOXEL_ENCODER_NAME", "BACKBONE_HEAD_NAME", "VOXEL_ENCODER_ONNX", diff --git a/projects/CenterPoint/deploy/component_extractor.py b/projects/CenterPoint/deploy/component_extractor.py index b2ff89457..b66f76de8 100644 --- a/projects/CenterPoint/deploy/component_extractor.py +++ b/projects/CenterPoint/deploy/component_extractor.py @@ -14,12 +14,12 @@ from deployment.exporters.centerpoint.constants import ( BACKBONE_HEAD_NAME, BACKBONE_HEAD_ONNX, + OUTPUT_NAMES, VOXEL_ENCODER_NAME, VOXEL_ENCODER_ONNX, ) from deployment.exporters.common.configs import ONNXExportConfig from deployment.exporters.workflows.interfaces import ExportableComponent, ModelComponentExtractor -from projects.CenterPoint.deploy.constants import OUTPUT_NAMES from projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX logger = logging.getLogger(__name__) diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py index 75864b10b..89731eea6 100644 --- a/projects/CenterPoint/deploy/configs/deploy_config.py +++ b/projects/CenterPoint/deploy/configs/deploy_config.py @@ -1,10 +1,5 @@ """ -CenterPoint Deployment Configuration (v2). - -This config is designed to: -- Make export mode behavior explicit and easy to reason about. -- Separate "what to do" (mode, which backends) from "how to do it" (paths, devices). -- Make verification & evaluation rules depend on export.mode without hardcoding them in code. +CenterPoint Deployment Configuration """ # ============================================================================ @@ -39,7 +34,7 @@ # - 'trt' : build TensorRT engine from an existing ONNX # - 'both' : export PyTorch -> ONNX -> TensorRT # - 'none' : no export (only evaluation / verification on existing artifacts) - mode="none", + mode="both", # ---- Common options ---------------------------------------------------- work_dir="work_dirs/centerpoint_deployment", # ---- ONNX source when building TensorRT only --------------------------- @@ -90,16 +85,17 @@ # ============================================================================ # ONNX Export Configuration # ============================================================================ +# Note: CenterPoint uses multi-file ONNX export (voxel encoder + backbone/head). +# File names are defined by the workflow, not by save_file. +# See deployment/exporters/centerpoint/constants.py for file name definitions. onnx_config = dict( opset_version=16, do_constant_folding=True, - save_file="centerpoint.onnx", export_params=True, keep_initializers_as_inputs=False, simplify=False, - # CenterPoint uses multi-file ONNX (voxel encoder + backbone/head) - # When True, model_path should be a directory containing multiple .onnx files - # When False (default), model_path should be a single .onnx file + # multi_file=True means the export produces a directory with multiple .onnx files + # File names are controlled by CenterPointONNXExportWorkflow, not by save_file multi_file=True, ) diff --git a/projects/CenterPoint/deploy/constants.py b/projects/CenterPoint/deploy/constants.py deleted file mode 100644 index 63e97b3f7..000000000 --- a/projects/CenterPoint/deploy/constants.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Shared constants for CenterPoint deployment. - -This module centralizes default values used across the CenterPoint deployment -codebase. Note that many values (like class_names) should come from the -model config and should raise errors if missing - those are NOT defined here. - -Note: - This module only contains truly optional defaults. - Export-related constants (file names, component names) are defined in - deployment/exporters/centerpoint/ to maintain proper dependency direction. -""" - -from typing import Tuple - -# Default frame ID for evaluation metrics -# This is a reasonable default since most configs use "base_link" -DEFAULT_FRAME_ID: str = "base_link" - -# CenterPoint head output names (tied to model architecture) -# These are architectural constants, not dataset-dependent -OUTPUT_NAMES: Tuple[str, ...] = ("heatmap", "reg", "height", "dim", "rot", "vel") - -__all__ = [ - "DEFAULT_FRAME_ID", - "OUTPUT_NAMES", -] diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index 42b7ad67f..9e623c198 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -21,8 +21,8 @@ TaskProfile, ) from deployment.core.io.base_data_loader import BaseDataLoader +from deployment.exporters.centerpoint.constants import OUTPUT_NAMES from deployment.pipelines import PipelineFactory -from projects.CenterPoint.deploy.constants import DEFAULT_FRAME_ID, OUTPUT_NAMES logger = logging.getLogger(__name__) @@ -71,12 +71,10 @@ def __init__( num_classes=len(names), ) - # Create metrics adapter + # Create metrics adapter with default frame_id if not provided + # "base_link" is the standard base frame in robotics/ROS if metrics_config is None: - metrics_config = Detection3DMetricsConfig( - class_names=list(names), - frame_id=DEFAULT_FRAME_ID, - ) + raise ValueError("metrics_config must be provided") metrics_adapter = Detection3DMetricsAdapter(metrics_config) super().__init__( From 1d19a4eaee32df82937abada9aa3ece7e2b59604 Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 1 Dec 2025 18:06:13 +0900 Subject: [PATCH 48/62] chore: revert readme Signed-off-by: vividf --- projects/CenterPoint/README.md | 53 ++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/projects/CenterPoint/README.md b/projects/CenterPoint/README.md index c1409e335..6b265e890 100644 --- a/projects/CenterPoint/README.md +++ b/projects/CenterPoint/README.md @@ -2,10 +2,10 @@ ## Summary - [Support priority](https://github.com/tier4/AWML/blob/main/docs/design/autoware_ml_design.md#support-priority): Tier S -- ROS package: [auotoware_lidar_centerpoint] (https://github.com/autowarefoundation/autoware.universe/tree/main/perception/autoware_lidar_centerpoint) +- ROS package: [auotoware_lidar_centerpoint](https://github.com/autowarefoundation/autoware.universe/tree/main/perception/autoware_lidar_centerpoint) - Supported dataset - [x] T4dataset - - [] NuScenes + - [ ] NuScenes - Supported model - [x] LiDAR-only model - Other supported feature @@ -19,13 +19,23 @@ - v1 (121m range, grid_size = 760) - [CenterPoint base/1.X](./docs/CenterPoint/v1/base.md) - [CenterPoint x2/1.X](./docs/CenterPoint/v1/x2.md) - - [CenterPoint-ConvNeXtPC base/0.x](./docs/CenterPoint-ConvNeXtPC/v0/base.md) + - v2 (121m range, grid_size = 760) + - [CenterPoint base/2.X](./docs/CenterPoint/v2/base.md) + - [CenterPoint x2/2.X](./docs/CenterPoint/v2/x2.md) +- CenterPoint-ConvNeXtPC + - [CenterPoint-ConvNeXtPC base/0.x](./docs/CenterPoint-ConvNeXtPC/v0/base.md) +- CenterPoint-ShortRange + - v0 + - [CenterPoint-ShortRange base/0.X](./docs/CenterPoint-ShortRange/v0/base.md) + - v2 + - [CenterPoint-ShortRange base/2.X](./docs/CenterPoint-ShortRange/v2/base.md) + - [CenterPoint-ShortRange j6gen2/2.X](./docs/CenterPoint-ShortRange/v2/j6gen2.md) ## Get started ### 1. Setup -- Please follow the [installation tutorial](/docs/tutorial/tutorial_detection_3d.md)to set up the environment. -- Run docker +- Please follow the [installation tutorial](/docs/tutorial/tutorial_detection_3d.md) to set up the environment. +- Run docker. ```sh docker run -it --rm --gpus all --shm-size=64g --name awml -p 6006:6006 -v $PWD/:/workspace -v $PWD/data:/workspace/data autoware-ml @@ -34,7 +44,7 @@ docker run -it --rm --gpus all --shm-size=64g --name awml -p 6006:6006 -v $PWD/: ### 2. Train #### 2.1 Environment set up -Set `CUBLAS_WORKSPACE_CONFIG` for the deterministic behavior, plese check this [nvidia doc](https://docs.nvidia.com/cuda/cublas/index.html#results-reproducibility) for more info +- Set `CUBLAS_WORKSPACE_CONFIG` for the deterministic behavior, plese check this [nvidia doc](https://docs.nvidia.com/cuda/cublas/index.html#results-reproducibility) for more info. ```sh export CUBLAS_WORKSPACE_CONFIG=:4096:8 @@ -42,16 +52,16 @@ export CUBLAS_WORKSPACE_CONFIG=:4096:8 #### 2.2. Train CenterPoint model with T4dataset-base -- [choice] Train with a single GPU - - Rename config file to use for single GPU and batch size - - Change `train_batch_size` and `train_gpu_size` accordingly +- [choice] Train with a single GPU. + - Rename config file to use for single GPU and batch size. + - Change `train_batch_size` and `train_gpu_size` accordingly. ```sh # T4dataset (121m) python tools/detection3d/train.py projects/CenterPoint/configs/t4dataset/second_secfpn_2xb8_121m_base.py ``` -- [choice] Train with multi GPU +- [choice] Train with multi GPU. ```sh # Command @@ -63,7 +73,7 @@ bash tools/detection3d/dist_script.sh projects/CenterPoint/configs/t4dataset/Cen ### 3. Evaluation -- Run evaluation on a test set, please select experiment config accordingly +- Run evaluation on a test set, please select experiment config accordingly. - [choice] Evaluate with a single GPU @@ -73,8 +83,8 @@ DIR="work_dirs/centerpoint/t4dataset/second_secfpn_2xb8_121m_base/" && \ python tools/detection3d/test.py projects/CenterPoint/configs/t4dataset/second_secfpn_2xb8_121m_base.py $DIR/epoch_50.pth ``` -- [choice] Evaluate with multiple GPUs - - Note that if you choose to evaluate with multiple GPUs, you might get slightly different results as compared to single GPU due to differences across GPUs +- [choice] Evaluate with multiple GPUs. + - Note that if you choose to evaluate with multiple GPUs, you might get slightly different results as compared to single GPU due to differences across GPUs. ```sh # Command @@ -88,7 +98,7 @@ bash tools/detection3d/dist_script.sh projects/CenterPoint/configs/t4dataset/Cen ### 4. Visualization -- Run inference and visualize bounding boxes from a CenterPoint model +- Run inference and visualize bounding boxes from a CenterPoint model. ```sh # Inference for t4dataset @@ -100,7 +110,7 @@ where `frame-range` represents the range of frames to visualize. ### 5. Deploy -- Make an onnx file for a CenterPoint model +- Make an onnx file for a CenterPoint model. ```sh # Deploy for t4dataset @@ -111,14 +121,15 @@ python projects/CenterPoint/scripts/deploy.py projects/CenterPoint/configs/t4dat where `rot_y_axis_reference` can be removed if we would like to use the original counterclockwise x-axis rotation system. ## Troubleshooting +### Difference from original CenterPoint from mmdetection3d v1 -- The difference from original CenterPoint from mmdetection3d v1 - - To maintain the backward compatibility with the previous ML library, we modified the original CenterPoint from mmdetection3d v1 such as: - - Exclude voxel center from z-dimension as part of pillar features - - Assume that the rotation system in the deployed ONNX file is in clockwise y-axis, and a bounding box is [x, y, z, w, l, h] for the deployed ONNX file - - Do not use CBGS dataset to align the experiment configuration with the older library -- Latest mmdetection3D assumes the lidar coordinate system is in the right-handed x-axis reference, also the dimensionality of a bounding box is [x, y, z, l, w, h], please check [this](https://mmdetection3d.readthedocs.io/en/latest/user_guides/coord_sys_tutorial.html) for more details +- To maintain the backward compatibility with the previous ML library, we modified the original CenterPoint from mmdetection3d v1 such as: + - Exclude voxel center from z-dimension as part of pillar features. + - Assume that the rotation system in the deployed ONNX file is in clockwise y-axis, and a bounding box is [x, y, z, w, l, h] for the deployed ONNX file. + - Do not use CBGS dataset to align the experiment configuration with the older library. +- Latest mmdetection3D assumes the lidar coordinate system is in the right-handed x-axis reference, also the dimensionality of a bounding box is [x, y, z, l, w, h], please check [this](https://mmdetection3d.readthedocs.io/en/latest/user_guides/coord_sys_tutorial.html) for more details. ## Reference +- "Center-based 3D Object Detection and Tracking", Tianwei Yin, Xingyi Zhou, Philipp Krähenbühl, CVPR2021. - [CenterPoint of mmdetection3d](https://github.com/open-mmlab/mmdetection3d/tree/main/configs/centerpoint) From ce2edcbc8fdeef231415169caa0b807fb503266b Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 2 Dec 2025 00:28:06 +0900 Subject: [PATCH 49/62] chore: fix workflows Signed-off-by: vividf --- deployment/exporters/centerpoint/__init__.py | 2 - deployment/exporters/centerpoint/constants.py | 7 -- .../exporters/centerpoint/onnx_workflow.py | 96 ++++++++++++------- .../centerpoint/tensorrt_workflow.py | 69 +++++++------ projects/CenterPoint/deploy/utils.py | 1 - 5 files changed, 94 insertions(+), 81 deletions(-) diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py index 07bcfaac9..54d403412 100644 --- a/deployment/exporters/centerpoint/__init__.py +++ b/deployment/exporters/centerpoint/__init__.py @@ -4,7 +4,6 @@ BACKBONE_HEAD_ENGINE, BACKBONE_HEAD_NAME, BACKBONE_HEAD_ONNX, - ONNX_TO_TRT_MAPPINGS, OUTPUT_NAMES, VOXEL_ENCODER_ENGINE, VOXEL_ENCODER_NAME, @@ -26,5 +25,4 @@ "BACKBONE_HEAD_ONNX", "VOXEL_ENCODER_ENGINE", "BACKBONE_HEAD_ENGINE", - "ONNX_TO_TRT_MAPPINGS", ] diff --git a/deployment/exporters/centerpoint/constants.py b/deployment/exporters/centerpoint/constants.py index 80f5ac432..b16e13a26 100644 --- a/deployment/exporters/centerpoint/constants.py +++ b/deployment/exporters/centerpoint/constants.py @@ -33,12 +33,6 @@ VOXEL_ENCODER_ENGINE: str = f"{VOXEL_ENCODER_NAME}.engine" BACKBONE_HEAD_ENGINE: str = f"{BACKBONE_HEAD_NAME}.engine" -# Ordered list of ONNX to TensorRT file mappings -ONNX_TO_TRT_MAPPINGS: Tuple[Tuple[str, str], ...] = ( - (VOXEL_ENCODER_ONNX, VOXEL_ENCODER_ENGINE), - (BACKBONE_HEAD_ONNX, BACKBONE_HEAD_ENGINE), -) - __all__ = [ # Model architecture "OUTPUT_NAMES", @@ -49,5 +43,4 @@ "BACKBONE_HEAD_ONNX", "VOXEL_ENCODER_ENGINE", "BACKBONE_HEAD_ENGINE", - "ONNX_TO_TRT_MAPPINGS", ] diff --git a/deployment/exporters/centerpoint/onnx_workflow.py b/deployment/exporters/centerpoint/onnx_workflow.py index 60cf6361a..6fb0dcc5a 100644 --- a/deployment/exporters/centerpoint/onnx_workflow.py +++ b/deployment/exporters/centerpoint/onnx_workflow.py @@ -10,7 +10,8 @@ import logging import os -from typing import Optional +from pathlib import Path +from typing import Iterable, Optional, Tuple import torch @@ -18,7 +19,7 @@ from deployment.exporters.common.factory import ExporterFactory from deployment.exporters.common.model_wrappers import IdentityWrapper from deployment.exporters.workflows.base import OnnxExportWorkflow -from deployment.exporters.workflows.interfaces import ModelComponentExtractor +from deployment.exporters.workflows.interfaces import ExportableComponent, ModelComponentExtractor class CenterPointONNXExportWorkflow(OnnxExportWorkflow): @@ -80,62 +81,85 @@ def export( AttributeError: If component extractor doesn't have extract_features method RuntimeError: If feature extraction or export fails """ - # Ensure output directory exists - os.makedirs(output_dir, exist_ok=True) + 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) + + # --------------------------------------------------------------------- # + # Internal helpers + # --------------------------------------------------------------------- # + 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("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}") - # Extract features using component extractor helper - # This delegates to model's _extract_features method + 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: - self.logger.info("Extracting features from sample data...") - if hasattr(self.component_extractor, "extract_features"): - sample_data = self.component_extractor.extract_features(model, data_loader, sample_idx) - else: - raise AttributeError("Component extractor must provide extract_features method") + return self.component_extractor.extract_features(model, data_loader, sample_idx) except Exception as exc: - self.logger.error(f"Failed to extract features: {exc}") + self.logger.error("Failed to extract features", exc_info=exc) raise RuntimeError("Feature extraction failed") from exc - # Extract exportable components (delegates all model-specific logic) - components = self.component_extractor.extract_components(model, sample_data) - - # Export each component using generic ONNX exporter - exported_paths = [] - for i, component in enumerate(components, 1): - self.logger.info(f"\n[{i}/{len(components)}] Exporting {component.name}...") - - # Create fresh exporter for each component (no caching) - exporter = self.exporter_factory.create_onnx_exporter( - config=self.config, wrapper_cls=IdentityWrapper, logger=self.logger # CenterPoint doesn't need wrapper - ) - - # Determine output path - output_path = os.path.join(output_dir, f"{component.name}.onnx") + 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" - # Export component try: exporter.export( model=component.module, sample_input=component.sample_input, - output_path=output_path, + output_path=str(output_path), config_override=component.config_override, ) - exported_paths.append(output_path) - self.logger.info(f"Exported {component.name}: {output_path}") except Exception as exc: - self.logger.error(f"Failed to export {component.name}") + self.logger.error(f"Failed to export {component.name}", exc_info=exc) raise RuntimeError(f"{component.name} export failed") from exc - # Log summary + exported_paths.append(str(output_path)) + self.logger.info(f"Exported {component.name}: {output_path}") + + return tuple(exported_paths) + + def _build_onnx_exporter(self): + # CenterPoint does not require special wrapping, so IdentityWrapper suffices. + 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)}") - - return Artifact(path=output_dir, multi_file=True) diff --git a/deployment/exporters/centerpoint/tensorrt_workflow.py b/deployment/exporters/centerpoint/tensorrt_workflow.py index 954fbcc3a..d0312b222 100644 --- a/deployment/exporters/centerpoint/tensorrt_workflow.py +++ b/deployment/exporters/centerpoint/tensorrt_workflow.py @@ -10,12 +10,12 @@ import logging import os import re -from typing import Optional, Tuple +from pathlib import Path +from typing import List, Optional import torch from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig -from deployment.exporters.centerpoint.constants import ONNX_TO_TRT_MAPPINGS from deployment.exporters.common.factory import ExporterFactory from deployment.exporters.workflows.base import TensorRTExportWorkflow @@ -24,11 +24,8 @@ class CenterPointTensorRTExportWorkflow(TensorRTExportWorkflow): """ CenterPoint TensorRT export workflow. - Converts CenterPoint ONNX files to multiple TensorRT engines: - - pts_voxel_encoder.onnx → pts_voxel_encoder.engine - - pts_backbone_neck_head.onnx → pts_backbone_neck_head.engine - - Uses TENSORRT_FILE_MAPPINGS from constants module for file name configuration. + Converts every ONNX file in the export directory to a TensorRT engine by + following a simple naming convention (``foo.onnx`` → ``foo.engine``). """ # Pattern for validating CUDA device strings @@ -39,7 +36,6 @@ def __init__( exporter_factory: type[ExporterFactory], config: BaseDeploymentConfig, logger: Optional[logging.Logger] = None, - file_mappings: Optional[Tuple[Tuple[str, str], ...]] = None, ): """ Initialize CenterPoint TensorRT export workflow. @@ -48,13 +44,10 @@ def __init__( exporter_factory: Factory class for creating exporters config: Deployment configuration logger: Optional logger instance - file_mappings: Optional tuple of (onnx_file, engine_file) pairs. - Defaults to ONNX_TO_TRT_MAPPINGS from exporter constants. """ self.exporter_factory = exporter_factory self.config = config self.logger = logger or logging.getLogger(__name__) - self.file_mappings = file_mappings or ONNX_TO_TRT_MAPPINGS def _validate_cuda_device(self, device: str) -> int: """ @@ -111,7 +104,8 @@ def export( if onnx_dir is None: raise ValueError("onnx_dir must be provided for CenterPoint TensorRT export") - if not os.path.isdir(onnx_dir): + 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}") # Validate and set CUDA device @@ -119,36 +113,41 @@ def export( torch.cuda.set_device(device_id) self.logger.info(f"Using CUDA device: {device}") - # Create output directory - os.makedirs(output_dir, exist_ok=True) - - # Convert each ONNX file to TensorRT using configured file mappings - num_files = len(self.file_mappings) - for i, (onnx_file, trt_file) in enumerate(self.file_mappings, 1): - onnx_file_path = os.path.join(onnx_dir, onnx_file) - trt_path = os.path.join(output_dir, trt_file) + output_dir_path = Path(output_dir) + output_dir_path.mkdir(parents=True, exist_ok=True) - # Validate ONNX file exists - if not os.path.exists(onnx_file_path): - raise FileNotFoundError(f"ONNX file not found: {onnx_file_path}") + onnx_files = self._discover_onnx_files(onnx_dir_path) + if not onnx_files: + raise FileNotFoundError(f"No ONNX files found in {onnx_dir_path}") - self.logger.info(f"\n[{i}/{num_files}] Converting {onnx_file} to TensorRT...") + num_files = len(onnx_files) + for i, onnx_file in enumerate(onnx_files, 1): + trt_path = output_dir_path / f"{onnx_file.stem}.engine" - # Create fresh exporter (no caching) - exporter = self.exporter_factory.create_tensorrt_exporter(config=self.config, logger=self.logger) + self.logger.info(f"\n[{i}/{num_files}] Converting {onnx_file.name} → {trt_path.name}...") + exporter = self._build_tensorrt_exporter() - # Export to TensorRT try: artifact = exporter.export( model=None, # Not needed for TensorRT conversion - sample_input=None, # Shape info from config - output_path=trt_path, - onnx_path=onnx_file_path, + sample_input=None, # Shape info comes from config.model_inputs + output_path=str(trt_path), + onnx_path=str(onnx_file), ) - self.logger.info(f"TensorRT engine saved: {artifact.path}") except Exception as exc: - self.logger.error(f"Failed to convert {onnx_file} to TensorRT") - raise RuntimeError(f"TensorRT export failed for {onnx_file}") from exc + self.logger.error(f"Failed to convert {onnx_file.name} to TensorRT", exc_info=exc) + raise RuntimeError(f"TensorRT export failed for {onnx_file.name}") from exc + + 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, + ) - self.logger.info(f"\nAll TensorRT engines exported successfully to {output_dir}") - return Artifact(path=output_dir, multi_file=True) + def _build_tensorrt_exporter(self): + return self.exporter_factory.create_tensorrt_exporter(config=self.config, logger=self.logger) diff --git a/projects/CenterPoint/deploy/utils.py b/projects/CenterPoint/deploy/utils.py index 3c40bde7f..189d2a8a1 100644 --- a/projects/CenterPoint/deploy/utils.py +++ b/projects/CenterPoint/deploy/utils.py @@ -180,7 +180,6 @@ def get_cfg_value(cfg, key, default=None): return None logger.info("=" * 60) - logger.info("Detected T4MetricV2 config!") logger.info("Extracting evaluation settings for deployment...") logger.info("=" * 60) From 29d720956d40ce8af030b2e0894b76ae9dca54bc Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 2 Dec 2025 14:59:25 +0900 Subject: [PATCH 50/62] chore: remove constants and put the field in deploy config Signed-off-by: vividf --- deployment/exporters/centerpoint/__init__.py | 25 ++++++---- deployment/exporters/centerpoint/constants.py | 46 ------------------- .../CenterPoint/deploy/component_extractor.py | 20 ++++---- .../deploy/configs/deploy_config.py | 30 ++++++++---- projects/CenterPoint/deploy/evaluator.py | 4 +- 5 files changed, 47 insertions(+), 78 deletions(-) delete mode 100644 deployment/exporters/centerpoint/constants.py diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py index 54d403412..1701dad51 100644 --- a/deployment/exporters/centerpoint/__init__.py +++ b/deployment/exporters/centerpoint/__init__.py @@ -1,16 +1,23 @@ """CenterPoint-specific exporter workflows and constants.""" -from deployment.exporters.centerpoint.constants import ( - BACKBONE_HEAD_ENGINE, - BACKBONE_HEAD_NAME, - BACKBONE_HEAD_ONNX, - OUTPUT_NAMES, - VOXEL_ENCODER_ENGINE, - VOXEL_ENCODER_NAME, - VOXEL_ENCODER_ONNX, -) from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow +from projects.CenterPoint.deploy.configs.deploy_config import model_io, onnx_config + +# Re-export structured configs for direct access +OUTPUT_NAMES = model_io["head_output_names"] + +# Component configs +_voxel_cfg = onnx_config["components"]["voxel_encoder"] +_backbone_cfg = onnx_config["components"]["backbone_head"] + +VOXEL_ENCODER_NAME = _voxel_cfg["name"] +VOXEL_ENCODER_ONNX = _voxel_cfg["onnx_file"] +VOXEL_ENCODER_ENGINE = _voxel_cfg["engine_file"] + +BACKBONE_HEAD_NAME = _backbone_cfg["name"] +BACKBONE_HEAD_ONNX = _backbone_cfg["onnx_file"] +BACKBONE_HEAD_ENGINE = _backbone_cfg["engine_file"] __all__ = [ # Workflows diff --git a/deployment/exporters/centerpoint/constants.py b/deployment/exporters/centerpoint/constants.py deleted file mode 100644 index b16e13a26..000000000 --- a/deployment/exporters/centerpoint/constants.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Constants for CenterPoint export workflows. - -These constants define the export file structure and model architecture -for CenterPoint models. They are kept in the deployment package since -they are part of the export interface. -""" - -from typing import Tuple - -# ============================================================================= -# Model Architecture Constants -# ============================================================================= - -# CenterPoint head output names (tied to CenterHead architecture) -# Order matters for ONNX export -OUTPUT_NAMES: Tuple[str, ...] = ("heatmap", "reg", "height", "dim", "rot", "vel") - -# ============================================================================= -# Export File Structure Constants -# ============================================================================= - -# CenterPoint component names for multi-file ONNX export -# These match the model architecture (voxel encoder + backbone/neck/head) -VOXEL_ENCODER_NAME: str = "pts_voxel_encoder" -BACKBONE_HEAD_NAME: str = "pts_backbone_neck_head" - -# ONNX file names -VOXEL_ENCODER_ONNX: str = f"{VOXEL_ENCODER_NAME}.onnx" -BACKBONE_HEAD_ONNX: str = f"{BACKBONE_HEAD_NAME}.onnx" - -# TensorRT engine file names -VOXEL_ENCODER_ENGINE: str = f"{VOXEL_ENCODER_NAME}.engine" -BACKBONE_HEAD_ENGINE: str = f"{BACKBONE_HEAD_NAME}.engine" - -__all__ = [ - # Model architecture - "OUTPUT_NAMES", - # Export file structure - "VOXEL_ENCODER_NAME", - "BACKBONE_HEAD_NAME", - "VOXEL_ENCODER_ONNX", - "BACKBONE_HEAD_ONNX", - "VOXEL_ENCODER_ENGINE", - "BACKBONE_HEAD_ENGINE", -] diff --git a/projects/CenterPoint/deploy/component_extractor.py b/projects/CenterPoint/deploy/component_extractor.py index b66f76de8..b4a7c01aa 100644 --- a/projects/CenterPoint/deploy/component_extractor.py +++ b/projects/CenterPoint/deploy/component_extractor.py @@ -11,15 +11,9 @@ import torch -from deployment.exporters.centerpoint.constants import ( - BACKBONE_HEAD_NAME, - BACKBONE_HEAD_ONNX, - OUTPUT_NAMES, - VOXEL_ENCODER_NAME, - VOXEL_ENCODER_ONNX, -) from deployment.exporters.common.configs import ONNXExportConfig from deployment.exporters.workflows.interfaces import ExportableComponent, ModelComponentExtractor +from projects.CenterPoint.deploy.configs.deploy_config import model_io, onnx_config from projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX logger = logging.getLogger(__name__) @@ -81,8 +75,9 @@ def _create_voxel_encoder_component( self, model: torch.nn.Module, input_features: torch.Tensor ) -> ExportableComponent: """Create exportable voxel encoder component.""" + voxel_cfg = onnx_config["components"]["voxel_encoder"] return ExportableComponent( - name=VOXEL_ENCODER_NAME, + name=voxel_cfg["name"], module=model.pts_voxel_encoder, sample_input=input_features, config_override=ONNXExportConfig( @@ -95,7 +90,7 @@ def _create_voxel_encoder_component( opset_version=16, do_constant_folding=True, simplify=self.simplify, - save_file=VOXEL_ENCODER_ONNX, + save_file=voxel_cfg["onnx_file"], ), ) @@ -118,8 +113,9 @@ def _create_backbone_component( 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_HEAD_NAME, + name=backbone_cfg["name"], module=backbone_module, sample_input=backbone_input, config_override=ONNXExportConfig( @@ -129,7 +125,7 @@ def _create_backbone_component( opset_version=16, do_constant_folding=True, simplify=self.simplify, - save_file=BACKBONE_HEAD_ONNX, + save_file=backbone_cfg["onnx_file"], ), ) @@ -171,7 +167,7 @@ def _get_output_names(self, model: torch.nn.Module) -> Tuple[str, ...]: return tuple(output_names) return (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]: """ diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py index 89731eea6..db7c145e5 100644 --- a/projects/CenterPoint/deploy/configs/deploy_config.py +++ b/projects/CenterPoint/deploy/configs/deploy_config.py @@ -67,9 +67,9 @@ dict(name="num_points", shape=(-1,), dtype="int32"), # (num_voxels,) dict(name="coors", shape=(-1, 4), dtype="int32"), # (num_voxels, 4) = (batch, z, y, x) ], - # Outputs (head tensors) - output_name="reg", # Primary output name - additional_outputs=["height", "dim", "rot", "vel", "hm"], + # Head output names for ONNX export (order matters!) + # These are tied to CenterHead architecture + head_output_names=("heatmap", "reg", "height", "dim", "rot", "vel"), # Batch size configuration # - int : fixed batch size # - None : dynamic batch size with dynamic_axes @@ -85,18 +85,30 @@ # ============================================================================ # ONNX Export Configuration # ============================================================================ -# Note: CenterPoint uses multi-file ONNX export (voxel encoder + backbone/head). -# File names are defined by the workflow, not by save_file. -# See deployment/exporters/centerpoint/constants.py for file name definitions. +# CenterPoint uses multi-file ONNX export (voxel encoder + backbone/head). +# Component definitions below specify names and file outputs for each stage. onnx_config = dict( opset_version=16, do_constant_folding=True, export_params=True, keep_initializers_as_inputs=False, simplify=False, - # multi_file=True means the export produces a directory with multiple .onnx files - # File names are controlled by CenterPointONNXExportWorkflow, not by save_file + # Multi-file export: produces a directory with multiple .onnx/.engine files multi_file=True, + # Component definitions for multi-stage export + # Each component maps to a model sub-module that gets exported separately + 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", + ), + ), ) # ============================================================================ @@ -173,7 +185,7 @@ # ---------------------------------------------------------------------------- verification = dict( # Master switch to enable/disable verification - enabled=False, + enabled=True, tolerance=1e-1, num_verify_samples=1, # Device aliases for flexible device management diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index 9e623c198..548ef98c5 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -21,8 +21,8 @@ TaskProfile, ) from deployment.core.io.base_data_loader import BaseDataLoader -from deployment.exporters.centerpoint.constants import OUTPUT_NAMES from deployment.pipelines import PipelineFactory +from projects.CenterPoint.deploy.configs.deploy_config import model_io logger = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def set_onnx_config(self, model_cfg: Config) -> None: def _get_output_names(self) -> List[str]: """Provide meaningful names for CenterPoint head outputs.""" - return list(OUTPUT_NAMES) + return list(model_io["head_output_names"]) # ================== BaseEvaluator Implementation ================== From 17bf25ae6050422291a252618a1b8923fab19691 Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 2 Dec 2025 15:32:58 +0900 Subject: [PATCH 51/62] chore: clean code Signed-off-by: vividf --- deployment/exporters/centerpoint/__init__.py | 29 ++------------------ 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py index 1701dad51..f646398f2 100644 --- a/deployment/exporters/centerpoint/__init__.py +++ b/deployment/exporters/centerpoint/__init__.py @@ -1,35 +1,12 @@ -"""CenterPoint-specific exporter workflows and constants.""" +"""CenterPoint-specific exporter workflows and config accessors.""" from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow from projects.CenterPoint.deploy.configs.deploy_config import model_io, onnx_config -# Re-export structured configs for direct access -OUTPUT_NAMES = model_io["head_output_names"] - -# Component configs -_voxel_cfg = onnx_config["components"]["voxel_encoder"] -_backbone_cfg = onnx_config["components"]["backbone_head"] - -VOXEL_ENCODER_NAME = _voxel_cfg["name"] -VOXEL_ENCODER_ONNX = _voxel_cfg["onnx_file"] -VOXEL_ENCODER_ENGINE = _voxel_cfg["engine_file"] - -BACKBONE_HEAD_NAME = _backbone_cfg["name"] -BACKBONE_HEAD_ONNX = _backbone_cfg["onnx_file"] -BACKBONE_HEAD_ENGINE = _backbone_cfg["engine_file"] - __all__ = [ - # Workflows "CenterPointONNXExportWorkflow", "CenterPointTensorRTExportWorkflow", - # Model architecture constants - "OUTPUT_NAMES", - # Export file structure constants - "VOXEL_ENCODER_NAME", - "BACKBONE_HEAD_NAME", - "VOXEL_ENCODER_ONNX", - "BACKBONE_HEAD_ONNX", - "VOXEL_ENCODER_ENGINE", - "BACKBONE_HEAD_ENGINE", + "model_io", + "onnx_config", ] From 602eae334ee8579b958d24d9e0efd969c4c0d0df Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 2 Dec 2025 16:30:28 +0900 Subject: [PATCH 52/62] chore: change workflows to export pipeline Signed-off-by: vividf --- deployment/README.md | 14 +++---- deployment/core/config/base_config.py | 4 +- deployment/docs/README.md | 2 +- deployment/docs/architecture.md | 10 ++--- deployment/docs/best_practices.md | 12 +++--- deployment/docs/contributing.md | 6 +-- deployment/docs/core_contract.md | 2 +- ...{export_workflow.md => export_pipeline.md} | 12 +++--- deployment/docs/overview.md | 2 +- deployment/docs/projects.md | 12 +++--- deployment/docs/usage.md | 4 +- deployment/exporters/centerpoint/__init__.py | 10 ++--- ...nx_workflow.py => onnx_export_pipeline.py} | 16 ++++---- ...orkflow.py => tensorrt_export_pipeline.py} | 12 +++--- .../exporters/export_pipelines/__init__.py | 16 ++++++++ .../{workflows => export_pipelines}/base.py | 14 +++---- .../interfaces.py | 6 +-- deployment/exporters/workflows/__init__.py | 16 -------- .../runners/common/deployment_runner.py | 28 +++++++------- .../runners/common/export_orchestrator.py | 38 +++++++++---------- .../runners/projects/centerpoint_runner.py | 26 ++++++------- projects/CenterPoint/deploy/README.md | 4 +- .../CenterPoint/deploy/component_extractor.py | 2 +- .../deploy/configs/deploy_config.py | 2 +- 24 files changed, 134 insertions(+), 136 deletions(-) rename deployment/docs/{export_workflow.md => export_pipeline.md} (80%) rename deployment/exporters/centerpoint/{onnx_workflow.py => onnx_export_pipeline.py} (92%) rename deployment/exporters/centerpoint/{tensorrt_workflow.py => tensorrt_export_pipeline.py} (93%) create mode 100644 deployment/exporters/export_pipelines/__init__.py rename deployment/exporters/{workflows => export_pipelines}/base.py (81%) rename deployment/exporters/{workflows => export_pipelines}/interfaces.py (93%) delete mode 100644 deployment/exporters/workflows/__init__.py diff --git a/deployment/README.md b/deployment/README.md index 575bb201c..017510cbb 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -18,8 +18,6 @@ python projects/YOLOX_opt_elan/deploy/main.py configs/deploy_config.py configs/m python projects/CalibrationStatusClassification/deploy/main.py configs/deploy_config.py configs/model_config.py ``` -Only `--log-level` is available as a command-line flag. All other settings (`work_dir`, `device`, `checkpoint_path`) are configured in the deploy config file. Inject wrapper classes and optional workflows when instantiating a runner; exporters are created lazily inside `BaseDeploymentRunner`. - ## Documentation Map | Topic | Description | @@ -29,7 +27,7 @@ Only `--log-level` is available as a command-line flag. All other settings (`wor | [`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_workflow.md`](docs/export_workflow.md) | ONNX/TRT export steps and workflow patterns. | +| [`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. | @@ -38,9 +36,9 @@ Refer to `deployment/docs/README.md` for the same index. ## Architecture Snapshot -- **Entry points** (`projects/*/deploy/main.py`) instantiate project runners with data loaders, evaluators, wrappers, and optional workflows. +- **Entry points** (`projects/*/deploy/main.py`) instantiate project runners with data loaders, evaluators, wrappers, and optional export pipelines. - **Runners** coordinate load → export → verify → evaluate while delegating to shared Artifact/Verification/Evaluation orchestrators. -- **Exporters** live under `exporters/common/` with typed config classes; project wrappers/workflows compose the base exporters as needed. +- **Exporters** live under `exporters/common/` with typed config classes; project wrappers/pipelines compose the base exporters as needed. - **Pipelines** (`pipelines/common/*`, `pipelines/{task}/`) provide consistent preprocessing/postprocessing with backend-specific inference implementations resolved via `PipelineFactory`. - **Core package** (`core/`) supplies typed configs, runtime contexts, task definitions, and shared verification utilities. @@ -48,17 +46,17 @@ See [`docs/architecture.md`](docs/architecture.md) for diagrams and component de ## Export & Verification Flow -1. Load the PyTorch checkpoint and run ONNX export (single or multi-file) using the injected wrappers/workflows. +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_workflow.md`](docs/export_workflow.md) and [`docs/verification_evaluation.md`](docs/verification_evaluation.md). +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 workflows; see [`docs/projects.md`](docs/projects.md). +- **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. diff --git a/deployment/core/config/base_config.py b/deployment/core/config/base_config.py index 16cb04c89..a9f00e573 100644 --- a/deployment/core/config/base_config.py +++ b/deployment/core/config/base_config.py @@ -43,7 +43,7 @@ class PrecisionPolicy(str, Enum): class ExportMode(str, Enum): - """Export workflow modes.""" + """Export pipeline modes.""" ONNX = "onnx" TRT = "trt" @@ -396,7 +396,7 @@ def checkpoint_path(self) -> Optional[str]: Get checkpoint path - single source of truth for PyTorch model. This path is used by: - - Export workflow: to load the PyTorch model for ONNX conversion + - 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 diff --git a/deployment/docs/README.md b/deployment/docs/README.md index 77c504908..262f195cf 100644 --- a/deployment/docs/README.md +++ b/deployment/docs/README.md @@ -7,7 +7,7 @@ Reference guides extracted from the monolithic deployment README: - [`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_workflow.md`](./export_workflow.md) – ONNX/TensorRT export details plus workflows. +- [`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 index 07c7e298f..900975dd1 100644 --- a/deployment/docs/architecture.md +++ b/deployment/docs/architecture.md @@ -22,7 +22,7 @@ │ Exporters │ │ Helper Orchestrators │ │ - ONNX / TRT │ │ - ArtifactManager │ │ - Wrappers │ │ - VerificationOrch. │ -│ - Workflows │ │ - EvaluationOrch. │ +│ - Export Ppl. │ │ - EvaluationOrch. │ └────────────────┘ └────────┬───────────────┘ │ ┌───────────────────────────────▼─────────────────────────┐ @@ -39,7 +39,7 @@ `BaseDeploymentRunner` orchestrates the export/verification/evaluation loop. Project runners (CenterPoint, YOLOX, Calibration, …): - Implement model loading. -- Inject wrapper classes and optional workflows. +- 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. @@ -54,11 +54,11 @@ - `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 & Workflows +### 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 workflows (e.g., `CenterPointONNXExportWorkflow`) that orchestrate multi-file exports by composing the base exporters. +- Complex projects add export pipelines (e.g., `CenterPointONNXExportPipeline`) that orchestrate multi-file exports by composing the base exporters. ### Pipelines @@ -69,7 +69,7 @@ ``` deployment/ ├── core/ # Core dataclasses, configs, evaluators -├── exporters/ # Base exporters + project wrappers/workflows +├── exporters/ # Base exporters + project wrappers/export pipelines ├── pipelines/ # Task-specific pipelines per backend ├── runners/ # Shared runner + project adapters ``` diff --git a/deployment/docs/best_practices.md b/deployment/docs/best_practices.md index 79ea8fbf5..a7ddeec23 100644 --- a/deployment/docs/best_practices.md +++ b/deployment/docs/best_practices.md @@ -8,9 +8,9 @@ ## Model Export -- Inject wrapper classes (and optional workflows) into project runners; let `ExporterFactory` build exporters lazily. +- 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 workflow modules only when orchestration beyond single file export is required. +- 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. @@ -19,12 +19,12 @@ ``` exporters/{model}/ ├── model_wrappers.py -├── [optional] onnx_workflow.py -└── [optional] tensorrt_workflow.py +├── [optional] onnx_export_pipeline.py +└── [optional] tensorrt_export_pipeline.py ``` - Simple models: use base exporters + wrappers, no subclassing. -- Complex models: compose workflows that call the base exporters multiple times. +- Complex models: compose export pipelines that call the base exporters multiple times. ## Dependency Injection Pattern @@ -37,7 +37,7 @@ runner = YOLOXOptElanDeploymentRunner( - Keeps dependencies explicit. - Enables lazy exporter construction. -- Simplifies testing via mock wrappers/workflows. +- Simplifies testing via mock wrappers/pipelines. ## Verification Tips diff --git a/deployment/docs/contributing.md b/deployment/docs/contributing.md index 2ed0c3a10..6ec4c7d58 100644 --- a/deployment/docs/contributing.md +++ b/deployment/docs/contributing.md @@ -8,7 +8,7 @@ 2. **Exporters** - Add `exporters/{project}/model_wrappers.py` (reuse `IdentityWrapper` or implement a custom wrapper). - - Introduce `onnx_workflow.py` / `tensorrt_workflow.py` only if multi-stage orchestration is required; prefer composing the base exporters instead of subclassing them. + - Introduce `onnx_export_pipeline.py` / `tensorrt_export_pipeline.py` only if multi-stage orchestration is required; prefer composing the base exporters instead of subclassing them. 3. **Pipelines** - Inherit from the appropriate task base (`Detection2D`, `Detection3D`, `Classification`). @@ -20,11 +20,11 @@ 5. **Entry Point** - Add `projects/{project}/deploy/main.py`. - - Follow the dependency injection pattern: explicitly pass wrapper classes and workflows to the runner. + - Follow the dependency injection pattern: explicitly pass wrapper classes and export pipelines to the runner. 6. **Documentation** - Update `deployment/README.md` and the relevant docs in `deployment/docs/`. - - Document special requirements, configuration flags, or workflows. + - Document special requirements, configuration flags, or export pipelines. ## Core Contract diff --git a/deployment/docs/core_contract.md b/deployment/docs/core_contract.md index 90264272a..dd5e9cdd8 100644 --- a/deployment/docs/core_contract.md +++ b/deployment/docs/core_contract.md @@ -5,7 +5,7 @@ This document defines the responsibilities and boundaries between the primary de ### 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 workflows. +- 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`) diff --git a/deployment/docs/export_workflow.md b/deployment/docs/export_pipeline.md similarity index 80% rename from deployment/docs/export_workflow.md rename to deployment/docs/export_pipeline.md index 4b4355b65..e6a9ed963 100644 --- a/deployment/docs/export_workflow.md +++ b/deployment/docs/export_pipeline.md @@ -1,4 +1,4 @@ -# Export Workflows +# Export Pipelines ## ONNX Export @@ -24,7 +24,7 @@ CenterPoint splits the model into multiple ONNX/TensorRT artifacts: - `voxel_encoder.onnx` - `backbone_head.onnx` -Workflows orchestrate: +Export pipelines orchestrate: - Sequential export of each component. - Input/output wiring between stages. @@ -37,14 +37,14 @@ Workflows orchestrate: ## Dependency Injection Pattern -Projects inject wrappers and workflows when instantiating the runner: +Projects inject wrappers and export pipelines when instantiating the runner: ```python runner = CenterPointDeploymentRunner( ..., - onnx_workflow=CenterPointONNXExportWorkflow(...), - tensorrt_workflow=CenterPointTensorRTExportWorkflow(...), + onnx_pipeline=CenterPointONNXExportPipeline(...), + tensorrt_pipeline=CenterPointTensorRTExportPipeline(...), ) ``` -Simple projects can skip workflows entirely and rely on the base exporters provided by `ExporterFactory`. +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 index 10b49ed81..bebb7a47b 100644 --- a/deployment/docs/overview.md +++ b/deployment/docs/overview.md @@ -9,7 +9,7 @@ The AWML Deployment Framework provides a standardized, task-agnostic approach to 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 workflows are explicitly wired for clarity and testability. +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. diff --git a/deployment/docs/projects.md b/deployment/docs/projects.md index 570f6cb53..91a150386 100644 --- a/deployment/docs/projects.md +++ b/deployment/docs/projects.md @@ -4,14 +4,14 @@ **Highlights** -- Multi-file ONNX export (voxel encoder + backbone/head) orchestrated via workflows. +- 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. -**Workflows & Wrappers** +**Pipelines & Wrappers** -- `CenterPointONNXExportWorkflow` – drives multiple ONNX exports using the generic `ONNXExporter`. -- `CenterPointTensorRTExportWorkflow` – converts each ONNX file via the generic `TensorRTExporter`. +- `CenterPointONNXExportPipeline` – drives multiple ONNX exports using the generic `ONNXExporter`. +- `CenterPointTensorRTExportPipeline` – converts each ONNX file via the generic `TensorRTExporter`. - `CenterPointONNXWrapper` – identity wrapper. **Key Files** @@ -19,8 +19,8 @@ - `projects/CenterPoint/deploy/main.py` - `projects/CenterPoint/deploy/evaluator.py` - `deployment/pipelines/centerpoint/` -- `deployment/exporters/centerpoint/onnx_workflow.py` -- `deployment/exporters/centerpoint/tensorrt_workflow.py` +- `deployment/exporters/centerpoint/onnx_export_pipeline.py` +- `deployment/exporters/centerpoint/tensorrt_export_pipeline.py` **Pipeline Structure** diff --git a/deployment/docs/usage.md b/deployment/docs/usage.md index 549a50529..a33071618 100644 --- a/deployment/docs/usage.md +++ b/deployment/docs/usage.md @@ -21,7 +21,7 @@ python projects/CalibrationStatusClassification/deploy/main.py \ ## Creating a Project Runner -Projects pass lightweight configuration objects (wrapper classes and optional workflows) into the runner. Exporters are created lazily via `ExporterFactory`. +Projects pass lightweight configuration objects (wrapper classes and optional export pipelines) into the runner. Exporters are created lazily via `ExporterFactory`. ```python from deployment.exporters.yolox.model_wrappers import YOLOXOptElanONNXWrapper @@ -39,7 +39,7 @@ runner = YOLOXOptElanDeploymentRunner( Key points: -- Pass wrapper classes (and optional workflows) instead of exporter instances. +- Pass wrapper classes (and optional export pipelines) instead of exporter instances. - Exporters are constructed lazily inside `BaseDeploymentRunner`. - Entry points remain explicit and easily testable. diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py index f646398f2..0190cb3e7 100644 --- a/deployment/exporters/centerpoint/__init__.py +++ b/deployment/exporters/centerpoint/__init__.py @@ -1,12 +1,12 @@ -"""CenterPoint-specific exporter workflows and config accessors.""" +"""CenterPoint-specific exporter pipelines and config accessors.""" -from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow -from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow +from deployment.exporters.centerpoint.onnx_export_pipeline import CenterPointONNXExportPipeline +from deployment.exporters.centerpoint.tensorrt_export_pipeline import CenterPointTensorRTExportPipeline from projects.CenterPoint.deploy.configs.deploy_config import model_io, onnx_config __all__ = [ - "CenterPointONNXExportWorkflow", - "CenterPointTensorRTExportWorkflow", + "CenterPointONNXExportPipeline", + "CenterPointTensorRTExportPipeline", "model_io", "onnx_config", ] diff --git a/deployment/exporters/centerpoint/onnx_workflow.py b/deployment/exporters/centerpoint/onnx_export_pipeline.py similarity index 92% rename from deployment/exporters/centerpoint/onnx_workflow.py rename to deployment/exporters/centerpoint/onnx_export_pipeline.py index 6fb0dcc5a..749306339 100644 --- a/deployment/exporters/centerpoint/onnx_workflow.py +++ b/deployment/exporters/centerpoint/onnx_export_pipeline.py @@ -1,9 +1,9 @@ """ -CenterPoint ONNX export workflow using composition. +CenterPoint ONNX export pipeline using composition. -This workflow orchestrates multi-file ONNX export for CenterPoint models. +This pipeline orchestrates multi-file ONNX export for CenterPoint models. It uses the ModelComponentExtractor pattern to keep model-specific logic -separate from the generic export workflow. +separate from the generic export pipeline. """ from __future__ import annotations @@ -18,13 +18,13 @@ 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.workflows.base import OnnxExportWorkflow -from deployment.exporters.workflows.interfaces import ExportableComponent, ModelComponentExtractor +from deployment.exporters.export_pipelines.base import OnnxExportPipeline +from deployment.exporters.export_pipelines.interfaces import ExportableComponent, ModelComponentExtractor -class CenterPointONNXExportWorkflow(OnnxExportWorkflow): +class CenterPointONNXExportPipeline(OnnxExportPipeline): """ - CenterPoint ONNX export workflow. + CenterPoint ONNX export pipeline. Orchestrates multi-file ONNX export using a generic ONNXExporter and CenterPointComponentExtractor for model-specific logic. @@ -42,7 +42,7 @@ def __init__( logger: Optional[logging.Logger] = None, ): """ - Initialize CenterPoint ONNX export workflow. + Initialize CenterPoint ONNX export pipeline. Args: exporter_factory: Factory class for creating exporters diff --git a/deployment/exporters/centerpoint/tensorrt_workflow.py b/deployment/exporters/centerpoint/tensorrt_export_pipeline.py similarity index 93% rename from deployment/exporters/centerpoint/tensorrt_workflow.py rename to deployment/exporters/centerpoint/tensorrt_export_pipeline.py index d0312b222..72f0b0c7a 100644 --- a/deployment/exporters/centerpoint/tensorrt_workflow.py +++ b/deployment/exporters/centerpoint/tensorrt_export_pipeline.py @@ -1,7 +1,7 @@ """ -CenterPoint TensorRT export workflow using composition. +CenterPoint TensorRT export pipeline using composition. -This workflow orchestrates multi-file TensorRT export for CenterPoint models. +This pipeline orchestrates multi-file TensorRT export for CenterPoint models. It converts multiple ONNX files to TensorRT engines. """ @@ -17,12 +17,12 @@ from deployment.core import Artifact, BaseDataLoader, BaseDeploymentConfig from deployment.exporters.common.factory import ExporterFactory -from deployment.exporters.workflows.base import TensorRTExportWorkflow +from deployment.exporters.export_pipelines.base import TensorRTExportPipeline -class CenterPointTensorRTExportWorkflow(TensorRTExportWorkflow): +class CenterPointTensorRTExportPipeline(TensorRTExportPipeline): """ - CenterPoint TensorRT export workflow. + CenterPoint TensorRT export pipeline. Converts every ONNX file in the export directory to a TensorRT engine by following a simple naming convention (``foo.onnx`` → ``foo.engine``). @@ -38,7 +38,7 @@ def __init__( logger: Optional[logging.Logger] = None, ): """ - Initialize CenterPoint TensorRT export workflow. + Initialize CenterPoint TensorRT export pipeline. Args: exporter_factory: Factory class for creating exporters 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/workflows/base.py b/deployment/exporters/export_pipelines/base.py similarity index 81% rename from deployment/exporters/workflows/base.py rename to deployment/exporters/export_pipelines/base.py index 4deed15fa..1b0ff7d0b 100644 --- a/deployment/exporters/workflows/base.py +++ b/deployment/exporters/export_pipelines/base.py @@ -1,5 +1,5 @@ """ -Base workflow interfaces for specialized export flows. +Base export pipeline interfaces for specialized export flows. """ from __future__ import annotations @@ -12,9 +12,9 @@ from deployment.core.io.base_data_loader import BaseDataLoader -class OnnxExportWorkflow(ABC): +class OnnxExportPipeline(ABC): """ - Base interface for ONNX export workflows. + Base interface for ONNX export pipelines. """ @abstractmethod @@ -28,7 +28,7 @@ def export( sample_idx: int = 0, ) -> Artifact: """ - Execute the ONNX export workflow and return the produced artifact. + Execute the ONNX export pipeline and return the produced artifact. Args: model: PyTorch model to export @@ -42,9 +42,9 @@ def export( """ -class TensorRTExportWorkflow(ABC): +class TensorRTExportPipeline(ABC): """ - Base interface for TensorRT export workflows. + Base interface for TensorRT export pipelines. """ @abstractmethod @@ -58,7 +58,7 @@ def export( data_loader: BaseDataLoader, ) -> Artifact: """ - Execute the TensorRT export workflow and return the produced artifact. + Execute the TensorRT export pipeline and return the produced artifact. Args: onnx_path: Path to ONNX model file/directory diff --git a/deployment/exporters/workflows/interfaces.py b/deployment/exporters/export_pipelines/interfaces.py similarity index 93% rename from deployment/exporters/workflows/interfaces.py rename to deployment/exporters/export_pipelines/interfaces.py index 12feaa1a2..a2ebd7ee7 100644 --- a/deployment/exporters/workflows/interfaces.py +++ b/deployment/exporters/export_pipelines/interfaces.py @@ -1,8 +1,8 @@ """ -Interfaces for export workflow components. +Interfaces for export pipeline components. This module defines interfaces that allow project-specific code to provide -model-specific knowledge to generic deployment workflows. +model-specific knowledge to generic deployment export pipelines. """ from abc import ABC, abstractmethod @@ -42,7 +42,7 @@ class ModelComponentExtractor(ABC): This solves the dependency inversion problem: instead of deployment framework importing from projects/, projects/ implement this interface - and inject it into workflows. + and inject it into export pipelines. """ @abstractmethod diff --git a/deployment/exporters/workflows/__init__.py b/deployment/exporters/workflows/__init__.py deleted file mode 100644 index c932c18a3..000000000 --- a/deployment/exporters/workflows/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Export workflow interfaces and implementations.""" - -from deployment.exporters.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow -from deployment.exporters.workflows.interfaces import ( - ExportableComponent, - ModelComponentExtractor, -) - -__all__ = [ - # Base workflows - "OnnxExportWorkflow", - "TensorRTExportWorkflow", - # Component extraction interfaces - "ModelComponentExtractor", - "ExportableComponent", -] diff --git a/deployment/runners/common/deployment_runner.py b/deployment/runners/common/deployment_runner.py index c1c8354e5..504f926de 100644 --- a/deployment/runners/common/deployment_runner.py +++ b/deployment/runners/common/deployment_runner.py @@ -25,7 +25,7 @@ from deployment.core import BaseDataLoader, BaseDeploymentConfig, BaseEvaluator from deployment.core.contexts import ExportContext from deployment.exporters.common.model_wrappers import BaseModelWrapper -from deployment.exporters.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow +from deployment.exporters.export_pipelines.base import OnnxExportPipeline, TensorRTExportPipeline from deployment.runners.common.artifact_manager import ArtifactManager from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator from deployment.runners.common.export_orchestrator import ExportOrchestrator @@ -58,7 +58,7 @@ def to_dict(self) -> Dict[str, Any]: class BaseDeploymentRunner: """ - Base deployment runner for common deployment workflows. + Base deployment runner for common deployment pipelines. This runner orchestrates three specialized components: 1. ExportOrchestrator: Load PyTorch, export ONNX, export TensorRT @@ -67,7 +67,7 @@ class BaseDeploymentRunner: Projects should extend this class and override methods as needed: - Override load_pytorch_model() for project-specific model loading - - Provide project-specific ONNX/TensorRT workflows via constructor + - Provide project-specific ONNX/TensorRT export pipelines via constructor """ def __init__( @@ -78,8 +78,8 @@ def __init__( model_cfg: Config, logger: logging.Logger, onnx_wrapper_cls: Optional[Type[BaseModelWrapper]] = None, - onnx_workflow: Optional[OnnxExportWorkflow] = None, - tensorrt_workflow: Optional[TensorRTExportWorkflow] = None, + onnx_pipeline: Optional[OnnxExportPipeline] = None, + tensorrt_pipeline: Optional[TensorRTExportPipeline] = None, ): """ Initialize base deployment runner. @@ -91,8 +91,8 @@ def __init__( model_cfg: Model configuration logger: Logger instance onnx_wrapper_cls: Optional ONNX model wrapper class for exporter creation - onnx_workflow: Optional specialized ONNX workflow - tensorrt_workflow: Optional specialized TensorRT workflow + onnx_pipeline: Optional specialized ONNX export pipeline + tensorrt_pipeline: Optional specialized TensorRT export pipeline """ self.data_loader = data_loader self.evaluator = evaluator @@ -100,10 +100,10 @@ def __init__( self.model_cfg = model_cfg self.logger = logger - # Store workflow references for subclasses to modify + # Store pipeline references for subclasses to modify self._onnx_wrapper_cls = onnx_wrapper_cls - self._onnx_workflow = onnx_workflow - self._tensorrt_workflow = tensorrt_workflow + self._onnx_pipeline = onnx_pipeline + self._tensorrt_pipeline = tensorrt_pipeline # Initialize artifact manager (shared across orchestrators) self.artifact_manager = ArtifactManager(config, logger) @@ -116,9 +116,9 @@ def __init__( @property def export_orchestrator(self) -> ExportOrchestrator: """ - Get export orchestrator (created lazily to allow subclass workflow setup). + Get export orchestrator (created lazily to allow subclass pipeline setup). - This allows subclasses to set _onnx_workflow and _tensorrt_workflow in __init__ + This allows subclasses to set _onnx_pipeline and _tensorrt_pipeline in __init__ before the export orchestrator is created. """ if self._export_orchestrator is None: @@ -130,8 +130,8 @@ def export_orchestrator(self) -> ExportOrchestrator: model_loader=self.load_pytorch_model, evaluator=self.evaluator, onnx_wrapper_cls=self._onnx_wrapper_cls, - onnx_workflow=self._onnx_workflow, - tensorrt_workflow=self._tensorrt_workflow, + onnx_pipeline=self._onnx_pipeline, + tensorrt_pipeline=self._tensorrt_pipeline, ) return self._export_orchestrator diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runners/common/export_orchestrator.py index a0fd36d77..3e7d01492 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runners/common/export_orchestrator.py @@ -23,7 +23,7 @@ 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.workflows.base import OnnxExportWorkflow, TensorRTExportWorkflow +from deployment.exporters.export_pipelines.base import OnnxExportPipeline, TensorRTExportPipeline from deployment.runners.common.artifact_manager import ArtifactManager @@ -72,8 +72,8 @@ def __init__( model_loader: Callable[..., Any], evaluator: Any, onnx_wrapper_cls: Optional[Type[BaseModelWrapper]] = None, - onnx_workflow: Optional[OnnxExportWorkflow] = None, - tensorrt_workflow: Optional[TensorRTExportWorkflow] = None, + onnx_pipeline: Optional[OnnxExportPipeline] = None, + tensorrt_pipeline: Optional[TensorRTExportPipeline] = None, ): """ Initialize export orchestrator. @@ -86,8 +86,8 @@ def __init__( model_loader: Callable to load PyTorch model (checkpoint_path, **kwargs) -> model evaluator: Evaluator instance (for model injection) onnx_wrapper_cls: Optional ONNX model wrapper class - onnx_workflow: Optional specialized ONNX workflow - tensorrt_workflow: Optional specialized TensorRT workflow + onnx_pipeline: Optional specialized ONNX export pipeline + tensorrt_pipeline: Optional specialized TensorRT export pipeline """ self.config = config self.data_loader = data_loader @@ -96,8 +96,8 @@ def __init__( self._model_loader = model_loader self._evaluator = evaluator self._onnx_wrapper_cls = onnx_wrapper_cls - self._onnx_workflow = onnx_workflow - self._tensorrt_workflow = tensorrt_workflow + self._onnx_pipeline = onnx_pipeline + self._tensorrt_pipeline = tensorrt_pipeline # Lazy-initialized exporters self._onnx_exporter: Optional[ONNXExporter] = None @@ -352,8 +352,8 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A if not self.config.export_config.should_export_onnx(): return None - if self._onnx_workflow is None and self._onnx_wrapper_cls is None: - raise RuntimeError("ONNX export requested but no wrapper class or workflow provided.") + 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() # Use context.sample_idx, fallback to runtime config @@ -364,13 +364,13 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A os.makedirs(onnx_dir, exist_ok=True) output_path = os.path.join(onnx_dir, onnx_settings.save_file) - # Use workflow if available - if self._onnx_workflow is not None: + # Use export pipeline if available + if self._onnx_pipeline is not None: self.logger.info("=" * 80) - self.logger.info(f"Exporting to ONNX via workflow ({type(self._onnx_workflow).__name__})") + self.logger.info(f"Exporting to ONNX via pipeline ({type(self._onnx_pipeline).__name__})") self.logger.info("=" * 80) try: - artifact = self._onnx_workflow.export( + artifact = self._onnx_pipeline.export( model=pytorch_model, data_loader=self.data_loader, output_dir=onnx_dir, @@ -444,10 +444,10 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A self.logger.warning("ONNX path not available, skipping TensorRT export") return None - exporter_label = None if self._tensorrt_workflow else type(self._get_tensorrt_exporter()).__name__ + exporter_label = None if self._tensorrt_pipeline else type(self._get_tensorrt_exporter()).__name__ self.logger.info("=" * 80) - if self._tensorrt_workflow: - self.logger.info(f"Exporting to TensorRT via workflow ({type(self._tensorrt_workflow).__name__})") + 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) @@ -471,10 +471,10 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A 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) - # Use workflow if available - if self._tensorrt_workflow is not None: + # Use export pipeline if available + if self._tensorrt_pipeline is not None: try: - artifact = self._tensorrt_workflow.export( + artifact = self._tensorrt_pipeline.export( onnx_path=onnx_path, output_dir=tensorrt_dir, config=self.config, diff --git a/deployment/runners/projects/centerpoint_runner.py b/deployment/runners/projects/centerpoint_runner.py index c37e029a4..7521a049c 100644 --- a/deployment/runners/projects/centerpoint_runner.py +++ b/deployment/runners/projects/centerpoint_runner.py @@ -8,8 +8,8 @@ from typing import Any from deployment.core.contexts import CenterPointExportContext, ExportContext -from deployment.exporters.centerpoint.onnx_workflow import CenterPointONNXExportWorkflow -from deployment.exporters.centerpoint.tensorrt_workflow import CenterPointTensorRTExportWorkflow +from deployment.exporters.centerpoint.onnx_export_pipeline import CenterPointONNXExportPipeline +from deployment.exporters.centerpoint.tensorrt_export_pipeline import CenterPointTensorRTExportPipeline from deployment.exporters.common.factory import ExporterFactory from deployment.exporters.common.model_wrappers import IdentityWrapper from deployment.runners.common.deployment_runner import BaseDeploymentRunner @@ -40,8 +40,8 @@ def __init__( config, model_cfg, logger: logging.Logger, - onnx_workflow=None, - tensorrt_workflow=None, + onnx_pipeline=None, + tensorrt_pipeline=None, ): """ Initialize CenterPoint deployment runner. @@ -52,8 +52,8 @@ def __init__( config: Deployment configuration model_cfg: Model configuration logger: Logger instance - onnx_workflow: Optional custom ONNX workflow - tensorrt_workflow: Optional custom TensorRT workflow + onnx_pipeline: Optional custom ONNX export pipeline + tensorrt_pipeline: Optional custom TensorRT export pipeline Note: CenterPoint uses IdentityWrapper directly since no special @@ -71,21 +71,21 @@ def __init__( model_cfg=model_cfg, logger=logger, onnx_wrapper_cls=IdentityWrapper, - onnx_workflow=onnx_workflow, - tensorrt_workflow=tensorrt_workflow, + onnx_pipeline=onnx_pipeline, + tensorrt_pipeline=tensorrt_pipeline, ) - # Create workflows with ExporterFactory and component extractor - if self._onnx_workflow is None: - self._onnx_workflow = CenterPointONNXExportWorkflow( + # Create export pipelines with ExporterFactory and component extractor + 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_workflow is None: - self._tensorrt_workflow = CenterPointTensorRTExportWorkflow( + if self._tensorrt_pipeline is None: + self._tensorrt_pipeline = CenterPointTensorRTExportPipeline( exporter_factory=ExporterFactory, config=self.config, logger=self.logger, diff --git a/projects/CenterPoint/deploy/README.md b/projects/CenterPoint/deploy/README.md index 768a9a0f9..3afc0517c 100644 --- a/projects/CenterPoint/deploy/README.md +++ b/projects/CenterPoint/deploy/README.md @@ -169,10 +169,10 @@ The `CenterPointComponentExtractor` handles model-specific logic: ### Deployment Runner -`CenterPointDeploymentRunner` orchestrates the workflow: +`CenterPointDeploymentRunner` orchestrates the export pipeline: - Loads ONNX-compatible CenterPoint model - Injects model and config to evaluator -- Delegates export to `CenterPointONNXExportWorkflow` and `CenterPointTensorRTExportWorkflow` +- Delegates export to `CenterPointONNXExportPipeline` and `CenterPointTensorRTExportPipeline` ## Output Structure diff --git a/projects/CenterPoint/deploy/component_extractor.py b/projects/CenterPoint/deploy/component_extractor.py index b4a7c01aa..f690d5892 100644 --- a/projects/CenterPoint/deploy/component_extractor.py +++ b/projects/CenterPoint/deploy/component_extractor.py @@ -12,7 +12,7 @@ import torch from deployment.exporters.common.configs import ONNXExportConfig -from deployment.exporters.workflows.interfaces import ExportableComponent, ModelComponentExtractor +from deployment.exporters.export_pipelines.interfaces import ExportableComponent, ModelComponentExtractor from projects.CenterPoint.deploy.configs.deploy_config import model_io, onnx_config from projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py index db7c145e5..c53a75248 100644 --- a/projects/CenterPoint/deploy/configs/deploy_config.py +++ b/projects/CenterPoint/deploy/configs/deploy_config.py @@ -12,7 +12,7 @@ # Checkpoint Path - Single source of truth for PyTorch model # ============================================================================ # This is the main checkpoint path used by: -# - Export workflow: to load the PyTorch model for ONNX conversion +# - 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 checkpoint_path = "work_dirs/centerpoint/best_checkpoint.pth" From eaebbffd61d9629c1a2730455a8a4eb1f3823d6a Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 2 Dec 2025 17:39:21 +0900 Subject: [PATCH 53/62] chore: clean up pytorch pipeline Signed-off-by: vividf --- .../pipelines/centerpoint/centerpoint_onnx.py | 6 +- .../centerpoint/centerpoint_pipeline.py | 26 ++---- .../centerpoint/centerpoint_pytorch.py | 82 ++----------------- projects/CenterPoint/deploy/README.md | 12 +-- projects/CenterPoint/deploy/evaluator.py | 24 ++---- projects/CenterPoint/deploy/main.py | 3 +- 6 files changed, 31 insertions(+), 122 deletions(-) diff --git a/deployment/pipelines/centerpoint/centerpoint_onnx.py b/deployment/pipelines/centerpoint/centerpoint_onnx.py index 47e2c7572..10956f776 100644 --- a/deployment/pipelines/centerpoint/centerpoint_onnx.py +++ b/deployment/pipelines/centerpoint/centerpoint_onnx.py @@ -133,10 +133,8 @@ def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor input_name = self.backbone_head_session.get_inputs()[0].name output_names = [output.name for output in self.backbone_head_session.get_outputs()] - # Run ONNX inference with explicit output names for consistency - outputs = self.backbone_head_session.run( - output_names, {input_name: input_array} # Specify output names explicitly - ) + # Run ONNX inference + outputs = self.backbone_head_session.run(output_names, {input_name: input_array}) # Convert outputs to torch tensors # outputs should be: [heatmap, reg, height, dim, rot, vel] diff --git a/deployment/pipelines/centerpoint/centerpoint_pipeline.py b/deployment/pipelines/centerpoint/centerpoint_pipeline.py index b80b8720a..510c5d20b 100644 --- a/deployment/pipelines/centerpoint/centerpoint_pipeline.py +++ b/deployment/pipelines/centerpoint/centerpoint_pipeline.py @@ -42,21 +42,14 @@ def __init__(self, pytorch_model, device: str = "cuda", backend_type: str = "unk device: Device for inference ('cuda' or 'cpu') backend_type: Backend type ('pytorch', 'onnx', 'tensorrt') """ - # Get class names from model config if available - class_names = ["VEHICLE", "PEDESTRIAN", "CYCLIST"] # Default T4Dataset classes - if hasattr(pytorch_model, "CLASSES"): - class_names = pytorch_model.CLASSES - elif hasattr(pytorch_model, "cfg") and hasattr(pytorch_model.cfg, "class_names"): - class_names = pytorch_model.cfg.class_names - - # Get point cloud range and voxel size from model config - point_cloud_range = None - voxel_size = None - if hasattr(pytorch_model, "cfg"): - if hasattr(pytorch_model.cfg, "point_cloud_range"): - point_cloud_range = pytorch_model.cfg.point_cloud_range - if hasattr(pytorch_model.cfg, "voxel_size"): - voxel_size = pytorch_model.cfg.voxel_size + 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) # Initialize parent class super().__init__( @@ -73,7 +66,7 @@ def __init__(self, pytorch_model, device: str = "cuda", backend_type: str = "unk self.pytorch_model = pytorch_model self._stage_latencies = {} # Store stage-wise latencies for detailed breakdown - # ========== Shared Methods (All backends use same logic) ========== + # ========== Override Methods ========== def preprocess(self, points: torch.Tensor, **kwargs) -> Tuple[Dict[str, torch.Tensor], Dict]: """ @@ -196,7 +189,6 @@ def postprocess(self, head_outputs: List[torch.Tensor], sample_meta: Dict) -> Li preds_dicts = ([preds_dict],) # Tuple[List[dict]] format # Prepare metadata - if "box_type_3d" not in sample_meta: sample_meta["box_type_3d"] = LiDARInstance3DBoxes batch_input_metas = [sample_meta] diff --git a/deployment/pipelines/centerpoint/centerpoint_pytorch.py b/deployment/pipelines/centerpoint/centerpoint_pytorch.py index b56d2eabf..4b5077d68 100644 --- a/deployment/pipelines/centerpoint/centerpoint_pytorch.py +++ b/deployment/pipelines/centerpoint/centerpoint_pytorch.py @@ -6,11 +6,9 @@ """ import logging -import time from typing import Dict, List, Tuple import torch -from mmdet3d.apis import inference_detector from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline @@ -19,12 +17,10 @@ class CenterPointPyTorchPipeline(CenterPointDeploymentPipeline): """ - PyTorch implementation of CenterPoint pipeline. + PyTorch implementation of the staged CenterPoint deployment pipeline. - Uses pure PyTorch for all components, providing maximum flexibility - and ease of debugging at the cost of inference speed. - - For standard CenterPoint models (non-ONNX), uses end-to-end inference. + Uses PyTorch for preprocessing, middle encoder, backbone, and head while + sharing the same staged execution flow as ONNX/TensorRT backends. """ def __init__(self, pytorch_model, device: str = "cuda"): @@ -36,21 +32,13 @@ def __init__(self, pytorch_model, device: str = "cuda"): device: Device for inference """ super().__init__(pytorch_model, device, backend_type="pytorch") - - # Check if this is an ONNX-compatible model - self.is_onnx_model = hasattr(pytorch_model.pts_voxel_encoder, "get_input_features") - - if self.is_onnx_model: - logger.info("PyTorch pipeline initialized (ONNX-compatible model)") - else: - logger.info("PyTorch pipeline initialized (standard model, using end-to-end inference)") + logger.info("PyTorch pipeline initialized (ONNX-compatible staged inference)") def infer(self, points: torch.Tensor, sample_meta: Dict = None, return_raw_outputs: bool = False) -> Tuple: """ Complete inference pipeline. - For standard models, uses mmdet3d's inference_detector for end-to-end inference. - For ONNX-compatible models, uses the staged pipeline. + Uses the shared staged pipeline defined in CenterPointDeploymentPipeline. Args: points: Input point cloud @@ -59,68 +47,8 @@ def infer(self, points: torch.Tensor, sample_meta: Dict = None, return_raw_outpu """ if sample_meta is None: sample_meta = {} - - # For standard models, use end-to-end inference - if not self.is_onnx_model: - if return_raw_outputs: - raise NotImplementedError( - "return_raw_outputs=True is only supported for ONNX-compatible models. " - "Standard models use end-to-end inference via inference_detector." - ) - return self._infer_end_to_end(points, sample_meta) - - # For ONNX models, use staged pipeline return super().infer(points, sample_meta, return_raw_outputs=return_raw_outputs) - def _infer_end_to_end(self, points: torch.Tensor, sample_meta: Dict) -> Tuple[List[Dict], float, Dict[str, float]]: - """End-to-end inference for standard PyTorch models.""" - - start_time = time.time() - - try: - # Convert points to numpy for inference_detector - if isinstance(points, torch.Tensor): - points_np = points.cpu().numpy() - else: - points_np = points - - # Use mmdet3d's inference API - with torch.no_grad(): - results = inference_detector(self.pytorch_model, points_np) - - # Parse results - predictions = [] - if len(results) > 0 and hasattr(results[0], "pred_instances_3d"): - pred_instances = results[0].pred_instances_3d - - if hasattr(pred_instances, "bboxes_3d"): - 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)): - predictions.append( - { - "bbox_3d": bboxes_3d[i][:7].tolist(), # [x, y, z, w, l, h, yaw] - "score": float(scores_3d[i]), - "label": int(labels_3d[i]), - } - ) - - latency_ms = (time.time() - start_time) * 1000 - - # Empty latency breakdown for end-to-end models (not broken down into stages) - latency_breakdown = {} - - return predictions, latency_ms, latency_breakdown - - except Exception as e: - logger.error(f"End-to-end inference failed: {e}") - import traceback - - traceback.print_exc() - raise - def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: """ Run voxel encoder using PyTorch. diff --git a/projects/CenterPoint/deploy/README.md b/projects/CenterPoint/deploy/README.md index 3afc0517c..501aed2da 100644 --- a/projects/CenterPoint/deploy/README.md +++ b/projects/CenterPoint/deploy/README.md @@ -1,14 +1,14 @@ # CenterPoint Deployment -Complete deployment pipeline for CenterPoint 3D object detection using the unified deployment framework. +Deployment pipeline for CenterPoint 3D object detection using the unified deployment framework. ## Features -- ✅ Export to ONNX and TensorRT (multi-file architecture) -- ✅ Full evaluation with 3D detection metrics (autoware_perception_evaluation) -- ✅ Latency benchmarking -- ✅ Uses MMDet3D pipeline for consistency with training -- ✅ Unified runner architecture with composition-based design +- Export to ONNX and TensorRT (multi-file architecture) +- Full evaluation with 3D detection metrics (autoware_perception_evaluation) +- Latency benchmarking +- Uses MMDet3D pipeline for consistency with training +- Unified runner architecture with composition-based design ## Quick Start diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index 548ef98c5..0aa6e5e72 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -41,25 +41,21 @@ class CenterPointEvaluator(BaseEvaluator): def __init__( self, model_cfg: Config, - class_names: Optional[List[str]] = None, - metrics_config: Optional[Detection3DMetricsConfig] = None, + metrics_config: Detection3DMetricsConfig, ): """ Initialize CenterPoint evaluator. Args: model_cfg: Model configuration. - class_names: List of class names (optional). - metrics_config: Optional configuration for the metrics adapter. + metrics_config: Configuration for the metrics adapter. """ - # Determine class names - must come from config or explicit parameter - if class_names is not None: - names = class_names - elif hasattr(model_cfg, "class_names"): - names = model_cfg.class_names + # Determine class names - must come from config + if hasattr(model_cfg, "class_names"): + class_names = model_cfg.class_names else: raise ValueError( - "class_names must be provided either explicitly or via model_cfg.class_names. " + "class_names must be provided via model_cfg.class_names. " "Check your model config file includes class_names definition." ) @@ -67,14 +63,10 @@ def __init__( task_profile = TaskProfile( task_name="centerpoint_3d_detection", display_name="CenterPoint 3D Object Detection", - class_names=tuple(names), - num_classes=len(names), + class_names=tuple(class_names), + num_classes=len(class_names), ) - # Create metrics adapter with default frame_id if not provided - # "base_link" is the standard base frame in robotics/ROS - if metrics_config is None: - raise ValueError("metrics_config must be provided") metrics_adapter = Detection3DMetricsAdapter(metrics_config) super().__init__( diff --git a/projects/CenterPoint/deploy/main.py b/projects/CenterPoint/deploy/main.py index d0d966780..39d74a952 100644 --- a/projects/CenterPoint/deploy/main.py +++ b/projects/CenterPoint/deploy/main.py @@ -84,8 +84,7 @@ def main(): ) logger.info(f"Loaded {data_loader.get_num_samples()} samples") - # Extract T4MetricV2 config from model_cfg (if available) - # This ensures deployment evaluation uses the same settings as training evaluation + # Extract T4MetricV2 config from model_cfg logger.info("\nExtracting T4MetricV2 config from model config...") metrics_config = extract_t4metric_v2_config(model_cfg, logger=logger) if metrics_config is None: From 2f8a2fd16c288d405641f6b52743cc1d67995b27 Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 2 Dec 2025 18:00:43 +0900 Subject: [PATCH 54/62] chore: clean up centerpoint trt code Signed-off-by: vividf --- .../centerpoint/centerpoint_tensorrt.py | 25 ++++++------------- .../runners/projects/centerpoint_runner.py | 18 +------------ 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/deployment/pipelines/centerpoint/centerpoint_tensorrt.py b/deployment/pipelines/centerpoint/centerpoint_tensorrt.py index 4aab142f6..b15ae39b2 100644 --- a/deployment/pipelines/centerpoint/centerpoint_tensorrt.py +++ b/deployment/pipelines/centerpoint/centerpoint_tensorrt.py @@ -21,6 +21,7 @@ TensorRTResourceManager, release_tensorrt_resources, ) +from projects.CenterPoint.deploy.configs.deploy_config import onnx_config logger = logging.getLogger(__name__) @@ -73,10 +74,13 @@ def _load_tensorrt_engines(self): trt.init_libnvinfer_plugins(self._logger, "") runtime = trt.Runtime(self._logger) - # Define engine files + # Resolve engine filenames from deploy config with sane fallbacks + component_cfg = onnx_config.get("components", {}) + voxel_cfg = component_cfg.get("voxel_encoder", {}) + backbone_cfg = component_cfg.get("backbone_head", {}) engine_files = { - "voxel_encoder": "pts_voxel_encoder.engine", - "backbone_neck_head": "pts_backbone_neck_head.engine", + "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(): @@ -157,13 +161,8 @@ def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: cuda.memcpy_dtoh_async(output_array, d_output, stream) manager.synchronize() - # Convert to torch tensor voxel_features = torch.from_numpy(output_array).to(self.device) - - # Squeeze middle dimension if present - if voxel_features.ndim == 3 and voxel_features.shape[1] == 1: - voxel_features = voxel_features.squeeze(1) - + voxel_features = voxel_features.squeeze(1) return voxel_features def _get_io_names(self, engine, single_output: bool = False): @@ -203,14 +202,6 @@ def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor if context is None: raise RuntimeError("backbone_neck_head context is None - likely failed to initialize due to GPU OOM") - # DEBUG: Log input statistics for verification - if hasattr(self, "_debug_mode") and self._debug_mode: - logger.info( - f"TensorRT backbone input: shape={spatial_features.shape}, " - f"range=[{spatial_features.min():.3f}, {spatial_features.max():.3f}], " - f"mean={spatial_features.mean():.3f}, std={spatial_features.std():.3f}" - ) - # Convert to numpy input_array = spatial_features.cpu().numpy().astype(np.float32) if not input_array.flags["C_CONTIGUOUS"]: diff --git a/deployment/runners/projects/centerpoint_runner.py b/deployment/runners/projects/centerpoint_runner.py index 7521a049c..781ffad9f 100644 --- a/deployment/runners/projects/centerpoint_runner.py +++ b/deployment/runners/projects/centerpoint_runner.py @@ -112,7 +112,7 @@ def load_pytorch_model( Returns: Loaded PyTorch model (ONNX-compatible) """ - # Extract rot_y_axis_reference from typed context or extra dict + # Extract rot_y_axis_reference from context rot_y_axis_reference: bool = False if isinstance(context, CenterPointExportContext): rot_y_axis_reference = context.rot_y_axis_reference @@ -126,10 +126,7 @@ def load_pytorch_model( rot_y_axis_reference=rot_y_axis_reference, ) - # Update runner's internal model_cfg to ONNX-friendly version self.model_cfg = onnx_cfg - - # Explicitly inject model and config to evaluator self._inject_model_to_evaluator(model, onnx_cfg) return model @@ -142,19 +139,6 @@ def _inject_model_to_evaluator(self, model: Any, onnx_cfg: Any) -> None: model: PyTorch model to inject onnx_cfg: ONNX-compatible config to inject """ - # Check if evaluator has the setter methods - has_set_onnx_config = hasattr(self.evaluator, "set_onnx_config") - has_set_pytorch_model = hasattr(self.evaluator, "set_pytorch_model") - - if not (has_set_onnx_config and has_set_pytorch_model): - self.logger.warning( - "Evaluator does not have set_onnx_config() and/or set_pytorch_model() methods. " - "CenterPoint evaluator should implement these methods for proper model injection. " - f"Has set_onnx_config: {has_set_onnx_config}, " - f"Has set_pytorch_model: {has_set_pytorch_model}" - ) - return - # Inject ONNX-compatible config try: self.evaluator.set_onnx_config(onnx_cfg) From cb5ef37f044625ff6fb2f6216c8431aebf7d768a Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 2 Dec 2025 19:10:19 +0900 Subject: [PATCH 55/62] chore: add dockerfile Signed-off-by: vividf --- projects/CenterPoint/Dockerfile | 13 +++++++++++++ projects/CenterPoint/README.md | 15 +++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 projects/CenterPoint/Dockerfile 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..284854fc6 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 `projects/CenterPoint/deploy/configs/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 projects/CenterPoint/deploy/main.py \ + projects/CenterPoint/deploy/configs/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. From ce583dc8140eb837e6d0217bae39758ca31f6adb Mon Sep 17 00:00:00 2001 From: vividf Date: Fri, 12 Dec 2025 11:57:49 +0900 Subject: [PATCH 56/62] chore: wrap output to dataclass centerpoint Signed-off-by: vividf --- deployment/pipelines/centerpoint/__init__.py | 9 ++++++--- .../pipelines/centerpoint/centerpoint_pytorch.py | 4 ++-- projects/CenterPoint/deploy/evaluator.py | 12 +++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/deployment/pipelines/centerpoint/__init__.py b/deployment/pipelines/centerpoint/__init__.py index 9786420c2..295ef4251 100644 --- a/deployment/pipelines/centerpoint/__init__.py +++ b/deployment/pipelines/centerpoint/__init__.py @@ -9,17 +9,20 @@ PyTorch: >>> from deployment.pipelines.centerpoint import CenterPointPyTorchPipeline >>> pipeline = CenterPointPyTorchPipeline(model, device='cuda') - >>> predictions, latency, breakdown = pipeline.infer(points) + >>> result = pipeline.infer(points) + >>> predictions, latency, breakdown = result.output, result.latency_ms, result.breakdown ONNX: >>> from deployment.pipelines.centerpoint import CenterPointONNXPipeline >>> pipeline = CenterPointONNXPipeline(pytorch_model, onnx_dir='models', device='cuda') - >>> predictions, latency, breakdown = pipeline.infer(points) + >>> result = pipeline.infer(points) + >>> predictions, latency, breakdown = result.output, result.latency_ms, result.breakdown TensorRT: >>> from deployment.pipelines.centerpoint import CenterPointTensorRTPipeline >>> pipeline = CenterPointTensorRTPipeline(pytorch_model, tensorrt_dir='engines', device='cuda') - >>> predictions, latency, breakdown = pipeline.infer(points) + >>> result = pipeline.infer(points) + >>> predictions, latency, breakdown = result.output, result.latency_ms, result.breakdown Note: All pipelines now use the unified `infer()` interface from the base class. diff --git a/deployment/pipelines/centerpoint/centerpoint_pytorch.py b/deployment/pipelines/centerpoint/centerpoint_pytorch.py index 4b5077d68..b1be8854a 100644 --- a/deployment/pipelines/centerpoint/centerpoint_pytorch.py +++ b/deployment/pipelines/centerpoint/centerpoint_pytorch.py @@ -6,7 +6,7 @@ """ import logging -from typing import Dict, List, Tuple +from typing import Dict, List import torch @@ -34,7 +34,7 @@ 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) -> Tuple: + def infer(self, points: torch.Tensor, sample_meta: Dict = None, return_raw_outputs: bool = False): """ Complete inference pipeline. diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index 0aa6e5e72..feb5b79fd 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -147,21 +147,23 @@ def _build_results( """Build CenterPoint evaluation results.""" # Compute latency statistics latency_stats = self.compute_latency_stats(latencies) + latency_payload = latency_stats.to_dict() # Add stage-wise breakdown if available if latency_breakdowns: - latency_stats["latency_breakdown"] = self._compute_latency_breakdown(latency_breakdowns) + latency_payload["latency_breakdown"] = self._compute_latency_breakdown(latency_breakdowns).to_dict() # Get metrics from adapter map_results = self.metrics_adapter.compute_metrics() summary = self.metrics_adapter.get_summary() + summary_dict = summary.to_dict() if hasattr(summary, "to_dict") else summary return { - "mAP": summary.get("mAP", 0.0), - "mAPH": summary.get("mAPH", 0.0), - "per_class_ap": summary.get("per_class_ap", {}), + "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_stats, + "latency": latency_payload, "num_samples": num_samples, } From 8ea23e05165e03a8b68a4ece60586936d016545f Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 16:47:58 +0900 Subject: [PATCH 57/62] chore: rename adapter to interface 2 Signed-off-by: vividf --- .../deploy/configs/deploy_config.py | 4 ++-- projects/CenterPoint/deploy/evaluator.py | 24 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py index c53a75248..4612b344b 100644 --- a/projects/CenterPoint/deploy/configs/deploy_config.py +++ b/projects/CenterPoint/deploy/configs/deploy_config.py @@ -34,7 +34,7 @@ # - 'trt' : build TensorRT engine from an existing ONNX # - 'both' : export PyTorch -> ONNX -> TensorRT # - 'none' : no export (only evaluation / verification on existing artifacts) - mode="both", + mode="none", # ---- Common options ---------------------------------------------------- work_dir="work_dirs/centerpoint_deployment", # ---- ONNX source when building TensorRT only --------------------------- @@ -185,7 +185,7 @@ # ---------------------------------------------------------------------------- verification = dict( # Master switch to enable/disable verification - enabled=True, + enabled=False, tolerance=1e-1, num_verify_samples=1, # Device aliases for flexible device management diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index feb5b79fd..85b58a592 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -2,7 +2,7 @@ CenterPoint Evaluator for deployment. This module implements evaluation for CenterPoint 3D object detection models. -Uses autoware_perception_evaluation via Detection3DMetricsAdapter for consistent +Uses autoware_perception_evaluation via Detection3DMetricsInterface for consistent metric computation between training (T4MetricV2) and deployment. """ @@ -14,8 +14,8 @@ from deployment.core import ( BaseEvaluator, - Detection3DMetricsAdapter, Detection3DMetricsConfig, + Detection3DMetricsInterface, EvalResultDict, ModelSpec, TaskProfile, @@ -35,7 +35,7 @@ class CenterPointEvaluator(BaseEvaluator): - Pipeline creation (multi-stage 3D detection) - Point cloud input preparation - 3D bounding box ground truth parsing - - Detection3DMetricsAdapter integration + - Detection3DMetricsInterface integration """ def __init__( @@ -48,7 +48,7 @@ def __init__( Args: model_cfg: Model configuration. - metrics_config: Configuration for the metrics adapter. + metrics_config: Configuration for the metrics interface. """ # Determine class names - must come from config if hasattr(model_cfg, "class_names"): @@ -67,10 +67,10 @@ def __init__( num_classes=len(class_names), ) - metrics_adapter = Detection3DMetricsAdapter(metrics_config) + metrics_interface = Detection3DMetricsInterface(metrics_config) super().__init__( - metrics_adapter=metrics_adapter, + metrics_interface=metrics_interface, task_profile=task_profile, model_cfg=model_cfg, ) @@ -134,9 +134,9 @@ def _parse_ground_truths(self, gt_data: Dict[str, Any]) -> List[Dict]: return ground_truths - def _add_to_adapter(self, predictions: List[Dict], ground_truths: List[Dict]) -> None: - """Add frame to Detection3DMetricsAdapter.""" - self.metrics_adapter.add_frame(predictions, ground_truths) + def _add_to_interface(self, predictions: List[Dict], ground_truths: List[Dict]) -> None: + """Add frame to Detection3DMetricsInterface.""" + self.metrics_interface.add_frame(predictions, ground_truths) def _build_results( self, @@ -153,9 +153,9 @@ def _build_results( if latency_breakdowns: latency_payload["latency_breakdown"] = self._compute_latency_breakdown(latency_breakdowns).to_dict() - # Get metrics from adapter - map_results = self.metrics_adapter.compute_metrics() - summary = self.metrics_adapter.get_summary() + # Get metrics from interface + 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 { From e5c556aa14fa87b25d30ac92df93056cbd7b910d Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 18:34:06 +0900 Subject: [PATCH 58/62] chore: refactor factory Signed-off-by: vividf --- deployment/pipelines/centerpoint/__init__.py | 21 ++++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/deployment/pipelines/centerpoint/__init__.py b/deployment/pipelines/centerpoint/__init__.py index 295ef4251..7336aa5cf 100644 --- a/deployment/pipelines/centerpoint/__init__.py +++ b/deployment/pipelines/centerpoint/__init__.py @@ -6,21 +6,14 @@ Example usage: -PyTorch: - >>> from deployment.pipelines.centerpoint import CenterPointPyTorchPipeline - >>> pipeline = CenterPointPyTorchPipeline(model, device='cuda') - >>> result = pipeline.infer(points) - >>> predictions, latency, breakdown = result.output, result.latency_ms, result.breakdown -ONNX: - >>> from deployment.pipelines.centerpoint import CenterPointONNXPipeline - >>> pipeline = CenterPointONNXPipeline(pytorch_model, onnx_dir='models', device='cuda') - >>> result = pipeline.infer(points) - >>> predictions, latency, breakdown = result.output, result.latency_ms, result.breakdown +Using Registry: + >>> from deployment.pipelines.common import pipeline_registry + >>> pipeline = pipeline_registry.create_pipeline("centerpoint", model_spec, model) -TensorRT: - >>> from deployment.pipelines.centerpoint import CenterPointTensorRTPipeline - >>> pipeline = CenterPointTensorRTPipeline(pytorch_model, tensorrt_dir='engines', device='cuda') +Direct Instantiation: + >>> from deployment.pipelines.centerpoint import CenterPointPyTorchPipeline + >>> pipeline = CenterPointPyTorchPipeline(model, device='cuda') >>> result = pipeline.infer(points) >>> predictions, latency, breakdown = result.output, result.latency_ms, result.breakdown @@ -38,10 +31,12 @@ from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline from deployment.pipelines.centerpoint.centerpoint_pytorch import CenterPointPyTorchPipeline from deployment.pipelines.centerpoint.centerpoint_tensorrt import CenterPointTensorRTPipeline +from deployment.pipelines.centerpoint.factory import CenterPointPipelineFactory __all__ = [ "CenterPointDeploymentPipeline", "CenterPointPyTorchPipeline", "CenterPointONNXPipeline", "CenterPointTensorRTPipeline", + "CenterPointPipelineFactory", ] From 22b7dd5e8c2bce603c6c069f43558e4fd9ea67da Mon Sep 17 00:00:00 2001 From: vividf Date: Mon, 22 Dec 2025 18:34:15 +0900 Subject: [PATCH 59/62] chore: refactor centerpoint factory Signed-off-by: vividf --- projects/CenterPoint/deploy/evaluator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/projects/CenterPoint/deploy/evaluator.py b/projects/CenterPoint/deploy/evaluator.py index 85b58a592..ac1f70637 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/projects/CenterPoint/deploy/evaluator.py @@ -21,7 +21,7 @@ TaskProfile, ) from deployment.core.io.base_data_loader import BaseDataLoader -from deployment.pipelines import PipelineFactory +from deployment.pipelines import PipelineFactory, ProjectNames from projects.CenterPoint.deploy.configs.deploy_config import model_io logger = logging.getLogger(__name__) @@ -89,7 +89,8 @@ def _get_output_names(self) -> List[str]: def _create_pipeline(self, model_spec: ModelSpec, device: str) -> Any: """Create CenterPoint pipeline.""" - return PipelineFactory.create_centerpoint_pipeline( + return PipelineFactory.create( + project_name=ProjectNames.CENTERPOINT, model_spec=model_spec, pytorch_model=self.pytorch_model, device=device, From 1fd392f91822f15b01c028883534bb498876e3a8 Mon Sep 17 00:00:00 2001 From: vividf Date: Tue, 23 Dec 2025 17:32:29 +0900 Subject: [PATCH 60/62] chore: refactor whole deployment pipelines Signed-off-by: vividf --- deployment/README.md | 19 +- deployment/__init__.py | 2 +- deployment/cli/__init__.py | 1 + deployment/cli/main.py | 78 +++++ deployment/docs/configuration.md | 4 +- deployment/docs/contributing.md | 20 +- deployment/docs/projects.md | 21 +- deployment/docs/usage.md | 37 +-- deployment/exporters/centerpoint/__init__.py | 12 - deployment/pipelines/__init__.py | 58 +--- deployment/pipelines/base_factory.py | 46 +++ deployment/pipelines/base_pipeline.py | 116 +++++++ deployment/pipelines/centerpoint/__init__.py | 42 --- .../centerpoint/centerpoint_pipeline.py | 306 ------------------ deployment/pipelines/centerpoint/factory.py | 93 ------ deployment/pipelines/common/__init__.py | 19 -- deployment/pipelines/common/base_factory.py | 121 ------- deployment/pipelines/common/base_pipeline.py | 243 -------------- .../pipelines/common/gpu_resource_mixin.py | 236 -------------- deployment/pipelines/common/project_names.py | 36 --- deployment/pipelines/common/registry.py | 168 ---------- deployment/pipelines/factory.py | 6 +- deployment/pipelines/gpu_resource_mixin.py | 123 +++++++ deployment/pipelines/registry.py | 69 ++++ deployment/projects/__init__.py | 9 + deployment/projects/centerpoint/__init__.py | 22 ++ deployment/projects/centerpoint/cli.py | 13 + .../centerpoint/config/deploy_config.py | 160 +++++++++ .../projects/centerpoint}/data_loader.py | 162 +--------- deployment/projects/centerpoint/entrypoint.py | 61 ++++ .../projects/centerpoint}/evaluator.py | 71 +--- .../export}/component_extractor.py | 92 +----- .../export}/onnx_export_pipeline.py | 45 +-- .../export}/tensorrt_export_pipeline.py | 72 +---- .../projects/centerpoint/model_loader.py | 108 +------ .../pipelines/centerpoint_pipeline.py | 150 +++++++++ .../projects/centerpoint/pipelines/factory.py | 62 ++++ .../centerpoint/pipelines/onnx.py} | 70 +--- .../centerpoint/pipelines/pytorch.py} | 80 +---- .../centerpoint/pipelines/tensorrt.py} | 113 +------ .../centerpoint/runner.py} | 79 +---- deployment/projects/registry.py | 48 +++ deployment/runners/__init__.py | 24 -- deployment/runners/common/__init__.py | 16 - .../runners/common/deployment_runner.py | 214 ------------ deployment/runners/projects/__init__.py | 12 - deployment/runtime/__init__.py | 25 ++ .../common => runtime}/artifact_manager.py | 79 +---- .../evaluation_orchestrator.py | 77 +---- .../common => runtime}/export_orchestrator.py | 303 ++--------------- deployment/runtime/runner.py | 109 +++++++ .../verification_orchestrator.py | 65 +--- projects/CenterPoint/README.md | 6 +- projects/CenterPoint/deploy/README.md | 232 ------------- projects/CenterPoint/deploy/__init__.py | 5 - .../deploy/configs/deploy_config.py | 249 -------------- projects/CenterPoint/deploy/main.py | 125 ------- 57 files changed, 1266 insertions(+), 3568 deletions(-) create mode 100644 deployment/cli/__init__.py create mode 100644 deployment/cli/main.py delete mode 100644 deployment/exporters/centerpoint/__init__.py create mode 100644 deployment/pipelines/base_factory.py create mode 100644 deployment/pipelines/base_pipeline.py delete mode 100644 deployment/pipelines/centerpoint/__init__.py delete mode 100644 deployment/pipelines/centerpoint/centerpoint_pipeline.py delete mode 100644 deployment/pipelines/centerpoint/factory.py delete mode 100644 deployment/pipelines/common/__init__.py delete mode 100644 deployment/pipelines/common/base_factory.py delete mode 100644 deployment/pipelines/common/base_pipeline.py delete mode 100644 deployment/pipelines/common/gpu_resource_mixin.py delete mode 100644 deployment/pipelines/common/project_names.py delete mode 100644 deployment/pipelines/common/registry.py create mode 100644 deployment/pipelines/gpu_resource_mixin.py create mode 100644 deployment/pipelines/registry.py create mode 100644 deployment/projects/__init__.py create mode 100644 deployment/projects/centerpoint/__init__.py create mode 100644 deployment/projects/centerpoint/cli.py create mode 100644 deployment/projects/centerpoint/config/deploy_config.py rename {projects/CenterPoint/deploy => deployment/projects/centerpoint}/data_loader.py (52%) create mode 100644 deployment/projects/centerpoint/entrypoint.py rename {projects/CenterPoint/deploy => deployment/projects/centerpoint}/evaluator.py (67%) rename {projects/CenterPoint/deploy => deployment/projects/centerpoint/export}/component_extractor.py (61%) rename deployment/{exporters/centerpoint => projects/centerpoint/export}/onnx_export_pipeline.py (71%) rename deployment/{exporters/centerpoint => projects/centerpoint/export}/tensorrt_export_pipeline.py (56%) rename projects/CenterPoint/deploy/utils.py => deployment/projects/centerpoint/model_loader.py (50%) create mode 100644 deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py create mode 100644 deployment/projects/centerpoint/pipelines/factory.py rename deployment/{pipelines/centerpoint/centerpoint_onnx.py => projects/centerpoint/pipelines/onnx.py} (57%) rename deployment/{pipelines/centerpoint/centerpoint_pytorch.py => projects/centerpoint/pipelines/pytorch.py} (51%) rename deployment/{pipelines/centerpoint/centerpoint_tensorrt.py => projects/centerpoint/pipelines/tensorrt.py} (64%) rename deployment/{runners/projects/centerpoint_runner.py => projects/centerpoint/runner.py} (50%) create mode 100644 deployment/projects/registry.py delete mode 100644 deployment/runners/__init__.py delete mode 100644 deployment/runners/common/__init__.py delete mode 100644 deployment/runners/common/deployment_runner.py delete mode 100644 deployment/runners/projects/__init__.py create mode 100644 deployment/runtime/__init__.py rename deployment/{runners/common => runtime}/artifact_manager.py (54%) rename deployment/{runners/common => runtime}/evaluation_orchestrator.py (73%) rename deployment/{runners/common => runtime}/export_orchestrator.py (60%) create mode 100644 deployment/runtime/runner.py rename deployment/{runners/common => runtime}/verification_orchestrator.py (73%) delete mode 100644 projects/CenterPoint/deploy/README.md delete mode 100644 projects/CenterPoint/deploy/__init__.py delete mode 100644 projects/CenterPoint/deploy/configs/deploy_config.py delete mode 100644 projects/CenterPoint/deploy/main.py diff --git a/deployment/README.md b/deployment/README.md index 017510cbb..bf8bdfb77 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -8,14 +8,11 @@ At the center is a shared runner/pipeline/exporter architecture that teams can e ## Quick Start ```bash -# CenterPoint deployment -python projects/CenterPoint/deploy/main.py configs/deploy_config.py configs/model_config.py +# Deployment entrypoint +python -m deployment.cli.main [project-specific args] -# YOLOX deployment -python projects/YOLOX_opt_elan/deploy/main.py configs/deploy_config.py configs/model_config.py - -# Calibration deployment -python projects/CalibrationStatusClassification/deploy/main.py configs/deploy_config.py configs/model_config.py +# Example: CenterPoint deployment +python -m deployment.cli.main centerpoint --rot-y-axis-reference ``` ## Documentation Map @@ -36,10 +33,10 @@ Refer to `deployment/docs/README.md` for the same index. ## Architecture Snapshot -- **Entry points** (`projects/*/deploy/main.py`) instantiate project runners with data loaders, evaluators, wrappers, and optional export pipelines. -- **Runners** coordinate load → export → verify → evaluate while delegating to shared Artifact/Verification/Evaluation orchestrators. +- **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** (`pipelines/common/*`, `pipelines/{task}/`) provide consistent preprocessing/postprocessing with backend-specific inference implementations resolved via `PipelineFactory`. +- **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. @@ -60,7 +57,7 @@ Implementation details live in [`docs/export_pipeline.md`](docs/export_pipeline. - **YOLOX** – single-file export with output reshaping via `YOLOXOptElanONNXWrapper`. - **CalibrationStatusClassification** – binary classification deployment with identity wrappers and simplified pipelines. -Each project ships its own `deploy_config.py`, evaluator, and data loader under `projects/{Project}/deploy/`. +Each project ships its own deployment bundle under `deployment/projects//`. ## Core Contract diff --git a/deployment/__init__.py b/deployment/__init__.py index 32cc291ee..708e0b666 100644 --- a/deployment/__init__.py +++ b/deployment/__init__.py @@ -11,7 +11,7 @@ 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.runners import BaseDeploymentRunner +from deployment.runtime.runner import BaseDeploymentRunner __all__ = [ "BaseDeploymentConfig", 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..7fe56e2a0 --- /dev/null +++ b/deployment/cli/main.py @@ -0,0 +1,78 @@ +""" +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: + 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: + 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/docs/configuration.md b/deployment/docs/configuration.md index 1b91be981..06813a335 100644 --- a/deployment/docs/configuration.md +++ b/deployment/docs/configuration.md @@ -138,6 +138,4 @@ Use `from_mapping()` / `from_dict()` helpers to instantiate typed configs from e ## Example Config Paths -- `projects/CenterPoint/deploy/configs/deploy_config.py` -- `projects/YOLOX_opt_elan/deploy/configs/deploy_config.py` -- `projects/CalibrationStatusClassification/deploy/configs/deploy_config.py` +- `deployment/projects/centerpoint/config/deploy_config.py` diff --git a/deployment/docs/contributing.md b/deployment/docs/contributing.md index 6ec4c7d58..a2d1b5c6a 100644 --- a/deployment/docs/contributing.md +++ b/deployment/docs/contributing.md @@ -6,21 +6,19 @@ - Implement `BaseEvaluator` with task-specific metrics. - Implement `BaseDataLoader` variant for the dataset(s). -2. **Exporters** - - Add `exporters/{project}/model_wrappers.py` (reuse `IdentityWrapper` or implement a custom wrapper). - - Introduce `onnx_export_pipeline.py` / `tensorrt_export_pipeline.py` only if multi-stage orchestration is required; prefer composing the base exporters instead of subclassing them. +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** - - Inherit from the appropriate task base (`Detection2D`, `Detection3D`, `Classification`). - - Add backend-specific implementations (PyTorch, ONNX, TensorRT) only when behavior deviates from existing ones. + - Add backend-specific pipelines under `deployment/projects//pipelines/` and register a factory into `deployment.pipelines.registry.pipeline_registry`. -4. **Configuration** - - Create `projects/{project}/deploy/configs/deploy_config.py`. - - Configure export, verification, and evaluation settings with typed dataclasses where possible. +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. **Entry Point** - - Add `projects/{project}/deploy/main.py`. - - Follow the dependency injection pattern: explicitly pass wrapper classes and export pipelines to the runner. +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/`. diff --git a/deployment/docs/projects.md b/deployment/docs/projects.md index 91a150386..e6ea0d118 100644 --- a/deployment/docs/projects.md +++ b/deployment/docs/projects.md @@ -16,11 +16,11 @@ **Key Files** -- `projects/CenterPoint/deploy/main.py` -- `projects/CenterPoint/deploy/evaluator.py` -- `deployment/pipelines/centerpoint/` -- `deployment/exporters/centerpoint/onnx_export_pipeline.py` -- `deployment/exporters/centerpoint/tensorrt_export_pipeline.py` +- `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** @@ -43,10 +43,8 @@ run_backbone_head() → postprocess() **Key Files** -- `projects/YOLOX_opt_elan/deploy/main.py` -- `projects/YOLOX_opt_elan/deploy/evaluator.py` -- `deployment/pipelines/yolox/` -- `deployment/exporters/yolox/model_wrappers.py` +- `deployment/cli/main.py` (single entrypoint) +- `deployment/projects/yolox_opt_elan/` (planned bundle; not migrated yet) **Pipeline Structure** @@ -67,10 +65,7 @@ preprocess() → run_model() → postprocess() **Key Files** -- `projects/CalibrationStatusClassification/deploy/main.py` -- `projects/CalibrationStatusClassification/deploy/evaluator.py` -- `deployment/pipelines/calibration/` -- `deployment/exporters/calibration/model_wrappers.py` +- `deployment/projects/calibration_status_classification/legacy/main.py` (legacy script) **Pipeline Structure** diff --git a/deployment/docs/usage.md b/deployment/docs/usage.md index a33071618..4c81382ba 100644 --- a/deployment/docs/usage.md +++ b/deployment/docs/usage.md @@ -3,20 +3,16 @@ ## Basic Commands ```bash -# CenterPoint deployment -python projects/CenterPoint/deploy/main.py \ - configs/deploy_config.py \ - configs/model_config.py - -# YOLOX deployment -python projects/YOLOX_opt_elan/deploy/main.py \ - configs/deploy_config.py \ - configs/model_config.py - -# Calibration deployment -python projects/CalibrationStatusClassification/deploy/main.py \ - configs/deploy_config.py \ - configs/model_config.py +# 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 @@ -24,17 +20,8 @@ python projects/CalibrationStatusClassification/deploy/main.py \ Projects pass lightweight configuration objects (wrapper classes and optional export pipelines) into the runner. Exporters are created lazily via `ExporterFactory`. ```python -from deployment.exporters.yolox.model_wrappers import YOLOXOptElanONNXWrapper -from deployment.runners import YOLOXOptElanDeploymentRunner - -runner = YOLOXOptElanDeploymentRunner( - data_loader=data_loader, - evaluator=evaluator, - config=config, - model_cfg=model_cfg, - logger=logger, - onnx_wrapper_cls=YOLOXOptElanONNXWrapper, -) +# Project bundles live under deployment/projects/ and are resolved by the CLI. +# The runtime layer is under deployment/runtime/*. ``` Key points: diff --git a/deployment/exporters/centerpoint/__init__.py b/deployment/exporters/centerpoint/__init__.py deleted file mode 100644 index 0190cb3e7..000000000 --- a/deployment/exporters/centerpoint/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""CenterPoint-specific exporter pipelines and config accessors.""" - -from deployment.exporters.centerpoint.onnx_export_pipeline import CenterPointONNXExportPipeline -from deployment.exporters.centerpoint.tensorrt_export_pipeline import CenterPointTensorRTExportPipeline -from projects.CenterPoint.deploy.configs.deploy_config import model_io, onnx_config - -__all__ = [ - "CenterPointONNXExportPipeline", - "CenterPointTensorRTExportPipeline", - "model_io", - "onnx_config", -] diff --git a/deployment/pipelines/__init__.py b/deployment/pipelines/__init__.py index c2ce54eae..8eaa99d9c 100644 --- a/deployment/pipelines/__init__.py +++ b/deployment/pipelines/__init__.py @@ -1,64 +1,18 @@ -""" -Deployment Pipelines for Complex Models. - -This module provides pipeline abstractions for models that require -multi-stage processing with mixed PyTorch and optimized backend inference. - -Architecture: - - BasePipelineFactory: Abstract base class for project-specific factories - - pipeline_registry: Registry for dynamic project registration - - PipelineFactory: Unified interface for creating pipelines - -Adding a New Project: - 1. Create a factory.py in your project directory (e.g., pipelines/myproject/factory.py) - 2. Implement a class inheriting from BasePipelineFactory - 3. Use @pipeline_registry.register decorator - 4. Import the factory in this __init__.py to trigger registration +"""Deployment pipeline infrastructure. -Example: - >>> from deployment.pipelines import PipelineFactory, pipeline_registry - >>> pipeline = PipelineFactory.create("centerpoint", model_spec, pytorch_model) - >>> print(pipeline_registry.list_projects()) +Project-specific pipeline implementations live under `deployment/projects//pipelines/` +and should register themselves into `deployment.pipelines.registry.pipeline_registry`. """ -# CenterPoint pipelines -from deployment.pipelines.centerpoint import ( - CenterPointDeploymentPipeline, - CenterPointONNXPipeline, - CenterPointPipelineFactory, - CenterPointPyTorchPipeline, - CenterPointTensorRTPipeline, -) - -# Base classes and registry -from deployment.pipelines.common import ( - BaseDeploymentPipeline, - BasePipelineFactory, - PipelineRegistry, - ProjectNames, - pipeline_registry, -) - -# Pipeline factory +from deployment.pipelines.base_factory import BasePipelineFactory +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline from deployment.pipelines.factory import PipelineFactory - -# Add pipelines here - +from deployment.pipelines.registry import PipelineRegistry, pipeline_registry __all__ = [ - # Base classes and registry "BaseDeploymentPipeline", "BasePipelineFactory", "PipelineRegistry", "pipeline_registry", - "ProjectNames", - # Factory "PipelineFactory", - # CenterPoint - "CenterPointDeploymentPipeline", - "CenterPointPyTorchPipeline", - "CenterPointONNXPipeline", - "CenterPointTensorRTPipeline", - "CenterPointPipelineFactory", - # Add pipelines here ] diff --git a/deployment/pipelines/base_factory.py b/deployment/pipelines/base_factory.py new file mode 100644 index 000000000..b597d5baf --- /dev/null +++ b/deployment/pipelines/base_factory.py @@ -0,0 +1,46 @@ +""" +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): + @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..45771cd24 --- /dev/null +++ b/deployment/pipelines/base_pipeline.py @@ -0,0 +1,116 @@ +""" +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): + 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/centerpoint/__init__.py b/deployment/pipelines/centerpoint/__init__.py deleted file mode 100644 index 7336aa5cf..000000000 --- a/deployment/pipelines/centerpoint/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -CenterPoint Deployment Pipelines. - -This module provides unified deployment pipelines for CenterPoint 3D object detection -across different backends (PyTorch, ONNX, TensorRT). - -Example usage: - - -Using Registry: - >>> from deployment.pipelines.common import pipeline_registry - >>> pipeline = pipeline_registry.create_pipeline("centerpoint", model_spec, model) - -Direct Instantiation: - >>> from deployment.pipelines.centerpoint import CenterPointPyTorchPipeline - >>> pipeline = CenterPointPyTorchPipeline(model, device='cuda') - >>> result = pipeline.infer(points) - >>> predictions, latency, breakdown = result.output, result.latency_ms, result.breakdown - -Note: - All pipelines now use the unified `infer()` interface from the base class. - The `breakdown` dict contains stage-wise latencies: - - preprocessing_ms - - voxel_encoder_ms - - middle_encoder_ms - - backbone_head_ms - - postprocessing_ms -""" - -from deployment.pipelines.centerpoint.centerpoint_onnx import CenterPointONNXPipeline -from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline -from deployment.pipelines.centerpoint.centerpoint_pytorch import CenterPointPyTorchPipeline -from deployment.pipelines.centerpoint.centerpoint_tensorrt import CenterPointTensorRTPipeline -from deployment.pipelines.centerpoint.factory import CenterPointPipelineFactory - -__all__ = [ - "CenterPointDeploymentPipeline", - "CenterPointPyTorchPipeline", - "CenterPointONNXPipeline", - "CenterPointTensorRTPipeline", - "CenterPointPipelineFactory", -] diff --git a/deployment/pipelines/centerpoint/centerpoint_pipeline.py b/deployment/pipelines/centerpoint/centerpoint_pipeline.py deleted file mode 100644 index 510c5d20b..000000000 --- a/deployment/pipelines/centerpoint/centerpoint_pipeline.py +++ /dev/null @@ -1,306 +0,0 @@ -""" -CenterPoint Deployment Pipeline Base Class. - -This module provides the abstract base class for CenterPoint deployment, -defining the unified pipeline that shares PyTorch processing while allowing -backend-specific optimizations for voxel encoder and backbone/head. -""" - -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.common.base_pipeline import BaseDeploymentPipeline - -logger = logging.getLogger(__name__) - - -class CenterPointDeploymentPipeline(BaseDeploymentPipeline): - """ - Abstract base class for CenterPoint deployment pipeline. - - This class defines the complete inference flow for CenterPoint, with: - - Shared preprocessing (voxelization + input features) - - Shared middle encoder processing - - Shared postprocessing (predict_by_feat) - - Abstract methods for backend-specific voxel encoder and backbone/head - - The design eliminates code duplication by centralizing PyTorch processing - while allowing ONNX/TensorRT backends to optimize the convertible parts. - """ - - def __init__(self, pytorch_model, device: str = "cuda", backend_type: str = "unknown"): - """ - Initialize CenterPoint pipeline. - - Args: - pytorch_model: PyTorch model (used for preprocessing, middle encoder, postprocessing) - device: Device for inference ('cuda' or 'cpu') - backend_type: Backend type ('pytorch', 'onnx', 'tensorrt') - """ - 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) - - # Initialize parent class - 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 = {} # Store stage-wise latencies for detailed breakdown - - # ========== Override Methods ========== - - def preprocess(self, points: torch.Tensor, **kwargs) -> Tuple[Dict[str, torch.Tensor], Dict]: - """ - Preprocess: voxelization + input features preparation. - - ONNX/TensorRT backends use this for voxelization and input feature preparation. - PyTorch backend may override this method for end-to-end inference. - - Args: - points: Input point cloud [N, point_features] - **kwargs: Additional preprocessing parameters - - Returns: - Tuple of (preprocessed_dict, metadata): - - preprocessed_dict: Dictionary containing: - - 'input_features': 11-dim features for voxel encoder [N_voxels, max_points, 11] - - 'voxels': Raw voxel data - - 'num_points': Number of points per voxel - - 'coors': Voxel coordinates [N_voxels, 4] (batch_idx, z, y, x) - - metadata: Empty dict (for compatibility with base class) - """ - - # Ensure points are on correct device - points_tensor = points.to(self.device) - - # Step 1: Voxelization using PyTorch data_preprocessor - 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"] - - # Step 2: Get input features (only for ONNX/TensorRT models) - 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 tuple format for compatibility with base class infer() - return preprocessed_dict, {} - - def process_middle_encoder(self, voxel_features: torch.Tensor, coors: torch.Tensor) -> torch.Tensor: - """ - Process through middle encoder using PyTorch. - - All backends use the same middle encoder processing because sparse convolution - cannot be converted to ONNX/TensorRT efficiently. - - Args: - voxel_features: Features from voxel encoder [N_voxels, feature_dim] - coors: Voxel coordinates [N_voxels, 4] - - Returns: - spatial_features: Spatial features [B, C, H, W] - """ - # Ensure tensors are on correct device - voxel_features = voxel_features.to(self.device) - coors = coors.to(self.device) - - # Calculate batch size - batch_size = int(coors[-1, 0].item()) + 1 if len(coors) > 0 else 1 - - # Process through PyTorch middle encoder - 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]: - """ - Postprocess: decode head outputs using PyTorch's predict_by_feat. - - All backends use the same postprocessing to ensure consistent results. - This includes NMS, coordinate transformation, and score filtering. - - Args: - head_outputs: List of [heatmap, reg, height, dim, rot, vel] - sample_meta: Sample metadata (point_cloud_range, voxel_size, etc.) - - Returns: - List of predictions with bbox_3d, score, and label - """ - # Ensure all outputs are on correct device - head_outputs = [out.to(self.device) for out in head_outputs] - - # Organize head outputs: [heatmap, reg, height, dim, rot, vel] - if len(head_outputs) != 6: - raise ValueError(f"Expected 6 head outputs, got {len(head_outputs)}") - - heatmap, reg, height, dim, rot, vel = head_outputs - - # Check if rot_y_axis_reference conversion is needed - # When ONNX/TensorRT outputs use rot_y_axis_reference format, we need to convert back - # to standard format before passing to PyTorch's predict_by_feat - 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: - # Convert dim from [w, l, h] back to [l, w, h] - dim = dim[:, [1, 0, 2], :, :] - - # Convert rot from [-cos(x), -sin(y)] back to [sin(y), cos(x)] - rot = rot * (-1.0) - rot = rot[:, [1, 0], :, :] - - # Convert to mmdet3d format - preds_dict = {"heatmap": heatmap, "reg": reg, "height": height, "dim": dim, "rot": rot, "vel": vel} - preds_dicts = ([preds_dict],) # Tuple[List[dict]] format - - # Prepare metadata - if "box_type_3d" not in sample_meta: - sample_meta["box_type_3d"] = LiDARInstance3DBoxes - batch_input_metas = [sample_meta] - - # Use PyTorch's predict_by_feat for consistent decoding - 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 - ) - - # Parse predictions - 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(), # [x, y, z, w, l, h, yaw] - "score": float(scores_3d[i]), - "label": int(labels_3d[i]), - } - ) - - return results - - # ========== Abstract Methods (Backend-specific implementations) ========== - - @abstractmethod - def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: - """ - Run voxel encoder inference. - - This method must be implemented by each backend (PyTorch/ONNX/TensorRT) - to provide optimized voxel encoder inference. - - Args: - input_features: Input features [N_voxels, max_points, feature_dim] - - Returns: - voxel_features: Voxel features [N_voxels, feature_dim] - """ - raise NotImplementedError - - @abstractmethod - def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: - """ - Run backbone + neck + head inference. - - This method must be implemented by each backend (PyTorch/ONNX/TensorRT) - to provide optimized backbone/neck/head inference. - - Args: - spatial_features: Spatial features [B, C, H, W] - - Returns: - List of head outputs: [heatmap, reg, height, dim, rot, vel] - """ - raise NotImplementedError - - # ========== Main Inference Pipeline ========== - - def run_model(self, preprocessed_input: Dict[str, torch.Tensor]) -> Tuple[List[torch.Tensor], Dict[str, float]]: - """ - Run complete multi-stage model inference. - - This method implements all inference stages: - 1. Voxel Encoder (backend-specific) - 2. Middle Encoder (PyTorch) - 3. Backbone + Head (backend-specific) - - This method is called by the base class `infer()` method, which handles - preprocessing, postprocessing, latency tracking, and error handling. - - Args: - preprocessed_input: Dict from preprocess() containing: - - 'input_features': Input features for voxel encoder [N_voxels, max_points, 11] - - 'coors': Voxel coordinates [N_voxels, 4] - - 'voxels': Raw voxel data - - 'num_points': Number of points per voxel - - Returns: - Tuple of (head_outputs, stage_latencies): - - head_outputs: List of head outputs [heatmap, reg, height, dim, rot, vel] - - stage_latencies: Dict mapping stage names to latency in ms - - Note: - Stage latencies are returned (not stored in instance variable) to avoid - race conditions when pipelines are reused across multiple threads. - """ - - # Use local variable for thread safety (not instance variable) - stage_latencies = {} - - # Stage 1: Voxel Encoder (backend-specific) - start = time.perf_counter() - voxel_features = self.run_voxel_encoder(preprocessed_input["input_features"]) - stage_latencies["voxel_encoder_ms"] = (time.perf_counter() - start) * 1000 - - # Stage 2: Middle Encoder (PyTorch - shared across all backends) - 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 - - # Stage 3: Backbone + Head (backend-specific) - 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/pipelines/centerpoint/factory.py b/deployment/pipelines/centerpoint/factory.py deleted file mode 100644 index 0520cb5db..000000000 --- a/deployment/pipelines/centerpoint/factory.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -CenterPoint Pipeline Factory. - -This module provides the factory for creating CenterPoint pipelines -across different backends (PyTorch, ONNX, TensorRT). -""" - -import logging -from typing import Any, Optional - -from deployment.core.backend import Backend -from deployment.core.evaluation.evaluator_types import ModelSpec -from deployment.pipelines.centerpoint.centerpoint_onnx import CenterPointONNXPipeline -from deployment.pipelines.centerpoint.centerpoint_pytorch import CenterPointPyTorchPipeline -from deployment.pipelines.centerpoint.centerpoint_tensorrt import CenterPointTensorRTPipeline -from deployment.pipelines.common.base_factory import BasePipelineFactory -from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline -from deployment.pipelines.common.project_names import ProjectNames -from deployment.pipelines.common.registry import pipeline_registry - -logger = logging.getLogger(__name__) - - -@pipeline_registry.register -class CenterPointPipelineFactory(BasePipelineFactory): - """ - Factory for creating CenterPoint deployment pipelines. - - Supports PyTorch, ONNX, and TensorRT backends for 3D object detection. - - Example: - >>> from deployment.pipelines.centerpoint.factory import CenterPointPipelineFactory - >>> pipeline = CenterPointPipelineFactory.create_pipeline( - ... model_spec=model_spec, - ... pytorch_model=model, - ... ) - """ - - @classmethod - def get_project_name(cls) -> str: - """Return the project name for registry lookup.""" - return ProjectNames.CENTERPOINT - - @classmethod - def create_pipeline( - cls, - model_spec: ModelSpec, - pytorch_model: Any, - device: Optional[str] = None, - **kwargs, - ) -> BaseDeploymentPipeline: - """ - Create a CenterPoint pipeline for the specified backend. - - Args: - model_spec: Model specification (backend/device/path) - pytorch_model: PyTorch CenterPoint model instance - device: Override device (uses model_spec.device if None) - **kwargs: Additional arguments (unused for CenterPoint) - - Returns: - CenterPoint pipeline instance - - Raises: - ValueError: If backend is not supported - """ - 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) - - elif 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, - ) - - elif 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, - ) - - else: - raise ValueError(f"Unsupported backend: {backend.value}") diff --git a/deployment/pipelines/common/__init__.py b/deployment/pipelines/common/__init__.py deleted file mode 100644 index 117fdfcfa..000000000 --- a/deployment/pipelines/common/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Base Pipeline Classes for Deployment Framework. - -This module provides the base abstract class for all deployment pipelines, -along with the factory base class and registry for project-specific factories. -""" - -from deployment.pipelines.common.base_factory import BasePipelineFactory -from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline -from deployment.pipelines.common.project_names import ProjectNames -from deployment.pipelines.common.registry import PipelineRegistry, pipeline_registry - -__all__ = [ - "BaseDeploymentPipeline", - "BasePipelineFactory", - "PipelineRegistry", - "pipeline_registry", - "ProjectNames", -] diff --git a/deployment/pipelines/common/base_factory.py b/deployment/pipelines/common/base_factory.py deleted file mode 100644 index d21dbb2ed..000000000 --- a/deployment/pipelines/common/base_factory.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Base Pipeline Factory for Project-specific Pipeline Creation. - -This module provides the abstract base class for pipeline factories, -defining a unified interface for creating pipelines across different backends. - -Architecture: - - Each project (CenterPoint, YOLOX, etc.) implements its own factory - - Factories are registered with the PipelineRegistry - - Main factory uses registry to lookup and delegate to project factories - -Benefits: - - Open-Closed Principle: Add new projects without modifying main factory - - Single Responsibility: Each project manages its own pipeline creation - - Decoupled: Project-specific logic stays in project directories -""" - -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.common.base_pipeline import BaseDeploymentPipeline - -logger = logging.getLogger(__name__) - - -class BasePipelineFactory(ABC): - """ - Abstract base class for project-specific pipeline factories. - - Each project (CenterPoint, YOLOX, Calibration, etc.) should implement - this interface to provide its own pipeline creation logic. - - Example: - class CenterPointPipelineFactory(BasePipelineFactory): - @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: - # Create and return appropriate pipeline based on backend - ... - """ - - @classmethod - @abstractmethod - def get_project_name(cls) -> str: - """ - Get the project name for registry lookup. - - Returns: - Project name (e.g., "centerpoint", "yolox", "calibration") - """ - raise NotImplementedError - - @classmethod - @abstractmethod - def create_pipeline( - cls, - model_spec: ModelSpec, - pytorch_model: Any, - device: Optional[str] = None, - **kwargs, - ) -> BaseDeploymentPipeline: - """ - Create a pipeline for the specified backend. - - Args: - 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 for the specified backend - - Raises: - ValueError: If backend is not supported - """ - raise NotImplementedError - - @classmethod - def get_supported_backends(cls) -> list: - """ - Get list of supported backends for this project. - - Override this method to specify which backends are supported. - Default implementation returns all common backends. - - Returns: - List of supported Backend enums - """ - return [Backend.PYTORCH, Backend.ONNX, Backend.TENSORRT] - - @classmethod - def _validate_backend(cls, backend: Backend) -> None: - """ - Validate that the backend is supported. - - Args: - backend: Backend to validate - - Raises: - ValueError: If backend is not supported - """ - 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()}. " - f"Supported backends: {supported_names}" - ) diff --git a/deployment/pipelines/common/base_pipeline.py b/deployment/pipelines/common/base_pipeline.py deleted file mode 100644 index e5f9314be..000000000 --- a/deployment/pipelines/common/base_pipeline.py +++ /dev/null @@ -1,243 +0,0 @@ -""" -Base Deployment Pipeline for Unified Model Deployment. - -This module provides the abstract base class for all deployment pipelines, -defining a unified interface across different backends (PyTorch, ONNX, TensorRT) -and task types (detection, classification, segmentation). - -Architecture: - Input → preprocess() → run_model() → postprocess() → Output - -Key Design Principles: - 1. Shared Logic: preprocess/postprocess are shared across backends - 2. Backend-Specific: run_model() is implemented per backend - 3. Unified Interface: infer() provides consistent API - 4. Flexible Output: Can return raw or processed outputs -""" - -import logging -import time -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple, Union - -import torch - -from deployment.core.evaluation.evaluator_types import InferenceResult - -logger = logging.getLogger(__name__) - - -class BaseDeploymentPipeline(ABC): - """ - Abstract base class for all deployment pipelines. - - This class defines the unified interface for model deployment across - different backends and task types. - - Attributes: - model: Model object (PyTorch model, ONNX session, TensorRT engine, etc.) - device: Device for inference - task_type: Type of task ("detection2d", "detection3d", "classification", etc.) - backend_type: Type of backend ("pytorch", "onnx", "tensorrt", etc.) - """ - - def __init__(self, model: Any, device: str = "cpu", task_type: str = "unknown", backend_type: str = "unknown"): - """ - Initialize deployment pipeline. - - Args: - model: Model object (backend-specific) - device: Device for inference ('cpu', 'cuda', 'cuda:0', etc.) - task_type: Type of task - backend_type: Type of backend - """ - 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: - """ - Preprocess input data. - - This method should handle all preprocessing steps required before - feeding data to the model (normalization, resizing, etc.). - - Args: - input_data: Raw input (image, point cloud, etc.) - **kwargs: Additional preprocessing parameters - - Returns: - Preprocessed data ready for model - """ - raise NotImplementedError - - @abstractmethod - def run_model(self, preprocessed_input: Any) -> Union[Any, Tuple[Any, Dict[str, float]]]: - """ - Run model inference (backend-specific). - - This is the only method that differs across backends. - Each backend (PyTorch, ONNX, TensorRT) implements its own version. - - Args: - preprocessed_input: Preprocessed input data - - Returns: - Model output, or Tuple of (model_output, stage_latencies) - - If a tuple is returned: - - model_output: Raw tensors or backend-specific format - - stage_latencies: Dict mapping stage names to latency in ms - - If single value is returned, it's treated as model_output with no stage latencies. - - Note: - Returning stage latencies as a tuple is the recommended pattern to avoid - race conditions when pipelines are reused across multiple threads. - Use local variables instead of instance variables for per-request data. - """ - raise NotImplementedError - - @abstractmethod - def postprocess(self, model_output: Any, metadata: Dict = None) -> Any: - """ - Postprocess model output to final predictions. - - This method should handle all postprocessing steps like NMS, - coordinate transformation, score filtering, etc. - - Args: - model_output: Raw model output from run_model() - metadata: Additional metadata (image size, point cloud range, etc.) - - Returns: - Final predictions in standard format - """ - raise NotImplementedError - - def infer( - self, input_data: Any, metadata: Optional[Dict] = None, return_raw_outputs: bool = False, **kwargs - ) -> InferenceResult: - """ - Complete inference pipeline. - - This method orchestrates the entire inference flow: - 1. Preprocessing - 2. Model inference - 3. Postprocessing (optional) - - This unified interface allows: - - Evaluation: infer(..., return_raw_outputs=False) → get final predictions - - Verification: infer(..., return_raw_outputs=True) → get raw outputs for comparison - - Args: - input_data: Raw input data - metadata: Additional metadata for preprocessing/postprocessing - return_raw_outputs: If True, skip postprocessing (for verification) - **kwargs: Additional arguments passed to preprocess() - - Returns: - InferenceResult containing: - - output: raw model output (return_raw_outputs=True) or final predictions - - latency_ms: total inference latency in milliseconds - - breakdown: stage-wise latencies (may be empty) with keys such as - preprocessing_ms, model_ms, postprocessing_ms - """ - if metadata is None: - metadata = {} - - latency_breakdown: Dict[str, float] = {} - - try: - start_time = time.perf_counter() - - # Preprocess - preprocessed = self.preprocess(input_data, **kwargs) - - # Unpack preprocess outputs - 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 - - # Merge caller metadata with preprocess metadata - merged_metadata = {} - merged_metadata.update(metadata or {}) - merged_metadata.update(preprocess_metadata) - - # Run model (backend-specific) - 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 - - # Handle returned stage latencies (new pattern - thread-safe) - stage_latencies = {} - 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: Merge stage-wise latencies from instance variable (deprecated) - # This is kept for backward compatibility but should be removed eventually - if hasattr(self, "_stage_latencies") and isinstance(self._stage_latencies, dict): - latency_breakdown.update(self._stage_latencies) - # Clear for next inference - self._stage_latencies = {} - - total_latency = (time.perf_counter() - start_time) * 1000 - - # Postprocess (optional) - if return_raw_outputs: - return InferenceResult(output=model_output, latency_ms=total_latency, breakdown=latency_breakdown) - else: - 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: - """ - Cleanup pipeline resources. - - Subclasses should override this to release backend-specific resources - (e.g., TensorRT contexts, ONNX sessions, CUDA streams). - - This method is called automatically when using the pipeline as a - context manager, or can be called explicitly when done with the pipeline. - """ - 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): - """Context manager entry.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit - cleanup resources.""" - self.cleanup() - return False diff --git a/deployment/pipelines/common/gpu_resource_mixin.py b/deployment/pipelines/common/gpu_resource_mixin.py deleted file mode 100644 index c026b36d5..000000000 --- a/deployment/pipelines/common/gpu_resource_mixin.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -GPU Resource Management Mixin for TensorRT Pipelines. - -This module provides a standardized approach to GPU resource cleanup, -ensuring proper release of TensorRT engines, contexts, and CUDA memory. - -Design Principles: - 1. Single Responsibility: Resource cleanup logic is centralized - 2. Context Manager Protocol: Supports `with` statement for automatic cleanup - 3. Explicit Cleanup: Provides `cleanup()` for manual resource release - 4. Thread Safety: Uses local variables instead of instance state where possible -""" - -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: - """ - Clear CUDA memory cache and synchronize. - - This is a utility function that safely clears GPU memory - regardless of whether CUDA is available. - """ - if torch.cuda.is_available(): - torch.cuda.empty_cache() - torch.cuda.synchronize() - - -class GPUResourceMixin(ABC): - """ - Mixin class for pipelines that manage GPU resources. - - This mixin provides: - - Standard `cleanup()` interface for resource release - - Context manager protocol for automatic cleanup - - Safe cleanup in `__del__` as fallback - - Subclasses must implement `_release_gpu_resources()` to specify - which resources to release. - - Usage: - class MyTensorRTPipeline(BaseDeploymentPipeline, GPUResourceMixin): - def _release_gpu_resources(self) -> None: - # Release TensorRT engines, contexts, CUDA buffers, etc. - ... - - With context manager: - with MyTensorRTPipeline(...) as pipeline: - results = pipeline.infer(data) - # Resources automatically cleaned up - - Explicit cleanup: - pipeline = MyTensorRTPipeline(...) - try: - results = pipeline.infer(data) - finally: - pipeline.cleanup() - """ - - _cleanup_called: bool = False - - @abstractmethod - def _release_gpu_resources(self) -> None: - """ - Release GPU-specific resources. - - Subclasses must implement this to release their specific resources: - - TensorRT engines and execution contexts - - CUDA device memory allocations - - CUDA streams - - Any other GPU-bound resources - - This method should be idempotent (safe to call multiple times). - """ - raise NotImplementedError - - def cleanup(self) -> None: - """ - Explicitly cleanup GPU resources and release memory. - - This method should be called when the pipeline is no longer needed. - It's safe to call multiple times. - - For automatic cleanup, use the pipeline as a context manager: - with pipeline: - results = pipeline.infer(data) - """ - 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): - """Context manager entry - return self for use in with statement.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit - cleanup resources.""" - self.cleanup() - return False # Don't suppress exceptions - - def __del__(self): - """Destructor - cleanup as fallback if not done explicitly.""" - try: - self.cleanup() - except Exception: - pass # Suppress errors in destructor - - -class TensorRTResourceManager: - """ - Context manager for TensorRT inference with automatic resource cleanup. - - This class manages temporary CUDA allocations during inference, - ensuring they are properly freed even if an exception occurs. - - Usage: - with TensorRTResourceManager() as manager: - d_input = manager.allocate(input_nbytes) - d_output = manager.allocate(output_nbytes) - # ... run inference ... - # All allocations automatically freed - """ - - def __init__(self): - self._allocations: List[Any] = [] - self._stream: Optional[Any] = None - - def allocate(self, nbytes: int) -> Any: - """ - Allocate CUDA device memory and track for cleanup. - - Args: - nbytes: Number of bytes to allocate - - Returns: - pycuda.driver.DeviceAllocation object - """ - - allocation = cuda.mem_alloc(nbytes) - self._allocations.append(allocation) - return allocation - - def get_stream(self) -> Any: - """ - Get or create a CUDA stream. - - Returns: - pycuda.driver.Stream object - """ - if self._stream is None: - self._stream = cuda.Stream() - return self._stream - - def synchronize(self) -> None: - """Synchronize the CUDA stream.""" - if self._stream is not None: - self._stream.synchronize() - - def _release_all(self) -> None: - """Release all tracked allocations.""" - 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: - """ - Release TensorRT resources safely. - - This is a utility function that handles the cleanup of various - TensorRT resources in a safe, idempotent manner. - - Args: - engines: Dictionary of TensorRT engine objects - contexts: Dictionary of TensorRT execution context objects - cuda_buffers: List of pycuda.driver.DeviceAllocation objects - """ - # Release contexts first (they reference engines) - if contexts: - for name, context in list(contexts.items()): - if context is not None: - try: - del context - except Exception: - pass - contexts.clear() - - # Release engines - if engines: - for name, engine in list(engines.items()): - if engine is not None: - try: - del engine - except Exception: - pass - engines.clear() - - # Free CUDA buffers - 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/common/project_names.py b/deployment/pipelines/common/project_names.py deleted file mode 100644 index cbed85316..000000000 --- a/deployment/pipelines/common/project_names.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Project Names for Pipeline Registry. - -Usage: - from deployment.pipelines.common.project_names import ProjectNames - - # In factory: - class CenterPointPipelineFactory(BasePipelineFactory): - @classmethod - def get_project_name(cls) -> str: - return ProjectNames.CENTERPOINT - - # When creating pipeline: - PipelineFactory.create(ProjectNames.CENTERPOINT, model_spec, pytorch_model) -""" - - -class ProjectNames: - """ - Constants for project names. - - Add new project names here when adding new projects. - """ - - CENTERPOINT = "centerpoint" - YOLOX = "yolox" - CALIBRATION = "calibration" - - @classmethod - def all(cls) -> list: - """Return all defined project names.""" - return [ - value - for key, value in vars(cls).items() - if not key.startswith("_") and isinstance(value, str) and key.isupper() - ] diff --git a/deployment/pipelines/common/registry.py b/deployment/pipelines/common/registry.py deleted file mode 100644 index dc0a6db13..000000000 --- a/deployment/pipelines/common/registry.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -Pipeline Registry for Dynamic Project Pipeline Registration. - -This module provides a registry pattern for managing pipeline factories, -allowing projects to register themselves and be discovered at runtime. - -Usage: - # In project's factory module (e.g., centerpoint/factory.py): - from deployment.pipelines.common.registry import pipeline_registry - - @pipeline_registry.register - class CenterPointPipelineFactory(BasePipelineFactory): - ... - - # In main code: - pipeline = pipeline_registry.create_pipeline(ProjectNames.CENTERPOINT, model_spec, pytorch_model) -""" - -import logging -from typing import Any, Dict, Optional, Type - -from deployment.core.evaluation.evaluator_types import ModelSpec -from deployment.pipelines.common.base_factory import BasePipelineFactory -from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline - -logger = logging.getLogger(__name__) - - -class PipelineRegistry: - """ - Registry for project-specific pipeline factories. - - This registry maintains a mapping of project names to their factory classes, - enabling dynamic pipeline creation without hardcoding project-specific logic. - - Example: - # Register a factory - @pipeline_registry.register - class MyProjectPipelineFactory(BasePipelineFactory): - @classmethod - def get_project_name(cls) -> str: - return "my_project" - ... - - # Create a pipeline - pipeline = pipeline_registry.create_pipeline( - "my_project", model_spec, pytorch_model - ) - """ - - def __init__(self): - self._factories: Dict[str, Type[BasePipelineFactory]] = {} - - def register(self, factory_cls: Type[BasePipelineFactory]) -> Type[BasePipelineFactory]: - """ - Register a pipeline factory class. - - Can be used as a decorator or called directly. - - Args: - factory_cls: Factory class implementing BasePipelineFactory - - Returns: - The registered factory class (for decorator usage) - - Example: - @pipeline_registry.register - class CenterPointPipelineFactory(BasePipelineFactory): - ... - """ - if not issubclass(factory_cls, BasePipelineFactory): - raise TypeError(f"Factory class must inherit from BasePipelineFactory, " f"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]: - """ - Get the factory class for a project. - - Args: - project_name: Name of the project - - Returns: - Factory class for the project - - Raises: - KeyError: If project is not registered - """ - if project_name not in self._factories: - available = list(self._factories.keys()) - raise KeyError(f"No factory registered for project '{project_name}'. " f"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: - """ - 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 - """ - 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: - """ - List all registered projects. - - Returns: - List of registered project names - """ - return list(self._factories.keys()) - - def is_registered(self, project_name: str) -> bool: - """ - Check if a project is registered. - - Args: - project_name: Name of the project - - Returns: - True if project is registered - """ - return project_name in self._factories - - def reset(self) -> None: - """ - Reset the registry (mainly for testing). - """ - self._factories.clear() - - -# Global registry instance -pipeline_registry = PipelineRegistry() diff --git a/deployment/pipelines/factory.py b/deployment/pipelines/factory.py index acc36eae5..b7ef2290f 100644 --- a/deployment/pipelines/factory.py +++ b/deployment/pipelines/factory.py @@ -15,7 +15,7 @@ pipeline = PipelineFactory.create("centerpoint", model_spec, pytorch_model) # Or use registry directly: - from deployment.pipelines.common import pipeline_registry + from deployment.pipelines.registry import pipeline_registry pipeline = pipeline_registry.create_pipeline("centerpoint", model_spec, pytorch_model) """ @@ -23,8 +23,8 @@ from typing import Any, List, Optional from deployment.core.evaluation.evaluator_types import ModelSpec -from deployment.pipelines.common.base_pipeline import BaseDeploymentPipeline -from deployment.pipelines.common.registry import pipeline_registry +from deployment.pipelines.base_pipeline import BaseDeploymentPipeline +from deployment.pipelines.registry import pipeline_registry logger = logging.getLogger(__name__) diff --git a/deployment/pipelines/gpu_resource_mixin.py b/deployment/pipelines/gpu_resource_mixin.py new file mode 100644 index 000000000..dd07da7cf --- /dev/null +++ b/deployment/pipelines/gpu_resource_mixin.py @@ -0,0 +1,123 @@ +""" +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: + if torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.synchronize() + + +class GPUResourceMixin(ABC): + _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: + 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: + 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..7e6b66f2a --- /dev/null +++ b/deployment/pipelines/registry.py @@ -0,0 +1,69 @@ +""" +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: + 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..615c03c7a --- /dev/null +++ b/deployment/projects/centerpoint/cli.py @@ -0,0 +1,13 @@ +"""CenterPoint CLI extensions.""" + +from __future__ import annotations + +import argparse + + +def add_args(parser: argparse.ArgumentParser) -> None: + 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..e270b3b27 --- /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="both", + 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/projects/CenterPoint/deploy/data_loader.py b/deployment/projects/centerpoint/data_loader.py similarity index 52% rename from projects/CenterPoint/deploy/data_loader.py rename to deployment/projects/centerpoint/data_loader.py index c19dd8eae..ed65cd45b 100644 --- a/projects/CenterPoint/deploy/data_loader.py +++ b/deployment/projects/centerpoint/data_loader.py @@ -1,8 +1,7 @@ """ CenterPoint DataLoader for deployment. -This module implements the BaseDataLoader interface for CenterPoint 3D detection -using MMDet3D's preprocessing pipeline. +Moved from projects/CenterPoint/deploy/data_loader.py into the unified deployment bundle. """ import os @@ -17,18 +16,6 @@ class CenterPointDataLoader(BaseDataLoader): - """ - DataLoader for CenterPoint 3D object detection. - - This loader uses MMDet3D's preprocessing pipeline to ensure consistency - between training and deployment. - - Attributes: - info_file: Path to info.pkl file containing dataset information - pipeline: MMDet3D preprocessing pipeline - data_infos: List of data information dictionaries - """ - def __init__( self, info_file: str, @@ -36,20 +23,6 @@ def __init__( device: str = "cpu", task_type: Optional[str] = None, ): - """ - Initialize CenterPoint DataLoader. - - Args: - info_file: Path to info.pkl file (e.g., centerpoint_infos_val.pkl) - model_cfg: Model configuration containing test pipeline - device: Device to load tensors on ('cpu', 'cuda', etc.) - task_type: Task type for pipeline building. If None, will try to get from - model_cfg.task_type or model_cfg.deploy.task_type. - - Raises: - FileNotFoundError: If info_file doesn't exist - ValueError: If info file format is invalid - """ super().__init__( config={ "info_file": info_file, @@ -57,7 +30,6 @@ def __init__( } ) - # Validate info file if not os.path.exists(info_file): raise FileNotFoundError(f"Info file not found: {info_file}") @@ -65,11 +37,7 @@ def __init__( self.model_cfg = model_cfg self.device = device - # Load info.pkl self.data_infos = self._load_info_file() - - # Build preprocessing pipeline - # task_type should be provided from deploy_config self.pipeline = build_preprocessing_pipeline(model_cfg, task_type=task_type) def _to_tensor( @@ -77,19 +45,6 @@ def _to_tensor( data: Union[torch.Tensor, np.ndarray, List[Union[torch.Tensor, np.ndarray]]], name: str = "data", ) -> torch.Tensor: - """ - Convert various data types to a torch.Tensor on the target device. - - Args: - data: Input data (torch.Tensor, np.ndarray, or list of either) - name: Name of the data for error messages - - Returns: - torch.Tensor on self.device - - Raises: - ValueError: If data type is unsupported or list is empty - """ if isinstance(data, torch.Tensor): return data.to(self.device) @@ -107,41 +62,27 @@ def _to_tensor( return torch.from_numpy(first_item).to(self.device) raise ValueError( - f"Unexpected type for {name}[0]: {type(first_item)}. " f"Expected torch.Tensor or np.ndarray." + f"Unexpected type for {name}[0]: {type(first_item)}. Expected torch.Tensor or np.ndarray." ) raise ValueError( - f"Unexpected type for '{name}': {type(data)}. " - f"Expected torch.Tensor, np.ndarray, or list of tensors/arrays." + f"Unexpected type for '{name}': {type(data)}. Expected torch.Tensor, np.ndarray, or list of tensors/arrays." ) def _load_info_file(self) -> list: - """ - Load and parse info.pkl file. - - Returns: - List of data information dictionaries - - Raises: - ValueError: If file format is invalid - """ 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 - # Extract data_list if isinstance(data, dict): if "data_list" in data: data_list = data["data_list"] elif "infos" in data: - # Alternative key name data_list = data["infos"] else: - raise ValueError( - f"Expected 'data_list' or 'infos' key in info file, " f"found keys: {list(data.keys())}" - ) + raise ValueError(f"Expected 'data_list' or 'infos' in info file, found keys: {list(data.keys())}") elif isinstance(data, list): data_list = data else: @@ -153,63 +94,37 @@ def _load_info_file(self) -> list: return data_list def load_sample(self, index: int) -> Dict[str, Any]: - """ - Load a single sample with point cloud and annotations. - - Args: - index: Sample index to load - - Returns: - Dictionary containing: - - lidar_points: Dict with lidar_path - - gt_bboxes_3d: 3D bounding boxes (if available) - - gt_labels_3d: 3D labels (if available) - - Additional metadata - - Raises: - IndexError: If index is out of range - """ 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] - # Extract lidar points info lidar_points = info.get("lidar_points", {}) if not lidar_points: - # Try alternative key lidar_path = info.get("lidar_path", info.get("velodyne_path", "")) lidar_points = {"lidar_path": lidar_path} - # Add data_root to lidar_path if it's relative if "lidar_path" in lidar_points and not lidar_points["lidar_path"].startswith("/"): - # Get data_root from model config data_root = getattr(self.model_cfg, "data_root", "data/t4dataset/") - # Ensure data_root ends with '/' if not data_root.endswith("/"): data_root += "/" - # Check if the path already starts with data_root to avoid duplication if not lidar_points["lidar_path"].startswith(data_root): lidar_points["lidar_path"] = data_root + lidar_points["lidar_path"] - # Extract annotations (if available) instances = info.get("instances", []) sample = { "lidar_points": lidar_points, "sample_idx": info.get("sample_idx", index), - "timestamp": info.get("timestamp", 0), # Add timestamp for pipeline + "timestamp": info.get("timestamp", 0), } - # Add ground truth if available if instances: - # Extract 3D bounding boxes and labels from instances gt_bboxes_3d = [] gt_labels_3d = [] for instance in instances: if "bbox_3d" in instance and "bbox_label_3d" in instance: - # Check if bbox is valid if instance.get("bbox_3d_isvalid", True): gt_bboxes_3d.append(instance["bbox_3d"]) gt_labels_3d.append(instance["bbox_label_3d"]) @@ -218,7 +133,6 @@ def load_sample(self, index: int) -> Dict[str, Any]: sample["gt_bboxes_3d"] = np.array(gt_bboxes_3d, dtype=np.float32) sample["gt_labels_3d"] = np.array(gt_labels_3d, dtype=np.int64) - # Add camera info if available (for multi-modal models) if "images" in info or "img_path" in info: sample["images"] = info.get("images", {}) if "img_path" in info: @@ -227,83 +141,39 @@ def load_sample(self, index: int) -> Dict[str, Any]: return sample def preprocess(self, sample: Dict[str, Any]) -> Union[Dict[str, torch.Tensor], torch.Tensor]: - """ - Preprocess using MMDet3D pipeline. - - For CenterPoint, the test pipeline typically outputs only 'points' (not voxelized). - Voxelization is performed by the model's data_preprocessor during inference. - This method returns the point cloud tensor for use by the deployment pipeline. - - Args: - sample: Sample dictionary from load_sample() - - Returns: - Dictionary containing: - - points: Point cloud tensor [N, point_features] (typically [N, 5] for x, y, z, intensity, timestamp) - - Raises: - ValueError: If pipeline output format is unexpected - """ - # Apply pipeline results = self.pipeline(sample) - # Validate expected format (MMDet3D 3.x format) if "inputs" not in results: raise ValueError( - f"Expected 'inputs' key in pipeline results (MMDet3D 3.x format). " + "Expected 'inputs' key in pipeline results (MMDet3D 3.x format). " f"Found keys: {list(results.keys())}. " - f"Please ensure your test pipeline includes Pack3DDetInputs transform." + "Please ensure your test pipeline includes Pack3DDetInputs transform." ) pipeline_inputs = results["inputs"] - - # For CenterPoint, pipeline should output 'points' (voxelization happens in data_preprocessor) if "points" not in pipeline_inputs: available_keys = list(pipeline_inputs.keys()) raise ValueError( - f"Expected 'points' key in pipeline inputs for CenterPoint. " + "Expected 'points' key in pipeline inputs for CenterPoint. " f"Available keys: {available_keys}. " - f"Note: For CenterPoint, voxelization is performed by the model's data_preprocessor, " - f"not in the test pipeline. The pipeline should output raw points using Pack3DDetInputs." + "For CenterPoint, voxelization is performed by the model's data_preprocessor." ) - # Convert points to tensor using helper points_tensor = self._to_tensor(pipeline_inputs["points"], name="points") - - # Validate points shape 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: - """ - Get total number of samples. - - Returns: - Number of samples in the dataset - """ return len(self.data_infos) def get_ground_truth(self, index: int) -> Dict[str, Any]: - """ - Get ground truth annotations for evaluation. - - Args: - index: Sample index - - Returns: - Dictionary containing: - - gt_bboxes_3d: 3D bounding boxes (N, 7) where 7 = (x, y, z, w, l, h, yaw) - - gt_labels_3d: 3D class labels (N,) - - sample_idx: Sample identifier - """ 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)) - # Convert to numpy if needed 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)): @@ -316,19 +186,7 @@ def get_ground_truth(self, index: int) -> Dict[str, Any]: } def get_class_names(self) -> List[str]: - """ - Get class names from config. - - Returns: - List of class names - - Raises: - ValueError: If class_names not found in model_cfg - """ if hasattr(self.model_cfg, "class_names"): return self.model_cfg.class_names - raise ValueError( - "class_names must be defined in model_cfg. " - "Check your model config file includes class_names definition." - ) + 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..f1ab42eae --- /dev/null +++ b/deployment/projects/centerpoint/entrypoint.py @@ -0,0 +1,61 @@ +"""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: + 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) + if metrics_config is None: + # Fall back to sane defaults (Detection3DMetricsConfig will populate defaults in __post_init__) + class_names = getattr(model_cfg, "class_names", None) + if not class_names: + raise ValueError("model_cfg.class_names is required for CenterPoint evaluation") + metrics_config = Detection3DMetricsConfig(class_names=list(class_names), frame_id="base_link") + logger.warning("T4MetricV2 config not found in model_cfg; using default deployment metrics config.") + + 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/projects/CenterPoint/deploy/evaluator.py b/deployment/projects/centerpoint/evaluator.py similarity index 67% rename from projects/CenterPoint/deploy/evaluator.py rename to deployment/projects/centerpoint/evaluator.py index ac1f70637..6b64b8d06 100644 --- a/projects/CenterPoint/deploy/evaluator.py +++ b/deployment/projects/centerpoint/evaluator.py @@ -1,15 +1,12 @@ """ CenterPoint Evaluator for deployment. -This module implements evaluation for CenterPoint 3D object detection models. -Uses autoware_perception_evaluation via Detection3DMetricsInterface for consistent -metric computation between training (T4MetricV2) and deployment. +Moved from projects/CenterPoint/deploy/evaluator.py into the unified deployment bundle. """ import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple -import torch from mmengine.config import Config from deployment.core import ( @@ -21,45 +18,23 @@ TaskProfile, ) from deployment.core.io.base_data_loader import BaseDataLoader -from deployment.pipelines import PipelineFactory, ProjectNames -from projects.CenterPoint.deploy.configs.deploy_config import model_io +from deployment.pipelines.factory import PipelineFactory +from deployment.projects.centerpoint.config.deploy_config import model_io logger = logging.getLogger(__name__) class CenterPointEvaluator(BaseEvaluator): - """ - Evaluator for CenterPoint 3D object detection. - - Extends BaseEvaluator with CenterPoint-specific: - - Pipeline creation (multi-stage 3D detection) - - Point cloud input preparation - - 3D bounding box ground truth parsing - - Detection3DMetricsInterface integration - """ - def __init__( self, model_cfg: Config, metrics_config: Detection3DMetricsConfig, ): - """ - Initialize CenterPoint evaluator. - - Args: - model_cfg: Model configuration. - metrics_config: Configuration for the metrics interface. - """ - # Determine class names - must come from config 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. " - "Check your model config file includes class_names definition." - ) + raise ValueError("class_names must be provided via model_cfg.class_names.") - # Create task profile task_profile = TaskProfile( task_name="centerpoint_3d_detection", display_name="CenterPoint 3D Object Detection", @@ -76,21 +51,14 @@ def __init__( ) def set_onnx_config(self, model_cfg: Config) -> None: - """Set ONNX-compatible model config.""" self.model_cfg = model_cfg - # ================== VerificationMixin Override ================== - def _get_output_names(self) -> List[str]: - """Provide meaningful names for CenterPoint head outputs.""" return list(model_io["head_output_names"]) - # ================== BaseEvaluator Implementation ================== - def _create_pipeline(self, model_spec: ModelSpec, device: str) -> Any: - """Create CenterPoint pipeline.""" return PipelineFactory.create( - project_name=ProjectNames.CENTERPOINT, + project_name="centerpoint", model_spec=model_spec, pytorch_model=self.pytorch_model, device=device, @@ -102,7 +70,6 @@ def _prepare_input( data_loader: BaseDataLoader, device: str, ) -> Tuple[Any, Dict[str, Any]]: - """Prepare point cloud input for CenterPoint.""" if "points" in sample: points = sample["points"] else: @@ -113,12 +80,9 @@ def _prepare_input( return points, metadata def _parse_predictions(self, pipeline_output: Any) -> List[Dict]: - """Parse CenterPoint predictions (already in standard format).""" - # Pipeline already returns list of dicts with bbox_3d, score, label return pipeline_output if isinstance(pipeline_output, list) else [] def _parse_ground_truths(self, gt_data: Dict[str, Any]) -> List[Dict]: - """Parse 3D ground truth bounding boxes.""" ground_truths = [] if "gt_bboxes_3d" in gt_data and "gt_labels_3d" in gt_data: @@ -126,17 +90,11 @@ def _parse_ground_truths(self, gt_data: Dict[str, Any]) -> List[Dict]: 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]), - } - ) + 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: - """Add frame to Detection3DMetricsInterface.""" self.metrics_interface.add_frame(predictions, ground_truths) def _build_results( @@ -145,16 +103,12 @@ def _build_results( latency_breakdowns: List[Dict[str, float]], num_samples: int, ) -> EvalResultDict: - """Build CenterPoint evaluation results.""" - # Compute latency statistics latency_stats = self.compute_latency_stats(latencies) latency_payload = latency_stats.to_dict() - # Add stage-wise breakdown if available if latency_breakdowns: latency_payload["latency_breakdown"] = self._compute_latency_breakdown(latency_breakdowns).to_dict() - # Get metrics from interface 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 @@ -169,18 +123,17 @@ def _build_results( } def print_results(self, results: EvalResultDict) -> None: - """Pretty print evaluation results.""" print("\n" + "=" * 80) print(f"{self.task_profile.display_name} - Evaluation Results") print("(Using autoware_perception_evaluation for consistent metrics)") print("=" * 80) - print(f"\nDetection Metrics:") + 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(f"\nPer-Class AP:") + print("\nPer-Class AP:") for class_id, ap in results["per_class_ap"].items(): class_name = ( class_id @@ -191,7 +144,7 @@ def print_results(self, results: EvalResultDict) -> None: if "latency" in results: latency = results["latency"] - print(f"\nLatency Statistics:") + 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") @@ -200,12 +153,10 @@ def print_results(self, results: EvalResultDict) -> None: if "latency_breakdown" in latency: breakdown = latency["latency_breakdown"] - print(f"\nStage-wise Latency Breakdown:") - # Sub-stages that belong under "Model" + 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() - # Use extra indentation for model sub-stages if stage in model_substages: print(f" {stage_name:16s}: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms") else: diff --git a/projects/CenterPoint/deploy/component_extractor.py b/deployment/projects/centerpoint/export/component_extractor.py similarity index 61% rename from projects/CenterPoint/deploy/component_extractor.py rename to deployment/projects/centerpoint/export/component_extractor.py index f690d5892..92afd3563 100644 --- a/projects/CenterPoint/deploy/component_extractor.py +++ b/deployment/projects/centerpoint/export/component_extractor.py @@ -1,9 +1,7 @@ """ CenterPoint-specific component extractor. -This module contains all CenterPoint-specific logic for extracting -exportable model components. It implements the ModelComponentExtractor -interface from the deployment framework. +Moved from projects/CenterPoint/deploy/component_extractor.py into the unified deployment bundle. """ import logging @@ -13,68 +11,30 @@ from deployment.exporters.common.configs import ONNXExportConfig from deployment.exporters.export_pipelines.interfaces import ExportableComponent, ModelComponentExtractor -from projects.CenterPoint.deploy.configs.deploy_config import model_io, onnx_config +from deployment.projects.centerpoint.config.deploy_config import model_io, onnx_config from projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX logger = logging.getLogger(__name__) class CenterPointComponentExtractor(ModelComponentExtractor): - """ - Extracts exportable components from CenterPoint model. - - CenterPoint uses a multi-stage architecture that requires multi-file ONNX export: - 1. Voxel Encoder: Converts voxels to features - 2. Backbone+Neck+Head: Detection head - - This extractor handles all CenterPoint-specific logic: - - Feature extraction from model - - Creating combined backbone+neck+head module - - Preparing sample inputs for each component - - Configuring ONNX export settings - """ - def __init__(self, logger: logging.Logger = None, simplify: bool = True): - """ - Initialize extractor. - - Args: - logger: Optional logger instance - simplify: Whether to run onnx-simplifier for the exported parts - """ self.logger = logger or logging.getLogger(__name__) self.simplify = simplify def extract_components(self, model: torch.nn.Module, sample_data: Any) -> List[ExportableComponent]: - """ - Extract CenterPoint components for ONNX export. - - Args: - model: CenterPoint PyTorch model - sample_data: Tuple of (input_features, voxel_dict) from preprocessing - - Returns: - List containing two components: voxel encoder and backbone+neck+head - """ - # Unpack sample data input_features, voxel_dict = sample_data - self.logger.info("Extracting CenterPoint components for export...") - # Component 1: Voxel Encoder voxel_component = self._create_voxel_encoder_component(model, input_features) - - # Component 2: Backbone+Neck+Head 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: - """Create exportable voxel encoder component.""" voxel_cfg = onnx_config["components"]["voxel_encoder"] return ExportableComponent( name=voxel_cfg["name"], @@ -97,14 +57,8 @@ def _create_voxel_encoder_component( def _create_backbone_component( self, model: torch.nn.Module, input_features: torch.Tensor, voxel_dict: dict ) -> ExportableComponent: - """Create exportable backbone+neck+head component.""" - # Prepare backbone input by running through voxel and middle encoders backbone_input = self._prepare_backbone_input(model, input_features, voxel_dict) - - # Create combined backbone+neck+head module backbone_module = self._create_backbone_module(model) - - # Get output names output_names = self._get_output_names(model) dynamic_axes = { @@ -132,62 +86,28 @@ def _create_backbone_component( def _prepare_backbone_input( self, model: torch.nn.Module, input_features: torch.Tensor, voxel_dict: dict ) -> torch.Tensor: - """ - Prepare input tensor for backbone export by running inference. - - This runs the voxel encoder and middle encoder to generate - spatial features that will be the input to the backbone. - """ with torch.no_grad(): - # Run voxel encoder voxel_features = model.pts_voxel_encoder(input_features).squeeze(1) - - # Get coordinates and batch size coors = voxel_dict["coors"] batch_size = int(coors[-1, 0].item()) + 1 if len(coors) > 0 else 1 - - # Run middle encoder (sparse convolution) 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: - """ - Create combined backbone+neck+head module for ONNX export. - - """ - return CenterPointHeadONNX(model.pts_backbone, model.pts_neck, model.pts_bbox_head) def _get_output_names(self, model: torch.nn.Module) -> Tuple[str, ...]: - """Get output names from model or use defaults from constants.""" 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]: - """ - Extract features using model's internal method. - - This is a helper method that wraps the model's _extract_features method, - which is used during ONNX export to get sample data. - - Args: - model: CenterPoint model - data_loader: Data loader - sample_idx: Sample index - - Returns: - Tuple of (input_features, voxel_dict) - """ if hasattr(model, "_extract_features"): return model._extract_features(data_loader, sample_idx) - else: - raise AttributeError( - "CenterPoint model must have _extract_features method for ONNX export. " - "Please ensure the model is built with ONNX compatibility." - ) + 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/exporters/centerpoint/onnx_export_pipeline.py b/deployment/projects/centerpoint/export/onnx_export_pipeline.py similarity index 71% rename from deployment/exporters/centerpoint/onnx_export_pipeline.py rename to deployment/projects/centerpoint/export/onnx_export_pipeline.py index 749306339..f6a493d6e 100644 --- a/deployment/exporters/centerpoint/onnx_export_pipeline.py +++ b/deployment/projects/centerpoint/export/onnx_export_pipeline.py @@ -1,9 +1,7 @@ """ CenterPoint ONNX export pipeline using composition. -This pipeline orchestrates multi-file ONNX export for CenterPoint models. -It uses the ModelComponentExtractor pattern to keep model-specific logic -separate from the generic export pipeline. +Moved from deployment/exporters/centerpoint/onnx_export_pipeline.py into the CenterPoint deployment bundle. """ from __future__ import annotations @@ -23,17 +21,6 @@ class CenterPointONNXExportPipeline(OnnxExportPipeline): - """ - CenterPoint ONNX export pipeline. - - Orchestrates multi-file ONNX export using a generic ONNXExporter and - CenterPointComponentExtractor for model-specific logic. - - Components exported: - - voxel encoder → pts_voxel_encoder.onnx - - backbone+neck+head → pts_backbone_neck_head.onnx - """ - def __init__( self, exporter_factory: type[ExporterFactory], @@ -41,15 +28,6 @@ def __init__( config: BaseDeploymentConfig, logger: Optional[logging.Logger] = None, ): - """ - Initialize CenterPoint ONNX export pipeline. - - Args: - exporter_factory: Factory class for creating exporters - component_extractor: Extracts model components (injected from projects/) - config: Deployment configuration - logger: Optional logger instance - """ self.exporter_factory = exporter_factory self.component_extractor = component_extractor self.config = config @@ -64,23 +42,6 @@ def export( config: BaseDeploymentConfig, sample_idx: int = 0, ) -> Artifact: - """ - Export CenterPoint model to multi-file ONNX format. - - Args: - model: CenterPoint PyTorch model - data_loader: Data loader for extracting sample features - output_dir: Output directory for ONNX files - config: Deployment configuration (not used, kept for interface) - sample_idx: Sample index to use for feature extraction - - Returns: - Artifact pointing to output directory with multi_file=True - - Raises: - AttributeError: If component extractor doesn't have extract_features method - RuntimeError: If feature extraction or export fails - """ output_dir_path = Path(output_dir) output_dir_path.mkdir(parents=True, exist_ok=True) @@ -93,9 +54,6 @@ def export( return Artifact(path=str(output_dir_path), multi_file=True) - # --------------------------------------------------------------------- # - # Internal helpers - # --------------------------------------------------------------------- # def _log_header(self, output_dir: Path, sample_idx: int) -> None: self.logger.info("=" * 80) self.logger.info("Exporting CenterPoint to ONNX (multi-file)") @@ -150,7 +108,6 @@ def _export_components( return tuple(exported_paths) def _build_onnx_exporter(self): - # CenterPoint does not require special wrapping, so IdentityWrapper suffices. return self.exporter_factory.create_onnx_exporter( config=self.config, wrapper_cls=IdentityWrapper, diff --git a/deployment/exporters/centerpoint/tensorrt_export_pipeline.py b/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py similarity index 56% rename from deployment/exporters/centerpoint/tensorrt_export_pipeline.py rename to deployment/projects/centerpoint/export/tensorrt_export_pipeline.py index 72f0b0c7a..2f645deec 100644 --- a/deployment/exporters/centerpoint/tensorrt_export_pipeline.py +++ b/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py @@ -1,14 +1,12 @@ """ CenterPoint TensorRT export pipeline using composition. -This pipeline orchestrates multi-file TensorRT export for CenterPoint models. -It converts multiple ONNX files to TensorRT engines. +Moved from deployment/exporters/centerpoint/tensorrt_export_pipeline.py into the CenterPoint deployment bundle. """ from __future__ import annotations import logging -import os import re from pathlib import Path from typing import List, Optional @@ -21,14 +19,6 @@ class CenterPointTensorRTExportPipeline(TensorRTExportPipeline): - """ - CenterPoint TensorRT export pipeline. - - Converts every ONNX file in the export directory to a TensorRT engine by - following a simple naming convention (``foo.onnx`` → ``foo.engine``). - """ - - # Pattern for validating CUDA device strings _CUDA_DEVICE_PATTERN = re.compile(r"^cuda:\d+$") def __init__( @@ -37,34 +27,14 @@ def __init__( config: BaseDeploymentConfig, logger: Optional[logging.Logger] = None, ): - """ - Initialize CenterPoint TensorRT export pipeline. - - Args: - exporter_factory: Factory class for creating exporters - config: Deployment configuration - logger: Optional logger instance - """ self.exporter_factory = exporter_factory self.config = config self.logger = logger or logging.getLogger(__name__) def _validate_cuda_device(self, device: str) -> int: - """ - Validate CUDA device string and extract device ID. - - Args: - device: Device string (expected format: "cuda:N") - - Returns: - Device ID as integer - - Raises: - ValueError: If device format is invalid - """ if not self._CUDA_DEVICE_PATTERN.match(device): raise ValueError( - f"Invalid CUDA device format: '{device}'. " f"Expected format: 'cuda:N' (e.g., 'cuda:0', 'cuda:1')" + f"Invalid CUDA device format: '{device}'. Expected format: 'cuda:N' (e.g., 'cuda:0', 'cuda:1')" ) return int(device.split(":")[1]) @@ -77,30 +47,10 @@ def export( device: str, data_loader: BaseDataLoader, ) -> Artifact: - """ - Export CenterPoint ONNX files to TensorRT engines. - - Args: - onnx_path: Path to directory containing ONNX files - output_dir: Output directory for TensorRT engines - config: Deployment configuration (not used, kept for interface) - device: CUDA device string (e.g., "cuda:0") - data_loader: Data loader (not used for TensorRT) - - Returns: - Artifact pointing to output directory with multi_file=True - - Raises: - ValueError: If device format is invalid or onnx_path is not a directory - FileNotFoundError: If ONNX files are missing - RuntimeError: If TensorRT conversion fails - """ onnx_dir = onnx_path - # Validate inputs 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") @@ -108,7 +58,6 @@ def export( if not onnx_dir_path.is_dir(): raise ValueError(f"onnx_path must be a directory for multi-file export, got: {onnx_dir}") - # Validate and set CUDA device device_id = self._validate_cuda_device(device) torch.cuda.set_device(device_id) self.logger.info(f"Using CUDA device: {device}") @@ -127,17 +76,12 @@ def export( self.logger.info(f"\n[{i}/{num_files}] Converting {onnx_file.name} → {trt_path.name}...") exporter = self._build_tensorrt_exporter() - try: - artifact = exporter.export( - model=None, # Not needed for TensorRT conversion - sample_input=None, # Shape info comes from config.model_inputs - output_path=str(trt_path), - onnx_path=str(onnx_file), - ) - except Exception as exc: - self.logger.error(f"Failed to convert {onnx_file.name} to TensorRT", exc_info=exc) - raise RuntimeError(f"TensorRT export failed for {onnx_file.name}") from exc - + 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}") diff --git a/projects/CenterPoint/deploy/utils.py b/deployment/projects/centerpoint/model_loader.py similarity index 50% rename from projects/CenterPoint/deploy/utils.py rename to deployment/projects/centerpoint/model_loader.py index 189d2a8a1..36041ae00 100644 --- a/projects/CenterPoint/deploy/utils.py +++ b/deployment/projects/centerpoint/model_loader.py @@ -1,8 +1,7 @@ """ -CenterPoint deployment utilities. +CenterPoint deployment utilities: ONNX-compatible model building and metrics config extraction. -This module provides utility functions for CenterPoint model deployment, -including ONNX-compatible config creation and model building. +Moved from projects/CenterPoint/deploy/utils.py into the unified deployment bundle. """ import copy @@ -14,7 +13,7 @@ from mmengine.registry import MODELS, init_default_scope from mmengine.runner import load_checkpoint -from deployment.core import Detection3DMetricsConfig +from deployment.core.metrics.detection_3d_metrics import Detection3DMetricsConfig def create_onnx_model_cfg( @@ -22,17 +21,6 @@ def create_onnx_model_cfg( device: str, rot_y_axis_reference: bool = False, ) -> Config: - """ - Create an ONNX-friendly CenterPoint config. - - Args: - model_cfg: Original model configuration - device: Device string (e.g., "cpu", "cuda:0") - rot_y_axis_reference: Whether to use y-axis rotation reference - - Returns: - ONNX-compatible model configuration - """ onnx_cfg = model_cfg.copy() model_config = copy.deepcopy(onnx_cfg.model) @@ -60,17 +48,6 @@ def create_onnx_model_cfg( def build_model_from_cfg(model_cfg: Config, checkpoint_path: str, device: str) -> torch.nn.Module: - """ - Build and load a model from config + checkpoint on the given device. - - Args: - model_cfg: Model configuration - checkpoint_path: Path to checkpoint file - device: Device string (e.g., "cpu", "cuda:0") - - Returns: - Loaded PyTorch model - """ init_default_scope("mmdet3d") model_config = copy.deepcopy(model_cfg.model) model = MODELS.build(model_config) @@ -87,33 +64,12 @@ def build_centerpoint_onnx_model( device: str, rot_y_axis_reference: bool = False, ) -> Tuple[torch.nn.Module, Config]: - """ - Build an ONNX-friendly CenterPoint model from the *original* model_cfg. - - This is the single source of truth for building CenterPoint models from - original config + checkpoint to ONNX-compatible model. - - Args: - base_model_cfg: Original model configuration (mmdet3d config) - checkpoint_path: Path to checkpoint file - device: Device string (e.g., "cpu", "cuda:0") - rot_y_axis_reference: Whether to use y-axis rotation reference - - Returns: - Tuple of: - - model: loaded torch.nn.Module (ONNX-compatible) - - onnx_cfg: ONNX-compatible Config actually used to build the model - """ - # 1) Convert original cfg to ONNX-friendly cfg onnx_cfg = create_onnx_model_cfg( base_model_cfg, device=device, rot_y_axis_reference=rot_y_axis_reference, ) - - # 2) Use shared build_model_from_cfg to load checkpoint model = build_model_from_cfg(onnx_cfg, checkpoint_path, device=device) - return model, onnx_cfg @@ -122,38 +78,15 @@ def extract_t4metric_v2_config( class_names: Optional[List[str]] = None, logger: Optional[logging.Logger] = None, ) -> Optional[Detection3DMetricsConfig]: - """ - Extract T4MetricV2 configuration from model config. - - This function extracts evaluation settings from T4MetricV2 evaluator config - in the model config to ensure deployment evaluation uses the same settings - as training evaluation. - - Args: - model_cfg: Model configuration (may contain val_evaluator or test_evaluator with T4MetricV2 settings) - class_names: Optional list of class names. If not provided, will be extracted from model_cfg. - logger: Optional logger instance for logging - - Returns: - Detection3DMetricsConfig with settings from model_cfg, or None if T4MetricV2 config not found - - Note: - Only supports T4MetricV2. T4Metric (v1) is not supported. - """ if logger is None: logger = logging.getLogger(__name__) - # Get class names - must come from config or explicit parameter 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 either explicitly or via model_cfg.class_names. " - "Check your model config file includes class_names definition." - ) + raise ValueError("class_names must be provided or defined in model_cfg.class_names") - # Try to extract T4MetricV2 configs from val_evaluator or test_evaluator evaluator_cfg = None if hasattr(model_cfg, "val_evaluator"): evaluator_cfg = model_cfg.val_evaluator @@ -163,7 +96,6 @@ def extract_t4metric_v2_config( logger.warning("No val_evaluator or test_evaluator found in model_cfg") return None - # Helper to get value from dict or ConfigDict def get_cfg_value(cfg, key, default=None): if cfg is None: return default @@ -171,30 +103,18 @@ def get_cfg_value(cfg, key, default=None): return cfg.get(key, default) return getattr(cfg, key, default) - # Check if evaluator is T4MetricV2 evaluator_type = get_cfg_value(evaluator_cfg, "type") if evaluator_type != "T4MetricV2": - logger.warning( - f"Evaluator type is '{evaluator_type}', not 'T4MetricV2'. " "Only T4MetricV2 is supported. Returning None." - ) + logger.warning(f"Evaluator type is '{evaluator_type}', not 'T4MetricV2'. Returning None.") return None - logger.info("=" * 60) - logger.info("Extracting evaluation settings for deployment...") - logger.info("=" * 60) - - # Extract perception_evaluator_configs 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") - # Extract critical_object_filter_config critical_object_filter_config = get_cfg_value(evaluator_cfg, "critical_object_filter_config") - - # Extract frame_pass_fail_config frame_pass_fail_config = get_cfg_value(evaluator_cfg, "frame_pass_fail_config") - # Convert ConfigDict to regular dict if needed 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"): @@ -202,24 +122,6 @@ def get_cfg_value(cfg, key, default=None): if frame_pass_fail_config and hasattr(frame_pass_fail_config, "to_dict"): frame_pass_fail_config = dict(frame_pass_fail_config) - logger.info(f"Extracted settings:") - logger.info(f" - frame_id: {frame_id}") - if evaluation_config_dict: - logger.info(f" - evaluation_config_dict: {list(evaluation_config_dict.keys())}") - if "center_distance_bev_thresholds" in evaluation_config_dict: - logger.info( - f" - center_distance_bev_thresholds: " f"{evaluation_config_dict['center_distance_bev_thresholds']}" - ) - if critical_object_filter_config: - logger.info(f" - critical_object_filter_config: enabled") - if "max_distance_list" in critical_object_filter_config: - logger.info(f" - max_distance_list: " f"{critical_object_filter_config['max_distance_list']}") - if frame_pass_fail_config: - logger.info(f" - frame_pass_fail_config: enabled") - if "matching_threshold_list" in frame_pass_fail_config: - logger.info(f" - matching_threshold_list: " f"{frame_pass_fail_config['matching_threshold_list']}") - logger.info("=" * 60) - return Detection3DMetricsConfig( class_names=class_names, frame_id=frame_id, diff --git a/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py b/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py new file mode 100644 index 000000000..513bf5810 --- /dev/null +++ b/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py @@ -0,0 +1,150 @@ +""" +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): + 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..d5eaf3d7d --- /dev/null +++ b/deployment/projects/centerpoint/pipelines/factory.py @@ -0,0 +1,62 @@ +""" +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): + @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/pipelines/centerpoint/centerpoint_onnx.py b/deployment/projects/centerpoint/pipelines/onnx.py similarity index 57% rename from deployment/pipelines/centerpoint/centerpoint_onnx.py rename to deployment/projects/centerpoint/pipelines/onnx.py index 10956f776..07f63c8be 100644 --- a/deployment/pipelines/centerpoint/centerpoint_onnx.py +++ b/deployment/projects/centerpoint/pipelines/onnx.py @@ -1,8 +1,7 @@ """ CenterPoint ONNX Pipeline Implementation. -This module implements the CenterPoint pipeline using ONNX Runtime, -optimizing voxel encoder and backbone/head while keeping middle encoder in PyTorch. +Moved from deployment/pipelines/centerpoint/centerpoint_onnx.py into the CenterPoint deployment bundle. """ import logging @@ -13,57 +12,32 @@ import onnxruntime as ort import torch -from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline +from deployment.projects.centerpoint.pipelines.centerpoint_pipeline import CenterPointDeploymentPipeline logger = logging.getLogger(__name__) class CenterPointONNXPipeline(CenterPointDeploymentPipeline): - """ - ONNX Runtime implementation of CenterPoint pipeline. - - Uses ONNX Runtime for voxel encoder and backbone/head, - while keeping middle encoder in PyTorch (sparse convolution cannot be converted). - - Provides good cross-platform compatibility and moderate speedup. - """ - def __init__(self, pytorch_model, onnx_dir: str, device: str = "cpu"): - """ - Initialize ONNX pipeline. - - Args: - pytorch_model: PyTorch model (for preprocessing, middle encoder, postprocessing) - onnx_dir: Directory containing ONNX model files - device: Device for inference ('cpu' or 'cuda') - """ 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): - """Load ONNX models for voxel encoder and backbone/head.""" - # Define model paths 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") - # Verify files exist 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}") - # Create session options so = ort.SessionOptions() - # Disable graph optimization for numerical consistency with PyTorch - # Graph optimizations can reorder operations and fuse layers, causing numerical differences so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL - so.log_severity_level = 3 # ERROR level + so.log_severity_level = 3 - # Set execution providers if device.startswith("cuda"): providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] logger.info("Using CUDA execution provider for ONNX") @@ -71,14 +45,12 @@ def _load_onnx_models(self, device: str): providers = ["CPUExecutionProvider"] logger.info("Using CPU execution provider for ONNX") - # Load voxel encoder 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}") - # Load backbone + head try: self.backbone_head_session = ort.InferenceSession(backbone_head_path, sess_options=so, providers=providers) logger.info(f"Loaded backbone+head: {backbone_head_path}") @@ -86,58 +58,24 @@ def _load_onnx_models(self, device: str): raise RuntimeError(f"Failed to load backbone+head ONNX: {e}") def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: - """ - Run voxel encoder using ONNX Runtime. - - Args: - input_features: Input features [N_voxels, max_points, feature_dim] - - Returns: - voxel_features: Voxel features [N_voxels, feature_dim] - """ - # Convert to numpy input_array = input_features.cpu().numpy().astype(np.float32) - - # Get input and output names from ONNX model input_name = self.voxel_encoder_session.get_inputs()[0].name output_name = self.voxel_encoder_session.get_outputs()[0].name - # Run ONNX inference with explicit output name for consistency - outputs = self.voxel_encoder_session.run( - [output_name], {input_name: input_array} # Specify output name explicitly - ) + outputs = self.voxel_encoder_session.run([output_name], {input_name: input_array}) - # Convert back to torch tensor voxel_features = torch.from_numpy(outputs[0]).to(self.device) - - # Squeeze middle dimension if present (ONNX may output 3D) 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]: - """ - Run backbone + neck + head using ONNX Runtime. - - Args: - spatial_features: Spatial features [B, C, H, W] - - Returns: - List of head outputs: [heatmap, reg, height, dim, rot, vel] - """ - # Convert to numpy input_array = spatial_features.cpu().numpy().astype(np.float32) - # Get input and output names from ONNX model input_name = self.backbone_head_session.get_inputs()[0].name output_names = [output.name for output in self.backbone_head_session.get_outputs()] - # Run ONNX inference outputs = self.backbone_head_session.run(output_names, {input_name: input_array}) - - # Convert outputs to torch tensors - # outputs should be: [heatmap, reg, height, dim, rot, vel] head_outputs = [torch.from_numpy(out).to(self.device) for out in outputs] if len(head_outputs) != 6: diff --git a/deployment/pipelines/centerpoint/centerpoint_pytorch.py b/deployment/projects/centerpoint/pipelines/pytorch.py similarity index 51% rename from deployment/pipelines/centerpoint/centerpoint_pytorch.py rename to deployment/projects/centerpoint/pipelines/pytorch.py index b1be8854a..4841c6478 100644 --- a/deployment/pipelines/centerpoint/centerpoint_pytorch.py +++ b/deployment/projects/centerpoint/pipelines/pytorch.py @@ -1,8 +1,7 @@ """ CenterPoint PyTorch Pipeline Implementation. -This module implements the CenterPoint pipeline using pure PyTorch, -providing a baseline for comparison with optimized backends. +Moved from deployment/pipelines/centerpoint/centerpoint_pytorch.py into the CenterPoint deployment bundle. """ import logging @@ -10,57 +9,22 @@ import torch -from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline +from deployment.projects.centerpoint.pipelines.centerpoint_pipeline import CenterPointDeploymentPipeline logger = logging.getLogger(__name__) class CenterPointPyTorchPipeline(CenterPointDeploymentPipeline): - """ - PyTorch implementation of the staged CenterPoint deployment pipeline. - - Uses PyTorch for preprocessing, middle encoder, backbone, and head while - sharing the same staged execution flow as ONNX/TensorRT backends. - """ - def __init__(self, pytorch_model, device: str = "cuda"): - """ - Initialize PyTorch pipeline. - - Args: - pytorch_model: PyTorch CenterPoint model - device: Device for inference - """ 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): - """ - Complete inference pipeline. - - Uses the shared staged pipeline defined in CenterPointDeploymentPipeline. - - Args: - points: Input point cloud - sample_meta: Sample metadata - return_raw_outputs: If True, return raw head outputs (only for ONNX models) - """ 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: - """ - Run voxel encoder using PyTorch. - - Note: Only used for ONNX-compatible models. - - Args: - input_features: Input features [N_voxels, max_points, feature_dim] - - Returns: - voxel_features: Voxel features [N_voxels, feature_dim] - """ if input_features is None: raise ValueError("input_features is None. This should not happen for ONNX models.") @@ -69,76 +33,42 @@ def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: with torch.no_grad(): voxel_features = self.pytorch_model.pts_voxel_encoder(input_features) - # Ensure output is 2D: [N_voxels, feature_dim] - # ONNX-compatible models may output 3D tensor that needs squeezing if voxel_features.ndim == 3: - # Try to squeeze to 2D if voxel_features.shape[1] == 1: - # Shape: [N_voxels, 1, feature_dim] -> [N_voxels, feature_dim] voxel_features = voxel_features.squeeze(1) elif voxel_features.shape[2] == 1: - # Shape: [N_voxels, feature_dim, 1] -> [N_voxels, feature_dim] voxel_features = voxel_features.squeeze(2) else: - # Cannot determine which dimension to squeeze - # This might be the input features [N_voxels, max_points, feature_dim] - # which should have been processed by the encoder raise RuntimeError( f"Voxel encoder output has unexpected 3D shape: {voxel_features.shape}. " - f"Expected 2D output [N_voxels, feature_dim]. " - f"This may indicate the voxel encoder didn't process the input correctly. " - f"Input features shape was: {input_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}. " - f"Expected 2D output [N_voxels, feature_dim]." + "Expected 2D output [N_voxels, feature_dim]." ) return voxel_features def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: - """ - Run backbone + neck + head using PyTorch. - - Note: Only used for ONNX-compatible models. - - Args: - spatial_features: Spatial features [B, C, H, W] - - Returns: - List of head outputs: [heatmap, reg, height, dim, rot, vel] - """ spatial_features = spatial_features.to(self.device) with torch.no_grad(): - # Backbone x = self.pytorch_model.pts_backbone(spatial_features) - # Neck if hasattr(self.pytorch_model, "pts_neck") and self.pytorch_model.pts_neck is not None: x = self.pytorch_model.pts_neck(x) - # Head - returns tuple of task head outputs head_outputs_tuple = self.pytorch_model.pts_bbox_head(x) - # Handle two possible output formats: - # 1. ONNX head: Tuple[torch.Tensor] directly (heatmap, reg, height, dim, rot, vel) - # 2. Standard head: Tuple[List[Dict]] format - if isinstance(head_outputs_tuple, tuple) and len(head_outputs_tuple) > 0: first_element = head_outputs_tuple[0] - # Check if this is ONNX format (tuple of tensors) if isinstance(first_element, torch.Tensor): - # ONNX format: (heatmap, reg, height, dim, rot, vel) head_outputs = list(head_outputs_tuple) - elif isinstance(first_element, list) and len(first_element) > 0: - # Standard format: (List[Dict],) - preds_dict = first_element[0] # Get first (and only) dict - - # Extract individual outputs + preds_dict = first_element[0] head_outputs = [ preds_dict["heatmap"], preds_dict["reg"], diff --git a/deployment/pipelines/centerpoint/centerpoint_tensorrt.py b/deployment/projects/centerpoint/pipelines/tensorrt.py similarity index 64% rename from deployment/pipelines/centerpoint/centerpoint_tensorrt.py rename to deployment/projects/centerpoint/pipelines/tensorrt.py index b15ae39b2..fae8c5d8c 100644 --- a/deployment/pipelines/centerpoint/centerpoint_tensorrt.py +++ b/deployment/projects/centerpoint/pipelines/tensorrt.py @@ -1,8 +1,7 @@ """ CenterPoint TensorRT Pipeline Implementation. -This module implements the CenterPoint pipeline using TensorRT, -providing maximum inference speed on NVIDIA GPUs. +Moved from deployment/pipelines/centerpoint/centerpoint_tensorrt.py into the CenterPoint deployment bundle. """ import logging @@ -15,44 +14,19 @@ import tensorrt as trt import torch -from deployment.pipelines.centerpoint.centerpoint_pipeline import CenterPointDeploymentPipeline -from deployment.pipelines.common.gpu_resource_mixin import ( +from deployment.pipelines.gpu_resource_mixin import ( GPUResourceMixin, TensorRTResourceManager, release_tensorrt_resources, ) -from projects.CenterPoint.deploy.configs.deploy_config import onnx_config +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 implementation of CenterPoint pipeline. - - Uses TensorRT for voxel encoder and backbone/head, - while keeping middle encoder in PyTorch (sparse convolution cannot be converted). - - Provides maximum inference speed on NVIDIA GPUs with INT8/FP16 optimization. - - Resource Management: - This pipeline implements GPUResourceMixin for proper resource cleanup. - Use as a context manager for automatic cleanup: - - with CenterPointTensorRTPipeline(...) as pipeline: - results = pipeline.infer(data) - # Resources automatically released - """ - def __init__(self, pytorch_model, tensorrt_dir: str, device: str = "cuda"): - """ - Initialize TensorRT pipeline. - - Args: - pytorch_model: PyTorch model (for preprocessing, middle encoder, postprocessing) - tensorrt_dir: Directory containing TensorRT engine files - device: Device for inference (must be 'cuda') - """ if not device.startswith("cuda"): raise ValueError("TensorRT requires CUDA device") @@ -62,19 +36,15 @@ def __init__(self, pytorch_model, tensorrt_dir: str, device: str = "cuda"): self._engines = {} self._contexts = {} self._logger = trt.Logger(trt.Logger.WARNING) - self._cleanup_called = False # For GPUResourceMixin + self._cleanup_called = False self._load_tensorrt_engines() - logger.info(f"TensorRT pipeline initialized with engines from: {tensorrt_dir}") def _load_tensorrt_engines(self): - """Load TensorRT engines for voxel encoder and backbone/head.""" - # Initialize TensorRT trt.init_libnvinfer_plugins(self._logger, "") runtime = trt.Runtime(self._logger) - # Resolve engine filenames from deploy config with sane fallbacks component_cfg = onnx_config.get("components", {}) voxel_cfg = component_cfg.get("voxel_encoder", {}) backbone_cfg = component_cfg.get("backbone_head", {}) @@ -85,69 +55,41 @@ def _load_tensorrt_engines(self): 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}") - try: - # Load engine - 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}") - - # Create execution context - context = engine.create_execution_context() - - # Check if context creation succeeded - if context is None: - raise RuntimeError( - f"Failed to create execution context for {component}. " - "This is likely due to GPU out-of-memory. " - "Try reducing batch size or closing other GPU processes." - ) - - self._engines[component] = engine - self._contexts[component] = context + 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}") - logger.info(f"Loaded TensorRT engine: {component}") + 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." + ) - except Exception as e: - raise RuntimeError(f"Failed to load TensorRT engine {component}: {e}") + 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: - """ - Run voxel encoder using TensorRT. - - Args: - input_features: Input features [N_voxels, max_points, feature_dim] - - Returns: - voxel_features: Voxel features [N_voxels, feature_dim] - """ 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") - # Convert to numpy input_array = input_features.cpu().numpy().astype(np.float32) if not input_array.flags["C_CONTIGUOUS"]: input_array = np.ascontiguousarray(input_array) - # Get tensor names input_name, output_name = self._get_io_names(engine, single_output=True) - - # Set input shape and get output shape 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) - # Use resource manager for automatic cleanup with TensorRTResourceManager() as manager: d_input = manager.allocate(input_array.nbytes) d_output = manager.allocate(output_array.nbytes) @@ -166,7 +108,6 @@ def run_voxel_encoder(self, input_features: torch.Tensor) -> torch.Tensor: return voxel_features def _get_io_names(self, engine, single_output: bool = False): - """Extract input/output tensor names from TensorRT engine.""" input_name = None output_names = [] @@ -187,33 +128,18 @@ def _get_io_names(self, engine, single_output: bool = False): return input_name, output_names def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor]: - """ - Run backbone + neck + head using TensorRT. - - Args: - spatial_features: Spatial features [B, C, H, W] - - Returns: - List of head outputs: [heatmap, reg, height, dim, rot, vel] - """ 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") - # Convert to numpy input_array = spatial_features.cpu().numpy().astype(np.float32) if not input_array.flags["C_CONTIGUOUS"]: input_array = np.ascontiguousarray(input_array) - # Get tensor names input_name, output_names = self._get_io_names(engine, single_output=False) - - # Set input shape context.set_input_shape(input_name, input_array.shape) - # Prepare output arrays output_arrays = {} for output_name in output_names: output_shape = context.get_tensor_shape(output_name) @@ -222,7 +148,6 @@ def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor output_array = np.ascontiguousarray(output_array) output_arrays[output_name] = output_array - # Use resource manager for automatic cleanup 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()} @@ -237,18 +162,14 @@ def run_backbone_head(self, spatial_features: torch.Tensor) -> List[torch.Tensor 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 engines and contexts (GPUResourceMixin implementation).""" release_tensorrt_resources( engines=getattr(self, "_engines", None), contexts=getattr(self, "_contexts", None), diff --git a/deployment/runners/projects/centerpoint_runner.py b/deployment/projects/centerpoint/runner.py similarity index 50% rename from deployment/runners/projects/centerpoint_runner.py rename to deployment/projects/centerpoint/runner.py index 781ffad9f..33a6de94a 100644 --- a/deployment/runners/projects/centerpoint_runner.py +++ b/deployment/projects/centerpoint/runner.py @@ -1,5 +1,7 @@ """ CenterPoint-specific deployment runner. + +Moved from deployment/runners/projects/centerpoint_runner.py into the unified deployment bundle. """ from __future__ import annotations @@ -8,31 +10,16 @@ from typing import Any from deployment.core.contexts import CenterPointExportContext, ExportContext -from deployment.exporters.centerpoint.onnx_export_pipeline import CenterPointONNXExportPipeline -from deployment.exporters.centerpoint.tensorrt_export_pipeline import CenterPointTensorRTExportPipeline from deployment.exporters.common.factory import ExporterFactory from deployment.exporters.common.model_wrappers import IdentityWrapper -from deployment.runners.common.deployment_runner import BaseDeploymentRunner -from projects.CenterPoint.deploy.component_extractor import CenterPointComponentExtractor -from projects.CenterPoint.deploy.utils import build_centerpoint_onnx_model +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-specific deployment runner. - - Handles CenterPoint-specific requirements: - - Multi-file ONNX export (voxel encoder + backbone/neck/head) via workflow - - Multi-file TensorRT export via workflow - - Uses generic ONNX/TensorRT exporters composed into CenterPoint workflows - - ONNX-compatible model loading - - Key improvements: - - Uses ExporterFactory instead of passing runner methods - - Injects CenterPointComponentExtractor for model-specific logic - - No circular dependencies or exporter caching - """ - def __init__( self, data_loader, @@ -43,27 +30,9 @@ def __init__( onnx_pipeline=None, tensorrt_pipeline=None, ): - """ - Initialize CenterPoint deployment runner. - - Args: - data_loader: Data loader for samples - evaluator: Evaluator for model evaluation - config: Deployment configuration - model_cfg: Model configuration - logger: Logger instance - onnx_pipeline: Optional custom ONNX export pipeline - tensorrt_pipeline: Optional custom TensorRT export pipeline - - Note: - CenterPoint uses IdentityWrapper directly since no special - output format conversion is needed for ONNX export. - """ - # Create component extractor for model-specific logic simplify_onnx = config.get_onnx_settings().simplify component_extractor = CenterPointComponentExtractor(logger=logger, simplify=simplify_onnx) - # Initialize base runner with IdentityWrapper super().__init__( data_loader=data_loader, evaluator=evaluator, @@ -75,7 +44,6 @@ def __init__( tensorrt_pipeline=tensorrt_pipeline, ) - # Create export pipelines with ExporterFactory and component extractor if self._onnx_pipeline is None: self._onnx_pipeline = CenterPointONNXExportPipeline( exporter_factory=ExporterFactory, @@ -91,28 +59,7 @@ def __init__( logger=self.logger, ) - def load_pytorch_model( - self, - checkpoint_path: str, - context: ExportContext, - ) -> Any: - """ - Build ONNX-compatible CenterPoint model from checkpoint. - - This method: - 1. Builds ONNX-compatible model - 2. Updates runner's config to ONNX version - 3. Explicitly injects model and config to evaluator - - Args: - checkpoint_path: Path to checkpoint file - context: Export context. Use CenterPointExportContext for type-safe access - to rot_y_axis_reference. Falls back to context.extra for compatibility. - - Returns: - Loaded PyTorch model (ONNX-compatible) - """ - # Extract rot_y_axis_reference from context + 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 @@ -128,18 +75,9 @@ def load_pytorch_model( 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: - """ - Inject PyTorch model and ONNX config to evaluator. - - Args: - model: PyTorch model to inject - onnx_cfg: ONNX-compatible config to inject - """ - # Inject ONNX-compatible config try: self.evaluator.set_onnx_config(onnx_cfg) self.logger.info("Injected ONNX-compatible config to evaluator") @@ -147,7 +85,6 @@ def _inject_model_to_evaluator(self, model: Any, onnx_cfg: Any) -> None: self.logger.error(f"Failed to inject ONNX config: {e}") raise - # Inject PyTorch model try: self.evaluator.set_pytorch_model(model) self.logger.info("Injected PyTorch model to evaluator") diff --git a/deployment/projects/registry.py b/deployment/projects/registry.py new file mode 100644 index 000000000..c1bcd361f --- /dev/null +++ b/deployment/projects/registry.py @@ -0,0 +1,48 @@ +""" +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: + 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/runners/__init__.py b/deployment/runners/__init__.py deleted file mode 100644 index 36d3dd765..000000000 --- a/deployment/runners/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Deployment runners for unified deployment workflow.""" - -from deployment.runners.common.artifact_manager import ArtifactManager -from deployment.runners.common.deployment_runner import BaseDeploymentRunner -from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator -from deployment.runners.common.verification_orchestrator import VerificationOrchestrator - -# from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner -from deployment.runners.projects.centerpoint_runner import CenterPointDeploymentRunner - -# from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner - -__all__ = [ - # Base runner - "BaseDeploymentRunner", - # Project-specific runners - "CenterPointDeploymentRunner", - # "YOLOXOptElanDeploymentRunner", - # "CalibrationDeploymentRunner", - # Helper components (orchestrators) - "ArtifactManager", - "VerificationOrchestrator", - "EvaluationOrchestrator", -] diff --git a/deployment/runners/common/__init__.py b/deployment/runners/common/__init__.py deleted file mode 100644 index adbe3af59..000000000 --- a/deployment/runners/common/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Core runner components for the deployment framework.""" - -from deployment.runners.common.artifact_manager import ArtifactManager -from deployment.runners.common.deployment_runner import BaseDeploymentRunner -from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator -from deployment.runners.common.export_orchestrator import ExportOrchestrator, ExportResult -from deployment.runners.common.verification_orchestrator import VerificationOrchestrator - -__all__ = [ - "ArtifactManager", - "BaseDeploymentRunner", - "EvaluationOrchestrator", - "ExportOrchestrator", - "ExportResult", - "VerificationOrchestrator", -] diff --git a/deployment/runners/common/deployment_runner.py b/deployment/runners/common/deployment_runner.py deleted file mode 100644 index 504f926de..000000000 --- a/deployment/runners/common/deployment_runner.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Unified deployment runner for common deployment workflows. - -This module provides a unified runner that handles the common deployment workflow -across different projects, while allowing project-specific customization. - -Architecture: - The runner orchestrates three specialized orchestrators: - - ExportOrchestrator: Handles PyTorch loading, ONNX export, TensorRT export - - VerificationOrchestrator: Handles output verification across backends - - EvaluationOrchestrator: Handles model evaluation with metrics - - This design keeps the runner thin (~150 lines vs original 850+) while - maintaining flexibility for project-specific customization. -""" - -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.runners.common.artifact_manager import ArtifactManager -from deployment.runners.common.evaluation_orchestrator import EvaluationOrchestrator -from deployment.runners.common.export_orchestrator import ExportOrchestrator -from deployment.runners.common.verification_orchestrator import VerificationOrchestrator - - -@dataclass -class DeploymentResult: - """ - Standardized structure returned by `BaseDeploymentRunner.run()`. - - Fields: - pytorch_model: In-memory model instance loaded from the checkpoint (if requested). - onnx_path: Filesystem path to the exported ONNX artifact (single file or directory). - tensorrt_path: Filesystem path to the exported TensorRT engine. - verification_results: Arbitrary dictionary produced by `BaseEvaluator.verify()`. - evaluation_results: Arbitrary dictionary produced by `BaseEvaluator.evaluate()`. - """ - - 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 a dict view for compatibility/serialization.""" - return asdict(self) - - -class BaseDeploymentRunner: - """ - Base deployment runner for common deployment pipelines. - - This runner orchestrates three specialized components: - 1. ExportOrchestrator: Load PyTorch, export ONNX, export TensorRT - 2. VerificationOrchestrator: Verify outputs across backends - 3. EvaluationOrchestrator: Evaluate models with metrics - - Projects should extend this class and override methods as needed: - - Override load_pytorch_model() for project-specific model loading - - Provide project-specific ONNX/TensorRT export pipelines via constructor - """ - - 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, - ): - """ - Initialize base deployment runner. - - Args: - data_loader: Data loader for samples - evaluator: Evaluator for model evaluation - config: Deployment configuration - model_cfg: Model configuration - logger: Logger instance - onnx_wrapper_cls: Optional ONNX model wrapper class for exporter creation - onnx_pipeline: Optional specialized ONNX export pipeline - tensorrt_pipeline: Optional specialized TensorRT export pipeline - """ - self.data_loader = data_loader - self.evaluator = evaluator - self.config = config - self.model_cfg = model_cfg - self.logger = logger - - # Store pipeline references for subclasses to modify - self._onnx_wrapper_cls = onnx_wrapper_cls - self._onnx_pipeline = onnx_pipeline - self._tensorrt_pipeline = tensorrt_pipeline - - # Initialize artifact manager (shared across orchestrators) - self.artifact_manager = ArtifactManager(config, logger) - - # Initialize orchestrators (export orchestrator created lazily to allow subclass workflow setup) - 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: - """ - Get export orchestrator (created lazily to allow subclass pipeline setup). - - This allows subclasses to set _onnx_pipeline and _tensorrt_pipeline in __init__ - before the export orchestrator is created. - """ - 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: - """ - Load PyTorch model from checkpoint. - - Subclasses must implement this method to provide project-specific model loading logic. - Project-specific parameters should be accessed from the typed context object. - - Args: - checkpoint_path: Path to checkpoint file - context: Export context containing project-specific parameters. - Use project-specific context subclasses (e.g., YOLOXExportContext, - CenterPointExportContext) for type-safe access to parameters. - - Returns: - Loaded PyTorch model - - Raises: - NotImplementedError: If not implemented by subclass - - Example: - # In YOLOXDeploymentRunner: - def load_pytorch_model(self, checkpoint_path: str, context: ExportContext) -> Any: - # Type narrow to access YOLOX-specific fields - if isinstance(context, YOLOXExportContext): - model_cfg_path = context.model_cfg_path - else: - model_cfg_path = context.get("model_cfg_path") - ... - """ - raise NotImplementedError(f"{self.__class__.__name__}.load_pytorch_model() must be implemented by subclasses.") - - def run( - self, - context: Optional[ExportContext] = None, - ) -> DeploymentResult: - """ - Execute the complete deployment workflow. - - The workflow consists of three phases: - 1. Export: Load PyTorch model, export to ONNX/TensorRT - 2. Verification: Verify outputs across backends - 3. Evaluation: Evaluate models with metrics - - Args: - context: Typed export context with parameters. If None, a default - ExportContext is created. - - Returns: - DeploymentResult: Structured summary of all deployment artifacts and reports. - """ - # Create default context if not provided - if context is None: - context = ExportContext() - - results = DeploymentResult() - - # Phase 1: Export - 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 - - # Phase 2: Verification - verification_results = self.verification_orchestrator.run( - artifact_manager=self.artifact_manager, - ) - results.verification_results = verification_results - - # Phase 3: Evaluation - evaluation_results = self.evaluation_orchestrator.run(self.artifact_manager) - results.evaluation_results = evaluation_results - - self.logger.info("\n" + "=" * 80) - self.logger.info("Deployment Complete!") - self.logger.info("=" * 80) - - return results diff --git a/deployment/runners/projects/__init__.py b/deployment/runners/projects/__init__.py deleted file mode 100644 index a48acbe78..000000000 --- a/deployment/runners/projects/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Project-specific deployment runners.""" - -# from deployment.runners.projects.calibration_runner import CalibrationDeploymentRunner -from deployment.runners.projects.centerpoint_runner import CenterPointDeploymentRunner - -# from deployment.runners.projects.yolox_runner import YOLOXOptElanDeploymentRunner - -__all__ = [ - # "CalibrationDeploymentRunner", - "CenterPointDeploymentRunner", - # "YOLOXOptElanDeploymentRunner", -] 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/runners/common/artifact_manager.py b/deployment/runtime/artifact_manager.py similarity index 54% rename from deployment/runners/common/artifact_manager.py rename to deployment/runtime/artifact_manager.py index 5619ffccf..17845e386 100644 --- a/deployment/runners/common/artifact_manager.py +++ b/deployment/runtime/artifact_manager.py @@ -19,12 +19,6 @@ class ArtifactManager: """ Manages model artifacts and path resolution for deployment workflows. - This class centralizes all logic for: - - Registering artifacts after export - - Resolving artifact paths from configuration - - Validating artifact existence - - Looking up artifacts by backend - Resolution Order (consistent for all backends): 1. Registered artifacts (from export operations) - highest priority 2. Explicit paths from evaluation.backends. config: @@ -36,65 +30,22 @@ class ArtifactManager: """ def __init__(self, config: BaseDeploymentConfig, logger: logging.Logger): - """ - Initialize artifact manager. - - Args: - config: Deployment configuration - logger: Logger instance - """ self.config = config self.logger = logger self.artifacts: Dict[str, Artifact] = {} def register_artifact(self, backend: Backend, artifact: Artifact) -> None: - """ - Register an artifact for a backend. - - Args: - backend: Backend identifier - artifact: Artifact to register - """ self.artifacts[backend.value] = artifact self.logger.debug(f"Registered {backend.value} artifact: {artifact.path}") def get_artifact(self, backend: Backend) -> Optional[Artifact]: - """ - Get registered artifact for a backend. - - Args: - backend: Backend identifier - - Returns: - Artifact if found, None otherwise - """ return self.artifacts.get(backend.value) def resolve_artifact(self, backend: Backend) -> Tuple[Optional[Artifact], bool]: - """ - Resolve artifact for any backend with consistent resolution order. - - Resolution order (same for all backends): - 1. Registered artifact (from previous export/load operations) - 2. Explicit path from evaluation.backends. config: - - ONNX: model_dir - - TensorRT: engine_dir - 3. Backend-specific fallback (checkpoint_path for PyTorch, export.onnx_path for ONNX) - - Args: - backend: Backend identifier - - Returns: - Tuple of (artifact, is_valid). - artifact is an Artifact instance if a path could be resolved, otherwise None. - is_valid indicates whether the artifact exists on disk. - """ - # Priority 1: Check registered artifacts artifact = self.artifacts.get(backend.value) if artifact: return artifact, artifact.exists() - # Priority 2 & 3: Get path from config config_path = self._get_config_path(backend) if config_path: is_dir = osp.isdir(config_path) if osp.exists(config_path) else False @@ -104,24 +55,9 @@ def resolve_artifact(self, backend: Backend) -> Tuple[Optional[Artifact], bool]: return None, False def _get_config_path(self, backend: Backend) -> Optional[str]: - """ - Get artifact path from configuration. - - Resolution order: - 1. evaluation.backends..model_dir or engine_dir (explicit per-backend path) - 2. Backend-specific fallbacks (checkpoint_path, export.onnx_path) - - Args: - backend: Backend identifier - - Returns: - Path string if found in config, None otherwise - """ - # Priority 1: Check evaluation.backends. for explicit path eval_backends = self.config.evaluation_config.backends backend_cfg = self._get_backend_entry(eval_backends, backend) if backend_cfg and isinstance(backend_cfg, Mapping): - # ONNX uses model_dir, TensorRT uses engine_dir if backend == Backend.ONNX: path = backend_cfg.get("model_dir") if path: @@ -131,26 +67,15 @@ def _get_config_path(self, backend: Backend) -> Optional[str]: if path: return path - # Priority 2: Backend-specific fallbacks from export config if backend == Backend.PYTORCH: return self.config.checkpoint_path - elif backend == Backend.ONNX: + if backend == Backend.ONNX: return self.config.export_config.onnx_path - # TensorRT has no global fallback path in export config + return None @staticmethod def _get_backend_entry(mapping: Optional[Mapping], backend: Backend) -> Any: - """ - Fetch a config value that may be keyed by either string literals or Backend enums. - - Args: - mapping: Configuration mapping (may be None or MappingProxyType) - backend: Backend to look up - - Returns: - Value from mapping if found, None otherwise - """ if not mapping: return None diff --git a/deployment/runners/common/evaluation_orchestrator.py b/deployment/runtime/evaluation_orchestrator.py similarity index 73% rename from deployment/runners/common/evaluation_orchestrator.py rename to deployment/runtime/evaluation_orchestrator.py index d6a4ff807..8ca4aa9b6 100644 --- a/deployment/runners/common/evaluation_orchestrator.py +++ b/deployment/runtime/evaluation_orchestrator.py @@ -14,20 +14,11 @@ 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.runners.common.artifact_manager import ArtifactManager +from deployment.runtime.artifact_manager import ArtifactManager class EvaluationOrchestrator: - """ - Orchestrates evaluation across backends with consistent metrics. - - This class handles: - - Resolving models to evaluate from configuration - - Running evaluation for each enabled backend - - Collecting and formatting evaluation results - - Logging evaluation progress and results - - Cross-backend metric comparison - """ + """Orchestrates evaluation across backends with consistent metrics.""" def __init__( self, @@ -36,30 +27,12 @@ def __init__( data_loader: BaseDataLoader, logger: logging.Logger, ): - """ - Initialize evaluation orchestrator. - - Args: - config: Deployment configuration - evaluator: Evaluator instance for running evaluation - data_loader: Data loader for loading samples - logger: Logger instance - """ self.config = config self.evaluator = evaluator self.data_loader = data_loader self.logger = logger def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: - """ - Run evaluation on specified models. - - Args: - artifact_manager: Artifact manager for resolving model paths - - Returns: - Dictionary containing evaluation results for all backends - """ eval_config = self.config.evaluation_config if not eval_config.enabled: @@ -70,31 +43,24 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: self.logger.info("Running Evaluation") self.logger.info("=" * 80) - # Get models to evaluate models_to_evaluate = self._get_models_to_evaluate(artifact_manager) - if not models_to_evaluate: self.logger.warning("No models found for evaluation") return {} - # Determine number of samples num_samples = eval_config.num_samples if num_samples == -1: num_samples = self.data_loader.get_num_samples() verbose_mode = eval_config.verbose - - # Run evaluation for each model 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, @@ -102,37 +68,23 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: 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: - # Ensure CUDA memory is cleaned up between model evaluations - from deployment.pipelines.common.gpu_resource_mixin import clear_cuda_memory + from deployment.pipelines.gpu_resource_mixin import clear_cuda_memory clear_cuda_memory() - # Print cross-backend comparison if multiple backends 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]: - """ - Get list of models to evaluate from config. - - Args: - artifact_manager: Artifact manager for resolving paths - - Returns: - List of ModelSpec instances describing models to evaluate - """ backends = self.config.get_evaluation_backends() models_to_evaluate: List[ModelSpec] = [] @@ -142,8 +94,6 @@ def _get_models_to_evaluate(self, artifact_manager: ArtifactManager) -> List[Mod continue device = str(backend_cfg.get("device", "cpu") or "cpu") - - # Use artifact_manager to resolve artifact artifact, is_valid = artifact_manager.resolve_artifact(backend_enum) if is_valid and artifact: @@ -156,22 +106,12 @@ def _get_models_to_evaluate(self, artifact_manager: ArtifactManager) -> List[Mod return models_to_evaluate def _normalize_device_for_backend(self, backend: Backend, device: str) -> str: - """ - Normalize device string for specific backend. - - Args: - backend: Backend identifier - device: Device string from config - - Returns: - Normalized device string - """ 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." + f"Unsupported device '{normalized_device}' for backend '{backend.value}'. Falling back to CPU." ) normalized_device = "cpu" elif backend is Backend.TENSORRT: @@ -187,18 +127,11 @@ def _normalize_device_for_backend(self, backend: Backend, device: str) -> str: return normalized_device def _get_default_device(self, backend: Backend) -> str: - """Return default device for a backend when config omits explicit value.""" 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: - """ - Print cross-backend comparison of metrics. - - Args: - all_results: Dictionary of results by backend - """ self.logger.info("\n" + "=" * 80) self.logger.info("Cross-Backend Comparison") self.logger.info("=" * 80) @@ -206,13 +139,11 @@ def _print_cross_backend_comparison(self, all_results: Mapping[str, Any]) -> Non for backend_label, results in all_results.items(): self.logger.info(f"\n{backend_label.upper()}:") if results and "error" not in results: - # Print primary metrics 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}") - # Print latency stats if "latency_stats" in results: stats = results["latency_stats"] self.logger.info(f" Latency: {stats['mean_ms']:.2f} ± {stats['std_ms']:.2f} ms") diff --git a/deployment/runners/common/export_orchestrator.py b/deployment/runtime/export_orchestrator.py similarity index 60% rename from deployment/runners/common/export_orchestrator.py rename to deployment/runtime/export_orchestrator.py index 3e7d01492..10de09798 100644 --- a/deployment/runners/common/export_orchestrator.py +++ b/deployment/runtime/export_orchestrator.py @@ -24,19 +24,12 @@ 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.runners.common.artifact_manager import ArtifactManager +from deployment.runtime.artifact_manager import ArtifactManager @dataclass class ExportResult: - """ - Result of the export orchestration. - - Attributes: - pytorch_model: Loaded PyTorch model (if loaded) - onnx_path: Path to exported ONNX artifact - tensorrt_path: Path to exported TensorRT engine - """ + """Result of export orchestration.""" pytorch_model: Optional[Any] = None onnx_path: Optional[str] = None @@ -44,21 +37,8 @@ class ExportResult: class ExportOrchestrator: - """ - Orchestrates model export workflows (PyTorch loading, ONNX, TensorRT). - - This class centralizes all export-related logic: - - Determining when PyTorch model is needed - - Loading PyTorch model via injected loader - - ONNX export (via workflow or standard exporter) - - TensorRT export (via workflow or standard exporter) - - Artifact registration + """Orchestrates model export workflows (PyTorch loading, ONNX, TensorRT).""" - By extracting this logic from the runner, the runner becomes a thin - orchestrator that coordinates Export, Verification, and Evaluation. - """ - - # Directory name constants ONNX_DIR_NAME = "onnx" TENSORRT_DIR_NAME = "tensorrt" DEFAULT_ENGINE_FILENAME = "model.engine" @@ -75,20 +55,6 @@ def __init__( onnx_pipeline: Optional[OnnxExportPipeline] = None, tensorrt_pipeline: Optional[TensorRTExportPipeline] = None, ): - """ - Initialize export orchestrator. - - Args: - config: Deployment configuration - data_loader: Data loader for samples - artifact_manager: Artifact manager for registration - logger: Logger instance - model_loader: Callable to load PyTorch model (checkpoint_path, **kwargs) -> model - evaluator: Evaluator instance (for model injection) - onnx_wrapper_cls: Optional ONNX model wrapper class - onnx_pipeline: Optional specialized ONNX export pipeline - tensorrt_pipeline: Optional specialized TensorRT export pipeline - """ self.config = config self.data_loader = data_loader self.artifact_manager = artifact_manager @@ -99,32 +65,10 @@ def __init__( self._onnx_pipeline = onnx_pipeline self._tensorrt_pipeline = tensorrt_pipeline - # Lazy-initialized exporters self._onnx_exporter: Optional[ONNXExporter] = None self._tensorrt_exporter: Optional[TensorRTExporter] = None - def run( - self, - context: Optional[ExportContext] = None, - ) -> ExportResult: - """ - Execute the complete export workflow. - - This method: - 1. Determines if PyTorch model is needed - 2. Loads PyTorch model if needed - 3. Exports to ONNX if configured - 4. Exports to TensorRT if configured - 5. Resolves external artifact paths - - Args: - context: Typed export context with parameters. If None, a default - ExportContext is created. - - Returns: - ExportResult containing model and artifact paths - """ - # Create default context if not provided + def run(self, context: Optional[ExportContext] = None) -> ExportResult: if context is None: context = ExportContext() @@ -135,21 +79,17 @@ def run( checkpoint_path = self.config.checkpoint_path external_onnx_path = self.config.export_config.onnx_path - # Step 1: Determine if PyTorch model is needed requires_pytorch = self._determine_pytorch_requirements() - # Step 2: Load PyTorch model if needed 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 - # Step 3: Export ONNX if requested if should_export_onnx: result.onnx_path = self._run_onnx_export(pytorch_model, context) - # Step 4: Export TensorRT if requested if should_export_trt: onnx_path = self._resolve_onnx_path_for_trt(result.onnx_path, external_onnx_path) if not onnx_path: @@ -158,23 +98,13 @@ def run( self._register_external_onnx_artifact(onnx_path) result.tensorrt_path = self._run_tensorrt_export(onnx_path, context) - # Step 5: Resolve external paths from evaluation config self._resolve_external_artifacts(result) - return result def _determine_pytorch_requirements(self) -> bool: - """ - Determine if PyTorch model is required based on configuration. - - Returns: - True if PyTorch model is needed, False otherwise - """ - # Check if ONNX export is needed (requires PyTorch model) if self.config.export_config.should_export_onnx(): return True - # Check if PyTorch evaluation is needed eval_config = self.config.evaluation_config if eval_config.enabled: backends_cfg = eval_config.backends @@ -182,35 +112,18 @@ def _determine_pytorch_requirements(self) -> bool: if pytorch_cfg and pytorch_cfg.get("enabled", False): return True - # Check if PyTorch is needed for verification 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: - if any( - policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH - for policy in scenarios - ): - return True + 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]: - """ - Load PyTorch model and register it with artifact manager. - - Args: - checkpoint_path: Path to checkpoint file - context: Export context with project-specific parameters - - Returns: - Loaded PyTorch model, or None if loading failed - """ + 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." @@ -222,7 +135,6 @@ def _load_and_register_pytorch_model( pytorch_model = self._model_loader(checkpoint_path, context) self.artifact_manager.register_artifact(Backend.PYTORCH, Artifact(path=checkpoint_path)) - # Inject model to evaluator via setter 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()") @@ -239,18 +151,6 @@ def _ensure_pytorch_model_loaded( context: ExportContext, result: ExportResult, ) -> tuple[Optional[Any], bool]: - """ - Ensure PyTorch model is loaded, loading it if necessary. - - Args: - pytorch_model: Existing model or None - checkpoint_path: Path to checkpoint file - context: Export context - result: Export result to update - - Returns: - Tuple of (model, success). If success is False, export should abort. - """ if pytorch_model is not None: return pytorch_model, True @@ -267,36 +167,15 @@ def _ensure_pytorch_model_loaded( return pytorch_model, True def _run_onnx_export(self, pytorch_model: Any, context: ExportContext) -> Optional[str]: - """ - Execute ONNX export and return the artifact path. - - Args: - pytorch_model: PyTorch model to export - context: Export context - - Returns: - Path to exported ONNX artifact, or None if export failed - """ 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]: - """ - Resolve ONNX path for TensorRT export. - - Args: - exported_onnx_path: Path from ONNX export step - external_onnx_path: External path from config - - Returns: - Resolved ONNX path, or None with error logged if unavailable - """ onnx_path = exported_onnx_path or external_onnx_path if not onnx_path: self.logger.error( @@ -307,48 +186,19 @@ def _resolve_onnx_path_for_trt( return onnx_path def _register_external_onnx_artifact(self, onnx_path: str) -> None: - """ - Register an external ONNX artifact if it exists. - - Args: - onnx_path: Path to ONNX file or directory - """ 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]: - """ - Execute TensorRT export and return the artifact path. - - Args: - onnx_path: Path to ONNX model - context: Export context - - Returns: - Path to exported TensorRT engine, or None if export failed - """ 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]: - """ - Export model to ONNX format. - - Uses either a specialized workflow or the standard ONNX exporter. - - Args: - pytorch_model: PyTorch model to export - context: Export context with project-specific parameters - - Returns: - Artifact describing the exported ONNX output, or None if skipped - """ if not self.config.export_config.should_export_onnx(): return None @@ -356,52 +206,40 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A raise RuntimeError("ONNX export requested but no wrapper class or export pipeline provided.") onnx_settings = self.config.get_onnx_settings() - # Use context.sample_idx, fallback to runtime config sample_idx = context.sample_idx if context.sample_idx != 0 else self.config.runtime_config.sample_idx - # Save to work_dir/onnx/ directory 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) - # Use export pipeline if available 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) - try: - artifact = self._onnx_pipeline.export( - model=pytorch_model, - data_loader=self.data_loader, - output_dir=onnx_dir, - config=self.config, - sample_idx=sample_idx, - ) - except Exception: - self.logger.exception("ONNX export workflow failed") - raise - + 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 - # Use standard exporter exporter = self._get_onnx_exporter() self.logger.info("=" * 80) self.logger.info(f"Exporting to ONNX (Using {type(exporter).__name__})") self.logger.info("=" * 80) - # Get sample input sample = self.data_loader.load_sample(sample_idx) single_input = self.data_loader.preprocess(sample) - # Get batch size from configuration batch_size = onnx_settings.batch_size if batch_size is None: input_tensor = single_input self.logger.info("Using dynamic batch size") else: - # Handle different input shapes 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 @@ -411,11 +249,7 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A input_tensor = single_input.repeat(batch_size, *([1] * (len(single_input.shape) - 1))) self.logger.info(f"Using fixed batch size: {batch_size}") - try: - exporter.export(pytorch_model, input_tensor, output_path) - except Exception: - self.logger.exception("ONNX export failed") - raise + 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 @@ -425,18 +259,6 @@ def _export_onnx(self, pytorch_model: Any, context: ExportContext) -> Optional[A return artifact def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[Artifact]: - """ - Export ONNX model to TensorRT engine. - - Uses either a specialized workflow or the standard TensorRT exporter. - - Args: - onnx_path: Path to ONNX model file/directory - context: Export context with project-specific parameters - - Returns: - Artifact describing the exported TensorRT output, or None if skipped - """ if not self.config.export_config.should_export_tensorrt(): return None @@ -452,14 +274,10 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A self.logger.info(f"Exporting to TensorRT (Using {exporter_label})") self.logger.info("=" * 80) - # Save to work_dir/tensorrt/ directory tensorrt_dir = os.path.join(self.config.export_config.work_dir, self.TENSORRT_DIR_NAME) os.makedirs(tensorrt_dir, exist_ok=True) - - # Determine output path based on ONNX file name output_path = self._get_tensorrt_output_path(onnx_path, tensorrt_dir) - # Set CUDA device for TensorRT export 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: @@ -467,48 +285,33 @@ def _export_tensorrt(self, onnx_path: str, context: ExportContext) -> Optional[A torch.cuda.set_device(device_id) self.logger.info(f"Using CUDA device for TensorRT export: {cuda_device}") - # Get sample input for shape configuration 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) - # Use export pipeline if available if self._tensorrt_pipeline is not None: - try: - artifact = self._tensorrt_pipeline.export( - onnx_path=onnx_path, - output_dir=tensorrt_dir, - config=self.config, - device=cuda_device, - data_loader=self.data_loader, - ) - except Exception: - self.logger.exception("TensorRT export workflow failed") - raise - + 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 - # Use standard exporter exporter = self._get_tensorrt_exporter() - - try: - artifact = exporter.export( - model=None, - sample_input=sample_input, - output_path=output_path, - onnx_path=onnx_path, - ) - except Exception: - self.logger.exception("TensorRT export failed") - raise - + 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: - """Lazily instantiate and return the ONNX exporter.""" if self._onnx_exporter is None: if self._onnx_wrapper_cls is None: raise RuntimeError("ONNX wrapper class not provided. Cannot create ONNX exporter.") @@ -520,7 +323,6 @@ def _get_onnx_exporter(self) -> ONNXExporter: return self._onnx_exporter def _get_tensorrt_exporter(self) -> TensorRTExporter: - """Lazily instantiate and return the TensorRT exporter.""" if self._tensorrt_exporter is None: self._tensorrt_exporter = ExporterFactory.create_tensorrt_exporter( config=self.config, @@ -529,52 +331,20 @@ def _get_tensorrt_exporter(self) -> TensorRTExporter: return self._tensorrt_exporter def _get_tensorrt_output_path(self, onnx_path: str, tensorrt_dir: str) -> str: - """ - Determine TensorRT output path based on ONNX file name. - - Args: - onnx_path: Path to ONNX model file or directory - tensorrt_dir: Directory for TensorRT engines - - Returns: - Path for TensorRT engine output - """ if os.path.isdir(onnx_path): return os.path.join(tensorrt_dir, self.DEFAULT_ENGINE_FILENAME) - else: - onnx_filename = os.path.basename(onnx_path) - engine_filename = onnx_filename.replace(".onnx", ".engine") - return os.path.join(tensorrt_dir, 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: - """ - Resolve artifact paths from evaluation config and register them. - - Args: - result: Export result to update with resolved paths - """ - # Resolve ONNX if not already set if not result.onnx_path: self._resolve_and_register_artifact(Backend.ONNX, result, "onnx_path") - # Resolve TensorRT if not already set 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: - """ - Resolve artifact path from evaluation config and register it. - - Args: - backend: Backend type (ONNX or TENSORRT) - result: Export result to update - attr_name: Attribute name on result ("onnx_path" or "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) @@ -587,13 +357,8 @@ def _resolve_and_register_artifact( @staticmethod def _get_backend_entry(mapping: Optional[Mapping[Any, Any]], backend: Backend) -> Any: - """ - Fetch a config value that may be keyed by either string literals or Backend enums. - """ 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/runners/common/verification_orchestrator.py b/deployment/runtime/verification_orchestrator.py similarity index 73% rename from deployment/runners/common/verification_orchestrator.py rename to deployment/runtime/verification_orchestrator.py index ac3cf40db..9f99253b5 100644 --- a/deployment/runners/common/verification_orchestrator.py +++ b/deployment/runtime/verification_orchestrator.py @@ -14,19 +14,11 @@ 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.runners.common.artifact_manager import ArtifactManager +from deployment.runtime.artifact_manager import ArtifactManager class VerificationOrchestrator: - """ - Orchestrates verification across backends using scenario-based verification. - - This class handles: - - Running verification scenarios from config - - Resolving model paths via ArtifactManager - - Collecting and aggregating verification results - - Logging verification progress and results - """ + """Orchestrates verification across backends using scenario-based verification.""" def __init__( self, @@ -35,33 +27,14 @@ def __init__( data_loader: BaseDataLoader, logger: logging.Logger, ): - """ - Initialize verification orchestrator. - - Args: - config: Deployment configuration - evaluator: Evaluator instance for running verification - data_loader: Data loader for loading samples - logger: Logger instance - """ self.config = config self.evaluator = evaluator self.data_loader = data_loader self.logger = logger def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: - """ - Run verification on exported models using policy-based verification. - - Args: - artifact_manager: Artifact manager for resolving model paths - - Returns: - Verification results dictionary - """ verification_cfg = self.config.verification_config - # Check master switch if not verification_cfg.enabled: self.logger.info("Verification disabled (verification.enabled=False), skipping...") return {} @@ -73,17 +46,14 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: self.logger.info(f"No verification scenarios for export mode '{export_mode.value}', skipping...") return {} - # Check if PyTorch checkpoint is needed and available needs_pytorch = any( policy.ref_backend is Backend.PYTORCH or policy.test_backend is Backend.PYTORCH for policy in scenarios ) - if needs_pytorch: - pytorch_artifact, pytorch_valid = artifact_manager.resolve_artifact(Backend.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." + "PyTorch checkpoint not available, but required by verification scenarios. Skipping verification." ) return {} @@ -98,12 +68,11 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: self.logger.info(f"Running Verification (mode: {export_mode.value})") self.logger.info("=" * 80) - all_results = {} + all_results: Dict[str, Any] = {} total_passed = 0 total_failed = 0 for i, policy in enumerate(scenarios): - # Resolve devices using alias system ref_device = self._resolve_device(policy.ref_device, devices_map) test_device = self._resolve_device(policy.test_device, devices_map) @@ -112,7 +81,6 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: f"{policy.ref_backend.value}({ref_device}) vs {policy.test_backend.value}({test_device})" ) - # Resolve artifacts via ArtifactManager ref_artifact, ref_valid = artifact_manager.resolve_artifact(policy.ref_backend) test_artifact, test_valid = artifact_manager.resolve_artifact(policy.test_backend) @@ -120,16 +88,14 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: ref_path = ref_artifact.path if ref_artifact else None test_path = test_artifact.path if test_artifact else None self.logger.warning( - f" Skipping: missing or invalid artifacts " + " Skipping: missing or invalid artifacts " f"(ref={ref_path}, valid={ref_valid}, test={test_path}, valid={test_valid})" ) continue - # Create model specs 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) - # Run verification verification_results = self.evaluator.verify( reference=reference_spec, test=test_spec, @@ -139,24 +105,20 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: verbose=False, ) - # Store results policy_key = f"{policy.ref_backend.value}_{ref_device}_vs_{policy.test_backend.value}_{test_device}" all_results[policy_key] = verification_results - # Update counters 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)") - # Overall summary self.logger.info("\n" + "=" * 80) if total_failed == 0: self.logger.info(f"All verifications passed! ({total_passed} total)") @@ -173,18 +135,7 @@ def run(self, artifact_manager: ArtifactManager) -> Dict[str, Any]: return all_results def _resolve_device(self, device_key: str, devices_map: Mapping[str, str]) -> str: - """ - Resolve device using alias system. - - Args: - device_key: Device key from scenario - devices_map: Device alias mapping - - Returns: - Actual device string - """ if device_key in devices_map: return devices_map[device_key] - else: - self.logger.warning(f"Device alias '{device_key}' not found in devices map, using as-is") - return 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/README.md b/projects/CenterPoint/README.md index 284854fc6..cc26b910a 100644 --- a/projects/CenterPoint/README.md +++ b/projects/CenterPoint/README.md @@ -115,12 +115,12 @@ where `frame-range` represents the range of frames to visualize. ### 5. Deploy -- Run the unified deployment pipeline to export ONNX/TensorRT artifacts, verify them, and (optionally) evaluate. Update `projects/CenterPoint/deploy/configs/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"`). +- 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 (export + verification + evaluation) -python projects/CenterPoint/deploy/main.py \ - projects/CenterPoint/deploy/configs/deploy_config.py \ +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 ``` diff --git a/projects/CenterPoint/deploy/README.md b/projects/CenterPoint/deploy/README.md deleted file mode 100644 index 501aed2da..000000000 --- a/projects/CenterPoint/deploy/README.md +++ /dev/null @@ -1,232 +0,0 @@ -# CenterPoint Deployment - -Deployment pipeline for CenterPoint 3D object detection using the unified deployment framework. - -## Features - -- Export to ONNX and TensorRT (multi-file architecture) -- Full evaluation with 3D detection metrics (autoware_perception_evaluation) -- Latency benchmarking -- Uses MMDet3D pipeline for consistency with training -- Unified runner architecture with composition-based design - -## Quick Start - -### 1. Prepare Data - -Make sure you have T4Dataset or similar 3D detection dataset: -``` -data/t4dataset/ -├── centerpoint_infos_train.pkl -├── centerpoint_infos_val.pkl -└── lidar/ - └── *.bin -``` - - -### 2. Export and Evaluate - -```bash -# Export to ONNX and TensorRT with evaluation -python projects/CenterPoint/deploy/main.py \ - projects/CenterPoint/deploy/configs/deploy_config.py \ - projects/CenterPoint/configs/t4dataset/Centerpoint/second_secfpn_4xb16_121m_base_amp.py -``` - -### 3. Export Modes - -The pipeline supports different export modes configured in `deploy_config.py`: - -```bash -# ONNX only -# Set export.mode = "onnx" in deploy_config.py - -# TensorRT only (requires existing ONNX files) -# Set export.mode = "trt" and export.onnx_path = "path/to/onnx/dir" - -# Both ONNX and TensorRT -# Set export.mode = "both" - -# Evaluation only (no export) -# Set export.mode = "none" -``` - -## Configuration - -All configuration is done through `deploy_config.py`. Key sections: - -### Checkpoint Path - -```python -# Single source of truth for PyTorch model -checkpoint_path = "work_dirs/centerpoint/best_checkpoint.pth" -``` - -### Export Settings - -```python -export = dict( - mode="both", # 'onnx', 'trt', 'both', 'none' - work_dir="work_dirs/centerpoint_deployment", - onnx_path=None, # Required when mode='trt' -) -``` - -### ONNX Settings - -```python -onnx_config = dict( - opset_version=16, - do_constant_folding=True, - save_file="centerpoint.onnx", - export_params=True, - keep_initializers_as_inputs=False, - simplify=False, # Set to True to run onnx-simplifier - multi_file=True, # CenterPoint uses multi-file ONNX -) -``` - -### Evaluation Settings - -```python -evaluation = dict( - enabled=True, - num_samples=1, # Number of samples to evaluate - verbose=True, - backends=dict( - pytorch=dict(enabled=True, device="cuda:0"), - onnx=dict(enabled=True, device="cuda:0", model_dir="..."), - tensorrt=dict(enabled=True, device="cuda:0", engine_dir="..."), - ), -) -``` - -### TensorRT Settings - -```python -backend_config = dict( - common_config=dict( - precision_policy="auto", # 'auto', 'fp16', 'fp32_tf32', 'strongly_typed' - max_workspace_size=2 << 30, # 2 GB - ), - 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], - ), - ) - ) - ], -) -``` - -### Verification Settings - -```python -verification = dict( - enabled=True, - tolerance=1e-1, - num_verify_samples=1, - devices=devices, # Reference to top-level devices dict - 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=[...], - trt=[...], - none=[], - ), -) -``` - -## Architecture - -CenterPoint uses a multi-file ONNX/TensorRT architecture: - -``` -CenterPoint Model -├── pts_voxel_encoder → pts_voxel_encoder.onnx / .engine -└── pts_backbone_neck_head → pts_backbone_neck_head.onnx / .engine -``` - -### Component Extractor - -The `CenterPointComponentExtractor` handles model-specific logic: -- Extracts voxel encoder and backbone+neck+head components -- Prepares sample inputs for each component -- Configures per-component ONNX export settings - -### Deployment Runner - -`CenterPointDeploymentRunner` orchestrates the export pipeline: -- Loads ONNX-compatible CenterPoint model -- Injects model and config to evaluator -- Delegates export to `CenterPointONNXExportPipeline` and `CenterPointTensorRTExportPipeline` - -## Output Structure - -After deployment: - -``` -work_dirs/centerpoint_deployment/ -├── onnx/ -│ ├── pts_voxel_encoder.onnx -│ └── pts_backbone_neck_head.onnx -└── tensorrt/ - ├── pts_voxel_encoder.engine - └── pts_backbone_neck_head.engine -``` - -## Command Line Options - -```bash -python projects/CenterPoint/deploy/main.py \ - \ - \ - [--rot-y-axis-reference] # Convert rotation to y-axis clockwise reference - [--log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL}] -``` - -## Troubleshooting - -### TensorRT Build Issues - -1. **Memory Issues**: Increase `max_workspace_size` in `backend_config` -2. **Shape Issues**: Verify `model_inputs` shapes match your data -3. **Precision Issues**: Try different `precision_policy` settings - -### Verification Failures - -1. **Tolerance**: Increase `tolerance` in verification config -2. **Samples**: Reduce `num_verify_samples` for faster testing - - -## File Structure - -``` -projects/CenterPoint/deploy/ -├── main.py # Entry point -├── configs/ -│ └── deploy_config.py # Deployment configuration -├── component_extractor.py # Model-specific component extraction -├── data_loader.py # CenterPoint data loader -├── evaluator.py # CenterPoint evaluator -├── utils.py # Utility functions -└── README.md # This file -``` - -## References - -- [Deployment Framework Documentation](../../../deployment/README.md) -- [CenterPoint Paper](https://arxiv.org/abs/2006.11275) diff --git a/projects/CenterPoint/deploy/__init__.py b/projects/CenterPoint/deploy/__init__.py deleted file mode 100644 index faf1e1867..000000000 --- a/projects/CenterPoint/deploy/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""CenterPoint Deployment Module.""" - -from .data_loader import CenterPointDataLoader - -__all__ = ["CenterPointDataLoader"] diff --git a/projects/CenterPoint/deploy/configs/deploy_config.py b/projects/CenterPoint/deploy/configs/deploy_config.py deleted file mode 100644 index 4612b344b..000000000 --- a/projects/CenterPoint/deploy/configs/deploy_config.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -CenterPoint Deployment Configuration -""" - -# ============================================================================ -# Task type for pipeline building -# Options: 'detection2d', 'detection3d', 'classification', 'segmentation' -# ============================================================================ -task_type = "detection3d" - -# ============================================================================ -# Checkpoint Path - Single source of truth for PyTorch model -# ============================================================================ -# This is the main checkpoint path 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 -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( - # Export mode: - # - 'onnx' : export PyTorch -> ONNX - # - 'trt' : build TensorRT engine from an existing ONNX - # - 'both' : export PyTorch -> ONNX -> TensorRT - # - 'none' : no export (only evaluation / verification on existing artifacts) - mode="none", - # ---- Common options ---------------------------------------------------- - work_dir="work_dirs/centerpoint_deployment", - # ---- ONNX source when building TensorRT only --------------------------- - # Rule: - # - mode == 'trt' -> onnx_path MUST be provided (file or directory) - # - mode in ['onnx', 'both'] -> onnx_path can be None (pipeline uses newly exported ONNX) - onnx_path=None, # e.g. "work_dirs/centerpoint_deployment/centerpoint.onnx" -) - -# ============================================================================ -# Runtime I/O settings -# ============================================================================ -runtime_io = dict( - # Path to info.pkl file - info_file="data/t4dataset/info/t4dataset_j6gen2_infos_val.pkl", - # Sample index for export (use first sample) - sample_idx=1, -) - -# ============================================================================ -# Model Input/Output Configuration -# ============================================================================ -model_io = dict( - # Primary input configuration for 3D detection - input_name="voxels", - input_shape=(32, 4), # (max_points_per_voxel, point_dim); batch dim added automatically - input_dtype="float32", - # Additional inputs for 3D detection - additional_inputs=[ - dict(name="num_points", shape=(-1,), dtype="int32"), # (num_voxels,) - dict(name="coors", shape=(-1, 4), dtype="int32"), # (num_voxels, 4) = (batch, z, y, x) - ], - # Head output names for ONNX export (order matters!) - # These are tied to CenterHead architecture - head_output_names=("heatmap", "reg", "height", "dim", "rot", "vel"), - # Batch size configuration - # - int : fixed batch size - # - None : dynamic batch size with dynamic_axes - batch_size=None, - # Dynamic axes when batch_size=None - dynamic_axes={ - "voxels": {0: "num_voxels"}, - "num_points": {0: "num_voxels"}, - "coors": {0: "num_voxels"}, - }, -) - -# ============================================================================ -# ONNX Export Configuration -# ============================================================================ -# CenterPoint uses multi-file ONNX export (voxel encoder + backbone/head). -# Component definitions below specify names and file outputs for each stage. -onnx_config = dict( - opset_version=16, - do_constant_folding=True, - export_params=True, - keep_initializers_as_inputs=False, - simplify=False, - # Multi-file export: produces a directory with multiple .onnx/.engine files - multi_file=True, - # Component definitions for multi-stage export - # Each component maps to a model sub-module that gets exported separately - 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 for TensorRT - # Options: 'auto', 'fp16', 'fp32_tf32', 'strongly_typed' - precision_policy="auto", - # TensorRT workspace size (bytes) - max_workspace_size=2 << 30, # 2 GB - ), - model_inputs=[ - dict( - input_shapes=dict( - input_features=dict( - min_shape=[1000, 32, 11], # Minimum supported input shape - opt_shape=[20000, 32, 11], # Optimal shape for performance tuning - max_shape=[64000, 32, 11], # Maximum supported input shape - ), - 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, # Number of samples to evaluate - verbose=True, - # Decide which backends to evaluate and on which devices. - # Note: - # - tensorrt.device MUST be a CUDA device (e.g., 'cuda:0') - # - For 'none' export mode, all models must already exist on disk. - # - PyTorch backend uses top-level checkpoint_path (no need to specify here) - backends=dict( - # PyTorch evaluation (uses top-level checkpoint_path) - pytorch=dict( - enabled=True, - device=devices["cuda"], # or 'cpu' - ), - # ONNX evaluation - onnx=dict( - enabled=True, - device=devices["cuda"], # 'cpu' or 'cuda:0' - # If None: pipeline will infer from export.work_dir / onnx_config.save_file - # model_dir=None, - model_dir="work_dirs/centerpoint_deployment/onnx/", - ), - # TensorRT evaluation - tensorrt=dict( - enabled=True, - device=devices["cuda"], # must be CUDA - # If None: pipeline will infer from export.work_dir + "/tensorrt" - # engine_dir=None, - engine_dir="work_dirs/centerpoint_deployment/tensorrt/", - ), - ), -) - -# ============================================================================ -# Verification Configuration -# ============================================================================ -# This block defines *scenarios* per export.mode, so the pipeline does not -# need many if/else branches; it just chooses the policy based on export["mode"]. -# ---------------------------------------------------------------------------- -verification = dict( - # Master switch to enable/disable verification - enabled=False, - tolerance=1e-1, - num_verify_samples=1, - # Device aliases for flexible device management - # - # Benefits of using aliases: - # - Change all CPU verifications to "cuda:1"? Just update devices["cpu"] = "cuda:1" - # - Switch ONNX verification device? Just update devices["cuda"] = "cuda:1" - # - Scenarios reference these aliases (e.g., ref_device="cpu", test_device="cuda") - devices=devices, - # Verification scenarios per export mode - # - # Each policy is a list of comparison pairs: - # - ref_backend : reference backend ('pytorch' or 'onnx') - # - ref_device : device alias (e.g., "cpu", "cuda") - resolved via devices dict above - # - test_backend : backend under test ('onnx' or 'tensorrt') - # - test_device : device alias (e.g., "cpu", "cuda") - resolved via devices dict above - # - # Pipeline resolves devices like: actual_device = verification["devices"][policy["ref_device"]] - # - # This structure encodes: - # - 'both': - # 1) PyTorch(cpu) vs ONNX(cpu) - # 2) ONNX(cuda) vs TensorRT(cuda) - # - 'onnx': - # 1) PyTorch(cpu) vs ONNX(cpu) - # - 'trt': - # 1) ONNX(cuda) vs TensorRT(cuda) (using provided ONNX) - 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/projects/CenterPoint/deploy/main.py b/projects/CenterPoint/deploy/main.py deleted file mode 100644 index 39d74a952..000000000 --- a/projects/CenterPoint/deploy/main.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -CenterPoint Deployment Main Script (Unified Runner Architecture). - -This script uses the unified deployment runner to handle the complete deployment workflow: -- Export to ONNX and/or TensorRT -- Verify outputs across backends -- Evaluate model performance -""" - -import sys -from pathlib import Path - -from mmengine.config import Config - -# Add project root to path -project_root = Path(__file__).parent.parent.parent.parent -sys.path.insert(0, str(project_root)) - -from deployment.core import BaseDeploymentConfig, setup_logging -from deployment.core.config.base_config import parse_base_args -from deployment.core.contexts import CenterPointExportContext -from deployment.runners import CenterPointDeploymentRunner -from projects.CenterPoint.deploy.data_loader import CenterPointDataLoader -from projects.CenterPoint.deploy.evaluator import CenterPointEvaluator -from projects.CenterPoint.deploy.utils import extract_t4metric_v2_config - - -def parse_args(): - """Parse command line arguments.""" - parser = parse_base_args() - - # Add CenterPoint-specific arguments - parser.add_argument( - "--rot-y-axis-reference", action="store_true", help="Convert rotation to y-axis clockwise reference" - ) - - args = parser.parse_args() - return args - - -def main(): - """Main deployment pipeline using unified runner.""" - # Parse arguments - args = parse_args() - - # Setup logging - logger = setup_logging(args.log_level) - - # Load configs - 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") - logger.info("=" * 80) - logger.info("Deployment Configuration:") - logger.info(f" Export mode: {config.export_config.mode.value}") - logger.info(f" Work dir: {config.export_config.work_dir}") - logger.info(f" Verify: {config.verification_config.enabled}") - logger.info(f" CUDA device (TensorRT): {config.devices.cuda}") - eval_devices_cfg = config.evaluation_config.devices - logger.info(" Evaluation devices:") - logger.info(f" PyTorch: {eval_devices_cfg.get('pytorch', 'cpu')}") - logger.info(f" ONNX: {eval_devices_cfg.get('onnx', 'cpu')}") - logger.info(f" TensorRT: {eval_devices_cfg.get('tensorrt', config.devices.cuda)}") - logger.info(f" Y-axis rotation: {args.rot_y_axis_reference}") - logger.info(f" Runner will build ONNX-compatible model internally") - - # Validate checkpoint path for export - if config.export_config.should_export_onnx(): - checkpoint_path = config.checkpoint_path - if not checkpoint_path: - logger.error("Checkpoint path must be provided in export.checkpoint_path for ONNX/TensorRT export.") - return - - # Create data loader - logger.info("\nCreating data loader...") - 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") - - # Extract T4MetricV2 config from model_cfg - logger.info("\nExtracting T4MetricV2 config from model config...") - metrics_config = extract_t4metric_v2_config(model_cfg, logger=logger) - if metrics_config is None: - logger.warning( - "T4MetricV2 config not found in model_cfg. " - "Using default metrics configuration for deployment evaluation." - ) - else: - logger.info("Successfully extracted T4MetricV2 config from model config") - - # Create evaluator with original model_cfg and extracted metrics_config - # Runner will convert model_cfg to ONNX-compatible config and inject both model_cfg and pytorch_model - evaluator = CenterPointEvaluator( - model_cfg=model_cfg, # original cfg; will be updated to ONNX cfg by runner - metrics_config=metrics_config, # extracted from model_cfg or None (will use defaults) - ) - - # Create CenterPoint-specific runner - # Runner will load model and inject it into evaluator - runner = CenterPointDeploymentRunner( - data_loader=data_loader, - evaluator=evaluator, - config=config, - model_cfg=model_cfg, # original cfg; runner will convert to ONNX cfg in load_pytorch_model() - logger=logger, - ) - - # Execute deployment workflow with typed context - context = CenterPointExportContext(rot_y_axis_reference=args.rot_y_axis_reference) - runner.run(context=context) - - logger.info("\n" + "=" * 80) - logger.info("Deployment Complete!") - logger.info("=" * 80) - - -if __name__ == "__main__": - main() From cf73a986b5a00cbfc80b2115dd0066281e0b31d9 Mon Sep 17 00:00:00 2001 From: vividf Date: Wed, 24 Dec 2025 00:31:19 +0900 Subject: [PATCH 61/62] chore: move onnx related files from projects to deployment Signed-off-by: vividf --- .../centerpoint/config/deploy_config.py | 2 +- deployment/projects/centerpoint/entrypoint.py | 7 ------ .../centerpoint/export/component_extractor.py | 2 +- .../projects/centerpoint/model_loader.py | 12 ++++++---- .../centerpoint/onnx_models/__init__.py | 23 +++++++++++++++++++ .../onnx_models}/centerpoint_head_onnx.py | 2 +- .../onnx_models}/centerpoint_onnx.py | 0 .../onnx_models}/pillar_encoder_onnx.py | 2 +- projects/CenterPoint/models/__init__.py | 8 ------- 9 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 deployment/projects/centerpoint/onnx_models/__init__.py rename {projects/CenterPoint/models/dense_heads => deployment/projects/centerpoint/onnx_models}/centerpoint_head_onnx.py (98%) rename {projects/CenterPoint/models/detectors => deployment/projects/centerpoint/onnx_models}/centerpoint_onnx.py (100%) rename {projects/CenterPoint/models/voxel_encoders => deployment/projects/centerpoint/onnx_models}/pillar_encoder_onnx.py (98%) diff --git a/deployment/projects/centerpoint/config/deploy_config.py b/deployment/projects/centerpoint/config/deploy_config.py index e270b3b27..d7e1894cd 100644 --- a/deployment/projects/centerpoint/config/deploy_config.py +++ b/deployment/projects/centerpoint/config/deploy_config.py @@ -28,7 +28,7 @@ # Export Configuration # ============================================================================ export = dict( - mode="both", + mode="none", work_dir="work_dirs/centerpoint_deployment", onnx_path=None, ) diff --git a/deployment/projects/centerpoint/entrypoint.py b/deployment/projects/centerpoint/entrypoint.py index f1ab42eae..a04ad014c 100644 --- a/deployment/projects/centerpoint/entrypoint.py +++ b/deployment/projects/centerpoint/entrypoint.py @@ -35,13 +35,6 @@ def run(args) -> int: logger.info(f"Loaded {data_loader.get_num_samples()} samples") metrics_config = extract_t4metric_v2_config(model_cfg, logger=logger) - if metrics_config is None: - # Fall back to sane defaults (Detection3DMetricsConfig will populate defaults in __post_init__) - class_names = getattr(model_cfg, "class_names", None) - if not class_names: - raise ValueError("model_cfg.class_names is required for CenterPoint evaluation") - metrics_config = Detection3DMetricsConfig(class_names=list(class_names), frame_id="base_link") - logger.warning("T4MetricV2 config not found in model_cfg; using default deployment metrics config.") evaluator = CenterPointEvaluator( model_cfg=model_cfg, diff --git a/deployment/projects/centerpoint/export/component_extractor.py b/deployment/projects/centerpoint/export/component_extractor.py index 92afd3563..77e0ff779 100644 --- a/deployment/projects/centerpoint/export/component_extractor.py +++ b/deployment/projects/centerpoint/export/component_extractor.py @@ -12,7 +12,7 @@ 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 projects.CenterPoint.models.detectors.centerpoint_onnx import CenterPointHeadONNX +from deployment.projects.centerpoint.onnx_models.centerpoint_onnx import CenterPointHeadONNX logger = logging.getLogger(__name__) diff --git a/deployment/projects/centerpoint/model_loader.py b/deployment/projects/centerpoint/model_loader.py index 36041ae00..c24cfee4d 100644 --- a/deployment/projects/centerpoint/model_loader.py +++ b/deployment/projects/centerpoint/model_loader.py @@ -14,6 +14,7 @@ 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( @@ -48,6 +49,9 @@ def create_onnx_model_cfg( def build_model_from_cfg(model_cfg: Config, checkpoint_path: str, device: str) -> torch.nn.Module: + # 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) @@ -77,7 +81,7 @@ def extract_t4metric_v2_config( model_cfg: Config, class_names: Optional[List[str]] = None, logger: Optional[logging.Logger] = None, -) -> Optional[Detection3DMetricsConfig]: +) -> Detection3DMetricsConfig: if logger is None: logger = logging.getLogger(__name__) @@ -93,8 +97,7 @@ def extract_t4metric_v2_config( elif hasattr(model_cfg, "test_evaluator"): evaluator_cfg = model_cfg.test_evaluator else: - logger.warning("No val_evaluator or test_evaluator found in model_cfg") - return None + raise ValueError("No val_evaluator or test_evaluator found in model_cfg") def get_cfg_value(cfg, key, default=None): if cfg is None: @@ -105,8 +108,7 @@ def get_cfg_value(cfg, key, default=None): evaluator_type = get_cfg_value(evaluator_cfg, "type") if evaluator_type != "T4MetricV2": - logger.warning(f"Evaluator type is '{evaluator_type}', not 'T4MetricV2'. Returning None.") - return None + 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") diff --git a/deployment/projects/centerpoint/onnx_models/__init__.py b/deployment/projects/centerpoint/onnx_models/__init__.py new file mode 100644 index 000000000..48433357a --- /dev/null +++ b/deployment/projects/centerpoint/onnx_models/__init__.py @@ -0,0 +1,23 @@ +"""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: + # 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 98% rename from projects/CenterPoint/models/dense_heads/centerpoint_head_onnx.py rename to deployment/projects/centerpoint/onnx_models/centerpoint_head_onnx.py index c12df19cd..abca0d504 100644 --- a/projects/CenterPoint/models/dense_heads/centerpoint_head_onnx.py +++ b/deployment/projects/centerpoint/onnx_models/centerpoint_head_onnx.py @@ -5,7 +5,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/projects/CenterPoint/models/detectors/centerpoint_onnx.py b/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py similarity index 100% rename from projects/CenterPoint/models/detectors/centerpoint_onnx.py rename to deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py diff --git a/projects/CenterPoint/models/voxel_encoders/pillar_encoder_onnx.py b/deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py similarity index 98% rename from projects/CenterPoint/models/voxel_encoders/pillar_encoder_onnx.py rename to deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py index 73ab10d90..03983900d 100644 --- a/projects/CenterPoint/models/voxel_encoders/pillar_encoder_onnx.py +++ b/deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py @@ -7,7 +7,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/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", ] From 188f45a8adf3cb0a12580ed586244a6ea97a3585 Mon Sep 17 00:00:00 2001 From: vividf Date: Wed, 24 Dec 2025 13:19:15 +0900 Subject: [PATCH 62/62] chore: add docstring Signed-off-by: vividf --- deployment/cli/main.py | 14 ++++++++++++++ deployment/pipelines/base_factory.py | 7 +++++++ deployment/pipelines/base_pipeline.py | 9 +++++++++ deployment/pipelines/gpu_resource_mixin.py | 18 ++++++++++++++++++ deployment/pipelines/registry.py | 6 ++++++ deployment/projects/centerpoint/cli.py | 1 + deployment/projects/centerpoint/data_loader.py | 8 ++++++++ deployment/projects/centerpoint/entrypoint.py | 5 +++++ deployment/projects/centerpoint/evaluator.py | 6 ++++++ .../centerpoint/export/component_extractor.py | 7 +++++++ .../centerpoint/export/onnx_export_pipeline.py | 6 ++++++ .../export/tensorrt_export_pipeline.py | 6 ++++++ .../projects/centerpoint/model_loader.py | 11 +++++++++++ .../centerpoint/onnx_models/__init__.py | 5 +++++ .../onnx_models/centerpoint_head_onnx.py | 6 ++++++ .../onnx_models/centerpoint_onnx.py | 6 ++++++ .../onnx_models/pillar_encoder_onnx.py | 6 ++++++ .../pipelines/centerpoint_pipeline.py | 7 +++++++ .../projects/centerpoint/pipelines/factory.py | 2 ++ .../projects/centerpoint/pipelines/onnx.py | 2 ++ .../projects/centerpoint/pipelines/pytorch.py | 2 ++ .../projects/centerpoint/pipelines/tensorrt.py | 2 ++ deployment/projects/centerpoint/runner.py | 6 ++++++ deployment/projects/registry.py | 7 +++++++ 24 files changed, 155 insertions(+) diff --git a/deployment/cli/main.py b/deployment/cli/main.py index 7fe56e2a0..3a1906148 100644 --- a/deployment/cli/main.py +++ b/deployment/cli/main.py @@ -37,6 +37,12 @@ def _import_and_register_project(project_name: str) -> None: 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, @@ -66,6 +72,14 @@ def build_parser() -> argparse.ArgumentParser: 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) diff --git a/deployment/pipelines/base_factory.py b/deployment/pipelines/base_factory.py index b597d5baf..f0f358e14 100644 --- a/deployment/pipelines/base_factory.py +++ b/deployment/pipelines/base_factory.py @@ -16,6 +16,13 @@ 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: diff --git a/deployment/pipelines/base_pipeline.py b/deployment/pipelines/base_pipeline.py index 45771cd24..ff7bc7c93 100644 --- a/deployment/pipelines/base_pipeline.py +++ b/deployment/pipelines/base_pipeline.py @@ -17,6 +17,15 @@ 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 diff --git a/deployment/pipelines/gpu_resource_mixin.py b/deployment/pipelines/gpu_resource_mixin.py index dd07da7cf..a55ef1b88 100644 --- a/deployment/pipelines/gpu_resource_mixin.py +++ b/deployment/pipelines/gpu_resource_mixin.py @@ -15,12 +15,19 @@ 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 @@ -54,6 +61,12 @@ def __del__(self): 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 @@ -95,6 +108,11 @@ def release_tensorrt_resources( 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: diff --git a/deployment/pipelines/registry.py b/deployment/pipelines/registry.py index 7e6b66f2a..7cf7ffc03 100644 --- a/deployment/pipelines/registry.py +++ b/deployment/pipelines/registry.py @@ -15,6 +15,12 @@ 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]] = {} diff --git a/deployment/projects/centerpoint/cli.py b/deployment/projects/centerpoint/cli.py index 615c03c7a..cc040e0d9 100644 --- a/deployment/projects/centerpoint/cli.py +++ b/deployment/projects/centerpoint/cli.py @@ -6,6 +6,7 @@ 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", diff --git a/deployment/projects/centerpoint/data_loader.py b/deployment/projects/centerpoint/data_loader.py index ed65cd45b..0c713a3ea 100644 --- a/deployment/projects/centerpoint/data_loader.py +++ b/deployment/projects/centerpoint/data_loader.py @@ -16,6 +16,14 @@ 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, diff --git a/deployment/projects/centerpoint/entrypoint.py b/deployment/projects/centerpoint/entrypoint.py index a04ad014c..2fd6a7022 100644 --- a/deployment/projects/centerpoint/entrypoint.py +++ b/deployment/projects/centerpoint/entrypoint.py @@ -16,6 +16,11 @@ 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) diff --git a/deployment/projects/centerpoint/evaluator.py b/deployment/projects/centerpoint/evaluator.py index 6b64b8d06..60f1df90e 100644 --- a/deployment/projects/centerpoint/evaluator.py +++ b/deployment/projects/centerpoint/evaluator.py @@ -25,6 +25,12 @@ 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, diff --git a/deployment/projects/centerpoint/export/component_extractor.py b/deployment/projects/centerpoint/export/component_extractor.py index 77e0ff779..5c5aed120 100644 --- a/deployment/projects/centerpoint/export/component_extractor.py +++ b/deployment/projects/centerpoint/export/component_extractor.py @@ -18,6 +18,13 @@ 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 diff --git a/deployment/projects/centerpoint/export/onnx_export_pipeline.py b/deployment/projects/centerpoint/export/onnx_export_pipeline.py index f6a493d6e..6938336b7 100644 --- a/deployment/projects/centerpoint/export/onnx_export_pipeline.py +++ b/deployment/projects/centerpoint/export/onnx_export_pipeline.py @@ -21,6 +21,12 @@ 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], diff --git a/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py b/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py index 2f645deec..d390371b0 100644 --- a/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py +++ b/deployment/projects/centerpoint/export/tensorrt_export_pipeline.py @@ -19,6 +19,12 @@ 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__( diff --git a/deployment/projects/centerpoint/model_loader.py b/deployment/projects/centerpoint/model_loader.py index c24cfee4d..f48558e63 100644 --- a/deployment/projects/centerpoint/model_loader.py +++ b/deployment/projects/centerpoint/model_loader.py @@ -22,6 +22,11 @@ def create_onnx_model_cfg( 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) @@ -49,6 +54,7 @@ def create_onnx_model_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() @@ -68,6 +74,7 @@ def build_centerpoint_onnx_model( 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, @@ -82,6 +89,10 @@ def extract_t4metric_v2_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__) diff --git a/deployment/projects/centerpoint/onnx_models/__init__.py b/deployment/projects/centerpoint/onnx_models/__init__.py index 48433357a..94e04a288 100644 --- a/deployment/projects/centerpoint/onnx_models/__init__.py +++ b/deployment/projects/centerpoint/onnx_models/__init__.py @@ -14,6 +14,11 @@ 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 diff --git a/deployment/projects/centerpoint/onnx_models/centerpoint_head_onnx.py b/deployment/projects/centerpoint/onnx_models/centerpoint_head_onnx.py index abca0d504..ee2a85491 100644 --- a/deployment/projects/centerpoint/onnx_models/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 diff --git a/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py b/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py index 7420a110e..ec83124d6 100644 --- a/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py +++ b/deployment/projects/centerpoint/onnx_models/centerpoint_onnx.py @@ -1,3 +1,9 @@ +"""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 diff --git a/deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py b/deployment/projects/centerpoint/onnx_models/pillar_encoder_onnx.py index 03983900d..45f81bbde 100644 --- a/deployment/projects/centerpoint/onnx_models/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 diff --git a/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py b/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py index 513bf5810..31015d625 100644 --- a/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py +++ b/deployment/projects/centerpoint/pipelines/centerpoint_pipeline.py @@ -18,6 +18,13 @@ 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) diff --git a/deployment/projects/centerpoint/pipelines/factory.py b/deployment/projects/centerpoint/pipelines/factory.py index d5eaf3d7d..af3bfceab 100644 --- a/deployment/projects/centerpoint/pipelines/factory.py +++ b/deployment/projects/centerpoint/pipelines/factory.py @@ -22,6 +22,8 @@ @pipeline_registry.register class CenterPointPipelineFactory(BasePipelineFactory): + """Pipeline factory for CenterPoint across supported backends.""" + @classmethod def get_project_name(cls) -> str: return "centerpoint" diff --git a/deployment/projects/centerpoint/pipelines/onnx.py b/deployment/projects/centerpoint/pipelines/onnx.py index 07f63c8be..f42713fbc 100644 --- a/deployment/projects/centerpoint/pipelines/onnx.py +++ b/deployment/projects/centerpoint/pipelines/onnx.py @@ -18,6 +18,8 @@ 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") diff --git a/deployment/projects/centerpoint/pipelines/pytorch.py b/deployment/projects/centerpoint/pipelines/pytorch.py index 4841c6478..3f43aab55 100644 --- a/deployment/projects/centerpoint/pipelines/pytorch.py +++ b/deployment/projects/centerpoint/pipelines/pytorch.py @@ -15,6 +15,8 @@ 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)") diff --git a/deployment/projects/centerpoint/pipelines/tensorrt.py b/deployment/projects/centerpoint/pipelines/tensorrt.py index fae8c5d8c..525c2bbd1 100644 --- a/deployment/projects/centerpoint/pipelines/tensorrt.py +++ b/deployment/projects/centerpoint/pipelines/tensorrt.py @@ -26,6 +26,8 @@ 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") diff --git a/deployment/projects/centerpoint/runner.py b/deployment/projects/centerpoint/runner.py index 33a6de94a..47e35338e 100644 --- a/deployment/projects/centerpoint/runner.py +++ b/deployment/projects/centerpoint/runner.py @@ -20,6 +20,12 @@ 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, diff --git a/deployment/projects/registry.py b/deployment/projects/registry.py index c1bcd361f..a64bc73a7 100644 --- a/deployment/projects/registry.py +++ b/deployment/projects/registry.py @@ -25,6 +25,13 @@ class ProjectAdapter: 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] = {}