From a130ac3f563dfb38f1e3018dcecdf4efd04cbdc3 Mon Sep 17 00:00:00 2001 From: wyh88 <2469410854@qq.com> Date: Sun, 13 Sep 2026 06:34:27 -0400 Subject: [PATCH 1/3] feat(yolox): add YOLOX-s object detection Signed-off-by: wyh88 <2469410854@qq.com> --- apps/benchmark/performance/release.yaml | 4 + families/yolox/__init__.py | 4 + families/yolox/checkpoint.py | 41 +++ families/yolox/graph.py | 183 +++++++++++ families/yolox/model.py | 301 ++++++++++++++++++ families/yolox/requirements.txt | 13 + families/yolox/runtime/CMakeLists.txt | 53 +++ .../yolox/runtime/image_preprocess_seam.cpp | 77 +++++ .../yolox/runtime/image_preprocess_seam.h | 33 ++ families/yolox/runtime/pipeline.cpp | 152 +++++++++ families/yolox/runtime/pipeline.h | 40 +++ families/yolox/runtime/plugin.cpp | 66 ++++ families/yolox/support.py | 13 + families/yolox/tests/__init__.py | 2 + .../tests/cpp/test_image_preprocess_seam.cpp | 92 ++++++ families/yolox/tests/data/test_img.jpeg | Bin 0 -> 55258 bytes families/yolox/tests/manifests/yolox-s.json | 21 ++ families/yolox/tests/reference-source.json | 4 + families/yolox/tests/test_e2e.py | 276 ++++++++++++++++ families/yolox/tests/test_model.py | 124 ++++++++ 20 files changed, 1499 insertions(+) create mode 100644 families/yolox/__init__.py create mode 100644 families/yolox/checkpoint.py create mode 100644 families/yolox/graph.py create mode 100644 families/yolox/model.py create mode 100644 families/yolox/requirements.txt create mode 100644 families/yolox/runtime/CMakeLists.txt create mode 100644 families/yolox/runtime/image_preprocess_seam.cpp create mode 100644 families/yolox/runtime/image_preprocess_seam.h create mode 100644 families/yolox/runtime/pipeline.cpp create mode 100644 families/yolox/runtime/pipeline.h create mode 100644 families/yolox/runtime/plugin.cpp create mode 100644 families/yolox/support.py create mode 100644 families/yolox/tests/__init__.py create mode 100644 families/yolox/tests/cpp/test_image_preprocess_seam.cpp create mode 100644 families/yolox/tests/data/test_img.jpeg create mode 100644 families/yolox/tests/manifests/yolox-s.json create mode 100644 families/yolox/tests/reference-source.json create mode 100644 families/yolox/tests/test_e2e.py create mode 100644 families/yolox/tests/test_model.py diff --git a/apps/benchmark/performance/release.yaml b/apps/benchmark/performance/release.yaml index 8f71ee2901..1ebb281f96 100644 --- a/apps/benchmark/performance/release.yaml +++ b/apps/benchmark/performance/release.yaml @@ -54,6 +54,10 @@ excluded_profiles: performance baseline runs Ultralytics, which cannot load a legacy YOLOv5 archive: those archives pickle classes from the standalone yolov5 repository, which is not a dependency here. + - model: yolox-s + reason: >- + Functional and official-reference parity are covered by family tests, + but no matching release-performance workload or receipt is provided. - model: mobilenetv4-conv-small reason: &mobilenetv4_performance_exclusion >- Functional and timm reference-parity qualification is present for every diff --git a/families/yolox/__init__.py b/families/yolox/__init__.py new file mode 100644 index 0000000000..2d5b3dd219 --- /dev/null +++ b/families/yolox/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""YOLOX object detection family.""" diff --git a/families/yolox/checkpoint.py b/families/yolox/checkpoint.py new file mode 100644 index 0000000000..fcb9d3ce35 --- /dev/null +++ b/families/yolox/checkpoint.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Read the official YOLOX-s state dictionary without unpickling model code.""" + +from pathlib import Path + +import numpy as np +import torch + + +class Checkpoint: + def __init__(self, state: dict[str, torch.Tensor]) -> None: + if not isinstance(state, dict) or not state: + raise ValueError("YOLOX checkpoint must contain a non-empty model state dictionary") + self.state = state + self.used: set[str] = set() + for name, tensor in state.items(): + if not isinstance(name, str) or not isinstance(tensor, torch.Tensor): + raise ValueError("YOLOX model state must contain named tensors") + if not torch.isfinite(tensor).all(): + raise ValueError(f"YOLOX checkpoint contains non-finite values: {name}") + + @classmethod + def open(cls, model_dir: Path) -> "Checkpoint": + archive = torch.load(model_dir / "yolox_s.pth", map_location="cpu", weights_only=True) + if not isinstance(archive, dict) or "model" not in archive: + raise ValueError("YOLOX checkpoint must contain a model state dictionary") + return cls(archive["model"]) + + def tensor(self, name: str) -> np.ndarray: + if name not in self.state: + raise ValueError(f"YOLOX checkpoint is missing {name}") + self.used.add(name) + return self.state[name].detach().float().numpy() + + def assert_consumed(self) -> None: + unused = set(self.state) - self.used + unused = {name for name in unused if not name.endswith(".bn.num_batches_tracked")} + if unused: + raise ValueError(f"Unsupported YOLOX-s checkpoint tensors: {sorted(unused)}") diff --git a/families/yolox/graph.py b/families/yolox/graph.py new file mode 100644 index 0000000000..d9e5d6b60b --- /dev/null +++ b/families/yolox/graph.py @@ -0,0 +1,183 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Small TensorRT graph vocabulary owned by YOLOX.""" + +from __future__ import annotations + +import numpy as np +import tensorrt as trt + + +def convolution( + network, + tensor, + weight: np.ndarray, + bias: np.ndarray, + *, + stride: int = 1, + padding: int = 0, + groups: int = 1, + dtype: np.dtype, +): + layer = network.add_convolution_nd( + tensor, + num_output_maps=int(weight.shape[0]), + kernel_shape=(int(weight.shape[2]), int(weight.shape[3])), + kernel=trt.Weights(np.ascontiguousarray(weight, dtype=dtype)), + bias=trt.Weights(np.ascontiguousarray(bias, dtype=dtype)), + ) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX convolution") + layer.stride_nd = (stride, stride) + layer.padding_nd = (padding, padding) + layer.num_groups = groups + return layer.get_output(0) + + +def silu(network, tensor): + """SiLU with FP32 internal arithmetic, as in PyTorch's half kernel. + + Rounding sigmoid and its product separately in FP16 differs from the + upstream single activation and accumulates across the CSP blocks. + """ + dtype = tensor.dtype + if dtype == trt.float16: + cast = network.add_cast(tensor, trt.float32) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX SiLU input cast") + tensor = cast.get_output(0) + gate = network.add_activation(tensor, trt.ActivationType.SIGMOID) + if gate is None: + raise RuntimeError("TensorRT rejected a YOLOX SiLU sigmoid") + product = network.add_elementwise(tensor, gate.get_output(0), trt.ElementWiseOperation.PROD) + if product is None: + raise RuntimeError("TensorRT rejected a YOLOX SiLU product") + output = product.get_output(0) + if dtype == trt.float16: + cast = network.add_cast(output, trt.float16) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX SiLU output cast") + output = cast.get_output(0) + return output + + +def add(network, left, right): + layer = network.add_elementwise(left, right, trt.ElementWiseOperation.SUM) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX add") + return layer.get_output(0) + + +def concatenate(network, tensors, *, axis: int = 1): + layer = network.add_concatenation(tensors) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX concatenation") + layer.axis = axis + return layer.get_output(0) + + +def slice_axis(network, tensor, *, axis: int, start: int, count: int): + shape = [int(value) for value in tensor.shape] + starts, sizes = [0] * len(shape), list(shape) + starts[axis], sizes[axis] = start, count + layer = network.add_slice(tensor, trt.Dims(starts), trt.Dims(sizes), trt.Dims([1] * len(shape))) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX slice") + return layer.get_output(0) + + +def max_pool(network, tensor, *, kernel: int, stride: int, padding: int): + layer = network.add_pooling_nd(tensor, trt.PoolingType.MAX, (kernel, kernel)) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX max pool") + layer.stride_nd = (stride, stride) + layer.padding_nd = (padding, padding) + return layer.get_output(0) + + +def nearest_upsample(network, tensor, factor: int): + layer = network.add_resize(tensor) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX upsample") + layer.resize_mode = trt.InterpolationMode.NEAREST + layer.scales = [1.0, 1.0, float(factor), float(factor)] + return layer.get_output(0) + + +def reshape(network, tensor, shape: tuple[int, ...]): + layer = network.add_shuffle(tensor) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX reshape") + layer.reshape_dims = trt.Dims(shape) + return layer.get_output(0) + + +def permute(network, tensor, permutation: tuple[int, ...]): + layer = network.add_shuffle(tensor) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX permutation") + layer.second_transpose = trt.Permutation(permutation) + return layer.get_output(0) + + +def sigmoid(network, tensor): + layer = network.add_activation(tensor, trt.ActivationType.SIGMOID) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX sigmoid") + return layer.get_output(0) + + +def scale(network, tensor, factor: float, *, dtype: np.dtype): + shape = (1,) * len(tuple(tensor.shape)) + layer = network.add_constant(shape, trt.Weights(np.array([factor], dtype=dtype).reshape(shape))) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX scale constant") + values = layer.get_output(0) + if values.dtype != tensor.dtype: + cast = network.add_cast(values, tensor.dtype) + if cast is None: + raise RuntimeError("TensorRT rejected a YOLOX scale cast") + values = cast.get_output(0) + product = network.add_elementwise(tensor, values, trt.ElementWiseOperation.PROD) + if product is None: + raise RuntimeError("TensorRT rejected a YOLOX scale product") + return product.get_output(0) + + +def constant(network, values: np.ndarray, *, dtype: np.dtype, like=None): + layer = network.add_constant( + values.shape, trt.Weights(np.ascontiguousarray(values, dtype=dtype)) + ) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX constant") + output = layer.get_output(0) + if like is None or output.dtype == like.dtype: + return output + cast = network.add_cast(output, like.dtype) + if cast is None: + raise RuntimeError("TensorRT rejected a YOLOX constant cast") + return cast.get_output(0) + + +def subtract(network, left, right): + layer = network.add_elementwise(left, right, trt.ElementWiseOperation.SUB) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX subtraction") + return layer.get_output(0) + + +def top_k(network, tensor, *, k: int, axis: int): + """Largest `k` values along one axis, with their indices.""" + layer = network.add_topk(tensor, trt.TopKOperation.MAX, k, 1 << axis) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX top-k") + return layer.get_output(0), layer.get_output(1) + + +def multiply(network, left, right): + """Element-wise product; TensorRT broadcasts size-one axes.""" + layer = network.add_elementwise(left, right, trt.ElementWiseOperation.PROD) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX product") + return layer.get_output(0) diff --git a/families/yolox/model.py b/families/yolox/model.py new file mode 100644 index 0000000000..64c691bb7e --- /dev/null +++ b/families/yolox/model.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""YOLOX-s: Focus/CSPDarknet, PAFPN and a decoupled anchor-free head. + +The topology follows Megvii-BaseDetection/YOLOX at +6ddff4824372906469a7fae2dc3206c7aa4bbaee, exps/default/yolox_s.py. +TensorRT owns lowering and execution; this family specifies the graph. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +import tensorrt as trt + +from . import graph +from .checkpoint import Checkpoint + +if TYPE_CHECKING: + from tensorrt_model_connect.build import BuildRequest + from tensorrt_model_connect.bundle_writer import BundleWriter + + +# Exp.get_model overrides the PyTorch default epsilon before loading weights. +_BATCH_NORM_EPSILON = 1e-3 +_IMAGE_SIZE = 640 +_NUM_CLASSES = 80 +_STRIDES = (8, 16, 32) + + +def _fold(checkpoint: Checkpoint, prefix: str, dtype: np.dtype) -> tuple[np.ndarray, np.ndarray]: + weight = checkpoint.tensor(f"{prefix}.conv.weight") + gamma = checkpoint.tensor(f"{prefix}.bn.weight") + beta = checkpoint.tensor(f"{prefix}.bn.bias") + mean = checkpoint.tensor(f"{prefix}.bn.running_mean") + variance = checkpoint.tensor(f"{prefix}.bn.running_var") + if weight.ndim != 4 or any( + v.shape != (weight.shape[0],) for v in (gamma, beta, mean, variance) + ): + raise ValueError(f"YOLOX convolution/BatchNorm shape mismatch: {prefix}") + if np.any(variance < 0): + raise ValueError(f"YOLOX BatchNorm variance must be non-negative: {prefix}") + scale = gamma / np.sqrt(variance + _BATCH_NORM_EPSILON) + return (weight * scale.reshape(-1, 1, 1, 1)).astype(dtype), (beta - mean * scale).astype(dtype) + + +class _Weights: + """Folded convolutions and plain tensors, addressed by checkpoint prefix.""" + + def __init__(self, checkpoint: Checkpoint, dtype: np.dtype) -> None: + self._checkpoint = checkpoint + self._dtype = dtype + self._folded: dict[str, tuple[np.ndarray, np.ndarray]] = {} + + def conv(self, prefix: str) -> tuple[np.ndarray, np.ndarray]: + if prefix not in self._folded: + self._folded[prefix] = _fold(self._checkpoint, prefix, self._dtype) + return self._folded[prefix] + + def raw(self, name: str) -> np.ndarray: + return self._checkpoint.tensor(name).astype(self._dtype) + + +def _conv(network, tensor, weights: _Weights, prefix: str, dtype, *, stride: int = 1): + weight, bias = weights.conv(prefix) + if weight.shape[1] != int(tensor.shape[1]): + raise ValueError(f"YOLOX-s input channel mismatch: {prefix}") + tensor = graph.convolution( + network, tensor, weight, bias, stride=stride, padding=weight.shape[2] // 2, dtype=dtype + ) + return graph.silu(network, tensor) + + +def _csp(network, tensor, weights: _Weights, prefix: str, dtype, *, count: int, residual: bool): + left = _conv(network, tensor, weights, f"{prefix}.conv1", dtype) + right = _conv(network, tensor, weights, f"{prefix}.conv2", dtype) + for index in range(count): + inner = _conv(network, left, weights, f"{prefix}.m.{index}.conv1", dtype) + inner = _conv(network, inner, weights, f"{prefix}.m.{index}.conv2", dtype) + left = graph.add(network, left, inner) if residual else inner + return _conv( + network, graph.concatenate(network, [left, right]), weights, f"{prefix}.conv3", dtype + ) + + +def _focus(network, tensor): + # Order is top-left, bottom-left, top-right, bottom-right, not row-major. + batch, channels, height, width = map(int, tensor.shape) + parts = [] + for y, x in ((0, 0), (1, 0), (0, 1), (1, 1)): + layer = network.add_slice( + tensor, (0, 0, y, x), (batch, channels, height // 2, width // 2), (1, 1, 2, 2) + ) + if layer is None: + raise RuntimeError("TensorRT rejected YOLOX Focus") + parts.append(layer.get_output(0)) + return graph.concatenate(network, parts) + + +def _backbone(network, pixels, weights: _Weights, dtype): + prefix = "backbone.backbone" + # Preserve small color differences in the unnormalized BGR byte input. + tensor = _conv(network, _focus(network, pixels), weights, f"{prefix}.stem.conv", np.float32) + if dtype == np.float16: + cast = network.add_cast(tensor, trt.float16) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX feature cast") + tensor = cast.get_output(0) + if int(tensor.shape[1]) != 32: + raise ValueError("Only the YOLOX-s width=0.50 checkpoint is supported") + outputs = [] + for stage, count in ((2, 1), (3, 3), (4, 3), (5, 1)): + tensor = _conv(network, tensor, weights, f"{prefix}.dark{stage}.0", dtype, stride=2) + if stage == 5: + entry = _conv(network, tensor, weights, f"{prefix}.dark5.1.conv1", dtype) + # Parallel SPP pools, rather than the SPPF block of later YOLOs. + parts = [entry] + [ + graph.max_pool(network, entry, kernel=k, stride=1, padding=k // 2) + for k in (5, 9, 13) + ] + tensor = _conv( + network, + graph.concatenate(network, parts), + weights, + f"{prefix}.dark5.1.conv2", + dtype, + ) + tensor = _csp( + network, + tensor, + weights, + f"{prefix}.dark{stage}.{2 if stage == 5 else 1}", + dtype, + count=count, + residual=stage != 5, + ) + if stage >= 3: + outputs.append(tensor) + return outputs + + +def _neck(network, sources, weights: _Weights, dtype): + # PAFPN and the head need FP32 to keep score error within 0.01. + promoted = [] + for source in sources: + if source.dtype != trt.float32: + cast = network.add_cast(source, trt.float32) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX PAFPN feature cast") + source = cast.get_output(0) + promoted.append(source) + dark3, dark4, dark5 = promoted + lateral = _conv(network, dark5, weights, "backbone.lateral_conv0", dtype) + merged = graph.concatenate(network, [graph.nearest_upsample(network, lateral, 2), dark4]) + upper = _csp(network, merged, weights, "backbone.C3_p4", dtype, count=1, residual=False) + reduced = _conv(network, upper, weights, "backbone.reduce_conv1", dtype) + merged = graph.concatenate(network, [graph.nearest_upsample(network, reduced, 2), dark3]) + p3 = _csp(network, merged, weights, "backbone.C3_p3", dtype, count=1, residual=False) + merged = graph.concatenate( + network, [_conv(network, p3, weights, "backbone.bu_conv2", dtype, stride=2), reduced] + ) + p4 = _csp(network, merged, weights, "backbone.C3_n3", dtype, count=1, residual=False) + merged = graph.concatenate( + network, [_conv(network, p4, weights, "backbone.bu_conv1", dtype, stride=2), lateral] + ) + p5 = _csp(network, merged, weights, "backbone.C3_n4", dtype, count=1, residual=False) + return p3, p4, p5 + + +def _predict(network, tensor, weights: _Weights, prefix: str, dtype, *, channels: int): + weight, bias = weights.raw(f"{prefix}.weight"), weights.raw(f"{prefix}.bias") + if weight.shape != (channels, int(tensor.shape[1]), 1, 1) or bias.shape != (channels,): + raise ValueError(f"Unsupported YOLOX-s prediction shape: {prefix}") + return graph.convolution(network, tensor, weight, bias, dtype=dtype) + + +def _detect(network, sources, weights: _Weights, dtype): + box_parts, score_parts = [], [] + for level, (tensor, stride) in enumerate(zip(sources, _STRIDES, strict=True)): + stem = _conv(network, tensor, weights, f"head.stems.{level}", dtype) + cls_feature, reg_feature = stem, stem + for index in range(2): + cls_feature = _conv( + network, cls_feature, weights, f"head.cls_convs.{level}.{index}", dtype + ) + reg_feature = _conv( + network, reg_feature, weights, f"head.reg_convs.{level}.{index}", dtype + ) + regression = _predict( + network, reg_feature, weights, f"head.reg_preds.{level}", dtype, channels=4 + ) + objectness = _predict( + network, reg_feature, weights, f"head.obj_preds.{level}", dtype, channels=1 + ) + classes = _predict( + network, cls_feature, weights, f"head.cls_preds.{level}", dtype, channels=_NUM_CLASSES + ) + rows, columns = map(int, regression.shape[2:]) + cells = rows * columns + regression = graph.reshape(network, regression, (4, cells)) + offset = graph.slice_axis(network, regression, axis=0, start=0, count=2) + log_size = graph.slice_axis(network, regression, axis=0, start=2, count=2) + y, x = np.meshgrid(np.arange(rows), np.arange(columns), indexing="ij") + grid = graph.constant(network, np.stack([x.ravel(), y.ravel()]), dtype=np.float32) + centre = graph.scale(network, graph.add(network, offset, grid), stride, dtype=np.float32) + exp = network.add_unary(log_size, trt.UnaryOperation.EXP) + if exp is None: + raise RuntimeError("TensorRT rejected YOLOX box exponential") + half = graph.scale(network, exp.get_output(0), stride * 0.5, dtype=np.float32) + corners = graph.concatenate( + network, + [graph.subtract(network, centre, half), graph.add(network, centre, half)], + axis=0, + ) + box_parts.append(graph.permute(network, corners, (1, 0))) + probabilities = graph.reshape( + network, graph.sigmoid(network, classes), (_NUM_CLASSES, cells) + ) + confidence = graph.reshape(network, graph.sigmoid(network, objectness), (1, cells)) + score_parts.append( + graph.permute(network, graph.multiply(network, probabilities, confidence), (1, 0)) + ) + boxes = graph.concatenate(network, box_parts, axis=0) + scores = graph.concatenate(network, score_parts, axis=0) + best, index = graph.top_k(network, scores, k=1, axis=1) + total = int(boxes.shape[0]) + return (boxes, graph.reshape(network, best, (total,)), graph.reshape(network, index, (total,))) + + +def _build_engine(checkpoint: Checkpoint, precision: str, verbose: bool) -> bytes: + if precision not in {"fp32", "fp16"}: + raise ValueError(f"Unsupported YOLOX precision: {precision}") + numpy_dtype = np.float16 if precision == "fp16" else np.float32 + # Fold once in FP32; graph.convolution converts weights to each layer's dtype. + weights = _Weights(checkpoint, np.float32) + logger = trt.Logger(trt.Logger.VERBOSE if verbose else trt.Logger.WARNING) + builder = trt.Builder(logger) + network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.STRONGLY_TYPED)) + config = builder.create_builder_config() + config.builder_optimization_level = 1 + config.clear_flag(trt.BuilderFlag.TF32) + config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30) + pixels = network.add_input("pixel_values", trt.float32, (1, 3, _IMAGE_SIZE, _IMAGE_SIZE)) + if pixels is None: + raise RuntimeError("TensorRT rejected the YOLOX input") + sources = _backbone(network, pixels, weights, numpy_dtype) + sources = _neck(network, sources, weights, np.float32) + outputs = _detect(network, sources, weights, np.float32) + checkpoint.assert_consumed() + for tensor, name in zip(outputs, ("boxes", "scores", "classes"), strict=True): + tensor.name = name + network.mark_output(tensor) + plan = builder.build_serialized_network(network, config) + if plan is None: + raise RuntimeError("TensorRT YOLOX engine build failed") + return bytes(plan) + + +def build(request: "BuildRequest", writer: "BundleWriter") -> None: + """Build the official 80-class YOLOX-s, batch one, 640 x 640 detector.""" + if request.backend != "trt": + raise NotImplementedError("yolox supports only backend=trt") + if request.task != "object_detection": + raise ValueError("yolox supports only task=object_detection") + if request.dynamic_kv_cache: + raise NotImplementedError("yolox does not support dynamic_kv_cache") + if request.image_height is not None or request.image_width is not None: + raise NotImplementedError("yolox does not support image_height or image_width overrides") + if request.video_num_frames is not None: + raise NotImplementedError("yolox does not support video_num_frames") + if request.max_batch_size != 1: + raise NotImplementedError("yolox does not support max_batch_size other than one") + if request.tensor_parallel_size != 1: + raise NotImplementedError("yolox does not support tensor parallelism") + if request.context_parallel_size != 1: + raise NotImplementedError("yolox does not support context parallelism") + if request.quantization not in {None, "none"}: + raise NotImplementedError("yolox does not support quantization") + if request.fp32_layers: + raise NotImplementedError("yolox does not support mixed-precision layer overrides") + if request.max_sequence_length not in {None, 1}: + raise NotImplementedError("yolox does not support max_sequence_length") + checkpoint = Checkpoint.open(Path(request.model_dir)) + plan = _build_engine(checkpoint, str(request.precision).lower(), bool(request.verbose)) + writer.set_header(family="yolox", task=request.task, backend=request.backend) + writer.add_bytes("engine.plan", plan) + writer.add_json( + "runtime.json", + { + "input_image_h": _IMAGE_SIZE, + "input_image_w": _IMAGE_SIZE, + "pad_value": 114, + "score_threshold": 0.25, + "iou_threshold": 0.45, + "num_classes": _NUM_CLASSES, + "max_detections": 8400, + }, + ) diff --git a/families/yolox/requirements.txt b/families/yolox/requirements.txt new file mode 100644 index 0000000000..5d1d0caf68 --- /dev/null +++ b/families/yolox/requirements.txt @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# PyTorch is used to read the checkpoint; the remaining packages run the official reference. +torch>=2.6 +torchvision +opencv-python-headless +loguru +psutil +pycocotools +Pillow +tabulate +tqdm diff --git a/families/yolox/runtime/CMakeLists.txt b/families/yolox/runtime/CMakeLists.txt new file mode 100644 index 0000000000..d50ac0f99c --- /dev/null +++ b/families/yolox/runtime/CMakeLists.txt @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +add_library(trtmc_model_yolox SHARED + image_preprocess_seam.cpp + pipeline.cpp + plugin.cpp +) +target_include_directories(trtmc_model_yolox PRIVATE + ${PROJECT_SOURCE_DIR} + ${PROJECT_SOURCE_DIR}/core/runtime/include +) +target_include_directories(trtmc_model_yolox SYSTEM PRIVATE + ${TRTMC_CUDA_INCLUDE_DIR} +) +target_link_libraries(trtmc_model_yolox PRIVATE + trtmc_core + nlohmann_json::nlohmann_json + ${TRTMC_CUDART_LIBRARY} +) +target_compile_options(trtmc_model_yolox PRIVATE + "$<$:-Wall;-Wextra;-Wpedantic>" +) +set_target_properties(trtmc_model_yolox PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + BUILD_RPATH "\$ORIGIN" + INSTALL_RPATH "\$ORIGIN" +) +install(TARGETS trtmc_model_yolox + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} +) + +if(TRTMC_BUILD_TESTS) + add_executable(test_yolox_image_preprocess + ${PROJECT_SOURCE_DIR}/families/yolox/tests/cpp/test_image_preprocess_seam.cpp + ) + target_include_directories(test_yolox_image_preprocess PRIVATE + ${PROJECT_SOURCE_DIR} + ${PROJECT_SOURCE_DIR}/core/runtime/include + ) + target_link_libraries(test_yolox_image_preprocess PRIVATE + trtmc_model_yolox + trtmc_core + ${TRTMC_CUDART_LIBRARY} + ) + target_compile_options(test_yolox_image_preprocess PRIVATE + -Wall -Wextra -Wpedantic + ) + add_test( + NAME yolox_image_preprocess + COMMAND test_yolox_image_preprocess + ) +endif() diff --git a/families/yolox/runtime/image_preprocess_seam.cpp b/families/yolox/runtime/image_preprocess_seam.cpp new file mode 100644 index 0000000000..b4a81161d5 --- /dev/null +++ b/families/yolox/runtime/image_preprocess_seam.cpp @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "families/yolox/runtime/image_preprocess_seam.h" + +#include +#include +#include + +namespace trtmc { +namespace { + +float sample_bilinear(const float* image, std::int32_t height, std::int32_t width, + std::int32_t channel, float y, float x) { + // The task supplies interleaved RGB floats. YOLOX resizes integer bytes, + // so quantize the four input samples before interpolation. + const float clamped_y = std::clamp(y, 0.0F, static_cast(height - 1)); + const float clamped_x = std::clamp(x, 0.0F, static_cast(width - 1)); + const auto y0 = static_cast(clamped_y); + const auto x0 = static_cast(clamped_x); + const std::int32_t y1 = std::min(y0 + 1, height - 1); + const std::int32_t x1 = std::min(x0 + 1, width - 1); + const double wy = static_cast(clamped_y) - y0; + const double wx = static_cast(clamped_x) - x0; + const auto at = [&](std::int32_t row, std::int32_t column) { + const auto index = (static_cast(row) * width + column) * 3U + channel; + const float value = image[index]; + if (!std::isfinite(value) || value < 0.0F || value > 1.0F) + throw std::invalid_argument("YOLOX input must be finite RGB pixels in [0, 1]"); + return std::round(value * 255.0F); + }; + const double top = at(y0, x0) * (1.0 - wx) + at(y0, x1) * wx; + const double bottom = at(y1, x0) * (1.0 - wx) + at(y1, x1) * wx; + // OpenCV's fixed-point byte resize may differ by one; E2E bounds this. + return static_cast(std::round(top * (1.0 - wy) + bottom * wy)); +} + +} // namespace + +std::vector preprocess_yolox_image(const float* pixels, std::int32_t height, + std::int32_t width, const YoloxPreprocessConfig& config, + YoloxLetterbox& letterbox) { + if (pixels == nullptr || height <= 0 || width <= 0) + throw std::invalid_argument("YOLOX preprocessing needs a non-empty image"); + if (config.input_image_h <= 0 || config.input_image_w <= 0 || + !std::isfinite(config.pad_value) || config.pad_value < 0.0F || config.pad_value > 255.0F) + throw std::invalid_argument("YOLOX preprocessing configuration is invalid"); + + // Upstream non-legacy preproc: truncate resized dimensions, paste at the + // top left, pad the right and bottom with 114, keep BGR bytes without /255. + const double scale = std::min(static_cast(config.input_image_h) / height, + static_cast(config.input_image_w) / width); + const auto scaled_h = static_cast(height * scale); + const auto scaled_w = static_cast(width * scale); + if (scaled_h < 1 || scaled_w < 1) + throw std::invalid_argument("YOLOX image aspect ratio produces an empty resize"); + letterbox = {static_cast(scale), 0.0F, 0.0F}; + const auto plane = static_cast(config.input_image_h) * config.input_image_w; + std::vector values(3U * plane, config.pad_value); + for (std::int32_t channel = 0; channel < 3; ++channel) { + float* target = values.data() + static_cast(channel) * plane; + for (std::int32_t row = 0; row < scaled_h; ++row) { + const float y = static_cast((row + 0.5) * height / scaled_h - 0.5); + for (std::int32_t column = 0; column < scaled_w; ++column) { + const float x = static_cast((column + 0.5) * width / scaled_w - 0.5); + // Select the RGB source channel for this BGR output plane. + target[static_cast(row) * config.input_image_w + column] = + sample_bilinear(pixels, height, width, 2 - channel, y, x); + } + } + } + return values; +} + +} // namespace trtmc diff --git a/families/yolox/runtime/image_preprocess_seam.h b/families/yolox/runtime/image_preprocess_seam.h new file mode 100644 index 0000000000..4cc102fe06 --- /dev/null +++ b/families/yolox/runtime/image_preprocess_seam.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include + +namespace trtmc { + +struct YoloxPreprocessConfig { + std::int32_t input_image_h{640}; + std::int32_t input_image_w{640}; + float pad_value{114.0F}; +}; + +// How the source image was fitted into the square network input. The pipeline +// needs it to map boxes back, so it is returned rather than recomputed. +struct YoloxLetterbox { + float scale{1.0F}; + float pad_x{0.0F}; + float pad_y{0.0F}; +}; + +// `pixels` is an interleaved RGB image in [0, 1], the layout the CLI's +// image reader produces. The result is planar BGR CHW in [0, 255] for the engine. +std::vector preprocess_yolox_image(const float* pixels, std::int32_t height, + std::int32_t width, const YoloxPreprocessConfig& config, + YoloxLetterbox& letterbox); + +} // namespace trtmc diff --git a/families/yolox/runtime/pipeline.cpp b/families/yolox/runtime/pipeline.cpp new file mode 100644 index 0000000000..f5d31d919a --- /dev/null +++ b/families/yolox/runtime/pipeline.cpp @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "families/yolox/runtime/pipeline.h" + +#include +#include +#include +#include +#include +#include + +namespace trtmc { +namespace { + +const Tensor* find(const TensorMap& outputs, const std::string& name) { + const auto entry = outputs.find(name); + return entry == outputs.end() ? nullptr : &entry->second; +} + +// Intersection over union of two corner-form boxes. +float overlap(const DetectionBox& left, const DetectionBox& right) { + const float x0 = std::max(left.x_min, right.x_min); + const float y0 = std::max(left.y_min, right.y_min); + const float x1 = std::min(left.x_max, right.x_max); + const float y1 = std::min(left.y_max, right.y_max); + const float shared = std::max(0.0F, x1 - x0) * std::max(0.0F, y1 - y0); + if (shared <= 0.0F) + return 0.0F; + const auto area = [](const DetectionBox& box) { + return std::max(0.0F, box.x_max - box.x_min) * std::max(0.0F, box.y_max - box.y_min); + }; + const float total = area(left) + area(right) - shared; + return total > 0.0F ? shared / total : 0.0F; +} + +// Greedy non-maximum suppression, per class. YOLOX's head reports one +// prediction per cell and leaves the overlaps in, so the runtime removes +// them. Boxes of different classes never suppress each other, which is what +// the reference does unless it is asked for the class-agnostic variant. +} // namespace + +std::vector suppress_yolox_boxes(std::vector boxes, float iou_threshold, + std::size_t max_detections) { + if (max_detections == 0) + return {}; + std::stable_sort(boxes.begin(), boxes.end(), [](const DetectionBox& a, const DetectionBox& b) { + return a.score > b.score; + }); + std::vector kept; + std::vector dropped(boxes.size(), false); + for (std::size_t index = 0; index < boxes.size(); ++index) { + if (dropped[index]) + continue; + kept.push_back(boxes[index]); + if (kept.size() >= max_detections) + break; + for (std::size_t other = index + 1; other < boxes.size(); ++other) { + if (dropped[other] || boxes[other].class_id != boxes[index].class_id) + continue; + if (overlap(boxes[index], boxes[other]) > iou_threshold) + dropped[other] = true; + } + } + return kept; +} + +YoloxObjectDetectionPipeline::YoloxObjectDetectionPipeline(std::unique_ptr model, + YoloxPreprocessConfig preprocess_config, + float score_threshold, + float iou_threshold, + std::int32_t max_detections) + : model_(std::move(model)), preprocess_config_(std::move(preprocess_config)), + score_threshold_(score_threshold), iou_threshold_(iou_threshold), + max_detections_(max_detections) { + if (!model_ || !model_->ok()) + throw std::runtime_error("YoloxObjectDetectionPipeline: invalid model"); +} + +ObjectDetectionResult YoloxObjectDetectionPipeline::detect(const float* pixels, int32_t height, + int32_t width) { + YoloxLetterbox letterbox; + auto values = preprocess_yolox_image(pixels, height, width, preprocess_config_, letterbox); + Tensor input; + input.data = values.data(); + input.shape = {1, 3, preprocess_config_.input_image_h, preprocess_config_.input_image_w}; + input.dtype = DType::kFloat32; + const auto outputs = model_->forward({{"pixel_values", input}}); + + const Tensor* boxes = find(outputs, "boxes"); + const Tensor* scores = find(outputs, "scores"); + const Tensor* classes = find(outputs, "classes"); + if (boxes == nullptr || scores == nullptr || classes == nullptr) + throw std::runtime_error("YOLOX engine did not return boxes, scores and classes"); + if (boxes->dtype != DType::kFloat32 || scores->dtype != DType::kFloat32) + throw std::runtime_error("YOLOX boxes and scores must be float32"); + if (classes->dtype != DType::kInt32) + throw std::runtime_error("YOLOX classes must be int32"); + + const auto count = static_cast(scores->numel()); + if (static_cast(boxes->numel()) != count * 4U || + static_cast(classes->numel()) != count) + throw std::runtime_error("YOLOX detection outputs disagree on their length"); + + if (count != 8400 || boxes->data == nullptr || scores->data == nullptr || + classes->data == nullptr) + throw std::runtime_error("YOLOX-s requires 8400 populated detection slots"); + + const auto* box_values = static_cast(boxes->data); + const auto* score_values = static_cast(scores->data); + const auto* class_values = static_cast(classes->data); + + std::vector candidates; + candidates.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + const float score = score_values[index]; + // Every cell is reported, in no particular order, so the whole set + // has to be walked rather than stopped at the first weak one. + if (!(score >= score_threshold_)) + continue; + DetectionBox box; + // Undo the letterbox: remove the padding, then the scale. + const float left = (box_values[index * 4U + 0U] - letterbox.pad_x) / letterbox.scale; + const float top = (box_values[index * 4U + 1U] - letterbox.pad_y) / letterbox.scale; + const float right = (box_values[index * 4U + 2U] - letterbox.pad_x) / letterbox.scale; + const float bottom = (box_values[index * 4U + 3U] - letterbox.pad_y) / letterbox.scale; + // Upstream runs NMS on unbounded corners and reports coordinates / r. + // Clipping before suppression changes the IoU of border detections. + if (!std::isfinite(score) || !std::isfinite(left) || !std::isfinite(top) || + !std::isfinite(right) || !std::isfinite(bottom) || right < left || bottom < top || + class_values[index] < 0 || class_values[index] >= 80) + throw std::runtime_error("YOLOX engine returned an invalid detection"); + box.x_min = left; + box.y_min = top; + box.x_max = right; + box.y_max = bottom; + box.score = score; + box.class_id = class_values[index]; + candidates.push_back(box); + } + + ObjectDetectionResult result; + result.image_height = height; + result.image_width = width; + result.boxes = suppress_yolox_boxes(std::move(candidates), iou_threshold_, + static_cast(max_detections_)); + return result; +} + +} // namespace trtmc diff --git a/families/yolox/runtime/pipeline.h b/families/yolox/runtime/pipeline.h new file mode 100644 index 0000000000..ca56636353 --- /dev/null +++ b/families/yolox/runtime/pipeline.h @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include "families/yolox/runtime/image_preprocess_seam.h" +#include "trtmc/runtime/trt_module.h" +#include "trtmc/task.h" + +#include +#include +#include +#include + +namespace trtmc { + +// Greedy non-maximum suppression, per class, ordered by score. Exposed so the +// suppression can be tested without standing up an engine. +std::vector suppress_yolox_boxes(std::vector boxes, float iou_threshold, + std::size_t max_detections); + +class YoloxObjectDetectionPipeline final : public IObjectDetection { + public: + YoloxObjectDetectionPipeline(std::unique_ptr model, + YoloxPreprocessConfig preprocess_config, float score_threshold, + float iou_threshold, std::int32_t max_detections); + + ObjectDetectionResult detect(const float* pixels, int32_t height, int32_t width) override; + + private: + std::unique_ptr model_; + YoloxPreprocessConfig preprocess_config_; + float score_threshold_; + float iou_threshold_; + std::int32_t max_detections_; +}; + +} // namespace trtmc diff --git a/families/yolox/runtime/plugin.cpp b/families/yolox/runtime/plugin.cpp new file mode 100644 index 0000000000..d9674c0236 --- /dev/null +++ b/families/yolox/runtime/plugin.cpp @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "families/yolox/runtime/pipeline.h" +#include "trtmc/runtime/family_factory.h" +#include "trtmc/runtime/trt_backend.h" + +#include +#include +#include +#include + +namespace trtmc::yolox { +namespace { + +constexpr float kScoreThreshold = 0.25F; +constexpr float kIouThreshold = 0.45F; +constexpr std::int32_t kMaxDetections = 8400; + +std::vector require_section(const BundleReader& bundle, const char* name) { + const auto* section = bundle.find_section(name); + if (section == nullptr || section->length == 0) + throw std::runtime_error("YOLOX bundle section is missing or empty: " + std::string(name)); + return bundle.read_section(name); +} + +YoloxPreprocessConfig parse_config(const std::vector& data) { + const auto json = nlohmann::json::parse(data.begin(), data.end()); + YoloxPreprocessConfig config; + config.input_image_h = json.at("input_image_h").get(); + config.input_image_w = json.at("input_image_w").get(); + config.pad_value = json.at("pad_value").get(); + const auto score = json.at("score_threshold").get(); + const auto iou = json.at("iou_threshold").get(); + const auto maximum = json.at("max_detections").get(); + if (config.input_image_h != 640 || config.input_image_w != 640 || config.pad_value != 114.0F || + json.at("num_classes").get() != 80 || maximum != kMaxDetections || + score != kScoreThreshold || iou != kIouThreshold) + throw std::runtime_error("YOLOX-s runtime.json does not match its contract"); + return config; +} + +std::unique_ptr load_engine(IBackend& backend, const std::vector& plan) { + ModuleCreateOptions options{}; + auto engine = backend.create_module(plan.data(), plan.size(), options); + if (!engine || !engine->ok()) + throw std::runtime_error("YOLOX engine failed to load"); + return engine; +} + +} // namespace +} // namespace trtmc::yolox + +extern "C" trtmc::ITask* trtmc_create_family(const trtmc::FamilyContext& context) { + if (context.kv_cache_size_bytes != 0) + throw std::invalid_argument("yolox does not support --kv-cache-size"); + const auto config_data = trtmc::yolox::require_section(context.reader, "runtime.json"); + const auto plan = trtmc::yolox::require_section(context.reader, "engine.plan"); + auto config = trtmc::yolox::parse_config(config_data); + auto engine = trtmc::yolox::load_engine(context.backend, plan); + return new trtmc::YoloxObjectDetectionPipeline( + std::move(engine), std::move(config), trtmc::yolox::kScoreThreshold, + trtmc::yolox::kIouThreshold, trtmc::yolox::kMaxDetections); +} diff --git a/families/yolox/support.py b/families/yolox/support.py new file mode 100644 index 0000000000..3f2a3a89b5 --- /dev/null +++ b/families/yolox/support.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Exact local checkpoint identity and public task owned by YOLOX.""" + +from tensorrt_model_connect.model_support import family_support + + +describe = family_support( + required_files=("yolox_s.pth",), + tasks=("object_detection",), + default_task="object_detection", +) diff --git a/families/yolox/tests/__init__.py b/families/yolox/tests/__init__.py new file mode 100644 index 0000000000..52a7a9daf0 --- /dev/null +++ b/families/yolox/tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/families/yolox/tests/cpp/test_image_preprocess_seam.cpp b/families/yolox/tests/cpp/test_image_preprocess_seam.cpp new file mode 100644 index 0000000000..9ed6b6e8cc --- /dev/null +++ b/families/yolox/tests/cpp/test_image_preprocess_seam.cpp @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "families/yolox/runtime/image_preprocess_seam.h" +#include "families/yolox/runtime/pipeline.h" + +#include +#include +#include +#include +#include +#include + +namespace { +void require(bool condition, const char* message) { + if (!condition) + throw std::runtime_error(message); +} +} // namespace + +int main(int argc, char** argv) { + try { + trtmc::YoloxPreprocessConfig config; + trtmc::YoloxLetterbox letterbox; + if (argc == 5) { + // Family test seam: raw interleaved RGB float32 in, BGR CHW out. + const int height = std::stoi(argv[2]); + const int width = std::stoi(argv[3]); + require(height > 0 && width > 0, "invalid image dimensions"); + std::vector pixels(static_cast(height) * width * 3U); + std::ifstream input(argv[1], std::ios::binary); + input.read(reinterpret_cast(pixels.data()), pixels.size() * sizeof(float)); + require(static_cast(input), "could not read input pixels"); + const auto values = + trtmc::preprocess_yolox_image(pixels.data(), height, width, config, letterbox); + std::ofstream output(argv[4], std::ios::binary); + output.write(reinterpret_cast(values.data()), + values.size() * sizeof(float)); + require(static_cast(output), "could not write preprocessed pixels"); + return 0; + } + require(argc == 1, "expected no arguments or input height width output"); + config.input_image_h = config.input_image_w = 2; + const float red_blue[] = {1, 0, 0, 0, 0, 1}; + const auto values = trtmc::preprocess_yolox_image(red_blue, 1, 2, config, letterbox); + const std::vector expected = {0, 255, 114, 114, 0, 0, 114, 114, 255, 0, 114, 114}; + require(values == expected, "BGR bytes, top-left placement or bottom padding is wrong"); + require(letterbox.scale == 1 && letterbox.pad_x == 0 && letterbox.pad_y == 0, + "YOLOX must not center its letterbox"); + config.input_image_h = config.input_image_w = 3; + const float corners[] = {0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}; + const auto sampled = trtmc::preprocess_yolox_image(corners, 2, 2, config, letterbox); + const std::vector expected_samples = {0, 0, 0, 0, 64, 128, 0, 128, 255, + 0, 0, 0, 128, 64, 0, 255, 128, 0, + 0, 128, 255, 0, 64, 128, 0, 0, 0}; + require(sampled == expected_samples, + "bilinear channel selection, coordinates or byte rounding is wrong"); + config.input_image_h = config.input_image_w = 4; + const std::vector constant(2U * 3U * 3U, 1.0F); + const auto resized = + trtmc::preprocess_yolox_image(constant.data(), 2, 3, config, letterbox); + require(std::fabs(letterbox.scale - 4.0F / 3.0F) < 1e-6F, "resize ratio is wrong"); + for (std::size_t channel = 0; channel < 3; ++channel) { + for (std::size_t index = 0; index < 16; ++index) { + require(resized[channel * 16 + index] == (index < 8 ? 255.0F : 114.0F), + "resized dimensions must truncate, with padding at the bottom"); + } + } + bool rejected = false; + try { + trtmc::preprocess_yolox_image(nullptr, 1, 2, config, letterbox); + } catch (const std::invalid_argument&) { + rejected = true; + } + require(rejected, "empty input was accepted"); + const std::vector boxes = {{-100, 0, 10, 10, 0.9F, 0}, + {0, 0, 10, 10, 0.8F, 0}, + {0, 0, 10, 10, 0.7F, 1}, + {0, 0, 10, 10, 0.6F, 0}}; + const auto kept = trtmc::suppress_yolox_boxes(boxes, 0.45F, 8400); + require(kept.size() == 3, "NMS must preserve unbounded boxes and distinct classes"); + require(kept[0].x_min == -100 && kept[1].score == 0.8F && kept[2].class_id == 1, + "NMS output ordering or coordinates are wrong"); + require(trtmc::suppress_yolox_boxes(boxes, 0.45F, 0).empty(), "zero cap is not empty"); + std::cout << "YOLOX preprocessing and NMS checks passed\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/families/yolox/tests/data/test_img.jpeg b/families/yolox/tests/data/test_img.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..095c03620f4efba781e30520da7f1462b08a6a30 GIT binary patch literal 55258 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%0D}Z7D2?o!Fjben*vg zT{F7vX!Vy9xqkjj-F#A65Jenw#9c&SU}+q%G5-Kc);NRl3;yzJqXKtQcVF z+tZ4t$rUflM0jJtCGvOID{UeH3rbOrj*38C$@BLV+2fzHTwB@AAp#~RB;nkWHXfn5 z@))Xni-T_?%(RwK#{}xH6_IcxT&^2^yeEEVomBCxi+vpK zI}O6;n}y!o%M6ImBxV?x4K5grc)XY%esxcE6!VCZ>;0Gr7z~USBT-R<(*vKiIX8O* zsuW8kkq^8w0>zu25BJi6@Vr`9Epol=c+;07JNajN-6hzcqt?7O72(R-+8*-cm6U+m zf}rr=a{NM^merEpSU6-@?iibi8AAZO;FdhM&*&+h7hwgU45WsTr2HV)4Vvbv(){S)@czd?7B$d_PvM!yNZb>~n zu}8H@RW2S$h*KK?p*034Z6gQJ9Mx>|!nc9=dP=OXh`^Ax2VeWu?AMSaG9|oi6oCN_ z21>V6w=MI(l?~9A$9Qa(Ts9aCx>Do4R*}dD00K_uk=P7+(>;)eZXmaF=uL4z65KqQ zIMfRfw%~8KuGOj7m@cPyuVi5ainwLiW1hcX>5*GEXD|K~-ATjHMzAE&M3NA}S4N)@ zJjU&l=4vGxOtkHuy5dmrXO-l)7TwNFGNR>-Dxj{iciUm9d|vxdC%xj9QA1;M43{y= zs`nC^Br$K|K%h1N+<3X1@8wn;YA)@-jx`p{k(ms5z;ZGJY2~>%&fAQ1q9pRpO`DS> zISd3*l45Nf@-erBXMAm*bt$AaR6+3%Z{Od=E9;vGt*gNJI2w-N<2>>zc0WmdwIXRAWv6ARiV;1Rq*R6LE2Wb8Z@C zh8U(pojOAJa&UIpzGPK)$>|lB=1Zm*+WV_{CY4q&`kpYjE2|@@IP<{vrHcmAT@=W~ zO3H^hVb5dc2cMN$aZWE`#BF1e=G)*^SfG1F%Bk?55;5O7Cm@16jcdf9m%}cx8)QfU z0mq&W;;k(T#crb2a?j!JrEEC^NK79uTDQ=v8#p>6Byw-zNX+oNvms}aMs4%B&%f%E4p=viM0Sn5i@|GC9;$_Q!`RLFvzG7O3?o-0$o_ z^UYP*hGe%L;Shp|G0(59W4dxm=~nB5(w!5NZ{r%5@`9s3Bg_g8xK|lMtC6G!&i;Q& zRspPHW{?P@X&mfh2LZjkeGkf~D`-+5TtsZa@~79^%7UsN#E+EEhr_WxLW?1mRwdYE zpTubZSYc>E!pI38ElbPg*0yfIaC5}_d%!qe1B;vZ;bjLoFR%oB>r;*7iL!LM4o$y{ zdfYzE@1)_qy6o=|Sv1LMeI3MF;~6a&EHm>IF2wpbPqp8$PSAaq_AjyhxNiJi-J-u3 zPt9X=>|QaPa={8o{NwzPnk9gd>P4@%YeZ)xunv=>|-v{tZMw2sfiCyGX8x`-3u zAc2WM0F?yf9F9x|(5|jBq#9!Y@PkXdHDgd{!4wfiQzj}Tbf=ylDg#7%&>>wr?X`F_ zFC+D)JEBei$@HbJU2UUzCd#S;P8TQSI?xBZ!_0td9q{Y5R&X23&LzVmyy6h8v)nUD z6gVNHh@S$se?FWO%LL=4SYs4)1dC^pp|Pwo=X_Ago`lc_Nd}0JFTjg2p{p55Gkm&1yVTjy=gLPKd`O9KL>at8ot6aQS3r7sBd8&$(8ZpZNi* zJFe2pC_f9sc#Pgfpb-zNvi|@Iw1C)eIEWeEwSFbVFb+*?J*Vw$=LF$5oKJ{b+E`jz z#$Ddx?6LG1ML8tw25aTMEB+-rSH#?RUTU}{*m{Kr9=NV|#3vJ6egPaq03z~01_F;j03Rx%>^B?Z+-B2=aW2zc{8Kp) zTaN8eMg|&9AOX#^0)NOyofEWNMDVT;d3k_c6!&@R=Vc#SB`QTFvfo+>JH2b(Y1OAA zIP=GqPc_ay&aG~4CA#95)5$!pG>4ONSnfx*G4-war!j)gxBzE4rE0cS72IyVxSn*DLhO8FCqDlG%DLO* z?9!rrDl*?BF8IWBA1l;G2H%}e5!htr`IO&TaNYpPvu{kt$UB&Ftst0 zs#)7D?VO+1l>#WPE)CVxlQ_Y4&OC=t(wc~EpKSWod|;D|Rg7Z*XJb|%`8nJQk}%lw zsCjh<&Zejd(n%htl>ihtBOM0&RI`(|eE}35BWxUwRWNyPze7L=RtKLtka48=NGEf> z1mSk(YGC+rlY#k8)B)3Oc$L2l;WOItIc_ducUMT^3EpZ_Tf91lpi{mGY+|Y=zmJIV zCX)7Bn9|?|ndKUH#zO7sfB^EOW_a(Qg8J)RW)#rKzljcZaM{PWBXL=~<1;VAStyHD z#49GVBAqF^jN_Lq`R~fSlP~m7DENm9f;DDIJKb8EcVflBTxnGuGrnkdJYE}|+VI;m z0!m3qnq7E3Fb>1io&9U3E%rSz(PsQMSB7gtY$9DPaWiXR-fMx3ZaiN~A%bLBrMkI! zP(GlH{{VO~$m5XZ%dTp+SYeXN$jKqNpSv;5HgFYHh60Xf0OG6??cDcLJbD#7y@1H& z`MRB{GSxoAaY-P)njGBE9Am^)msVS$+~*#dH4Bap8IC#au4KD8%PU4;QIY=us7cS3 z6lRdWp4tL{7BY>78zhW+`_y5*dtwGm3QiGNa~^>I02)a;6spPa>12Zb-06baT(OM@ z;LZ+qIr70LKD$=W!>#yb=M#<&CbF!{oFI=q+i#!DGJ_`|WMGqwRaCrPZxl~>UR!}8 zLnxO?Rpq~2jBIERdQI@Uid`~?4$4QK*!3SuPQ<7m8tl^cZf26)4kc>MrZ%&D zl-!RH#?7$+oQ<~MYHx*G@T=%<1^i|+5(BW*cL!Ig!N(wZ3Xd7OFv(|sWe%%9R@mVIq#jRrrHxpGCWU*#}us{v`!(>;qMBM z4tC%5)wV$uW5eQiwN|r>J~V_mJ24q%$>*oS4*d7urB%%DBF1z{t zJk2wC#G2N|+9(|&k%)HEFww}l#(tfA`cvIdBHxLikBToF;i1eA9wl{FZ-HavPtR&pJsaxRxvE_^k3=Lct7>$>JpKj1!U=5->+Eo(oG`6a5gF-CRW+lHkn9 za>cXW5$o%W=Wcx|Ym2GuETu@;6_;sKR?Y$a@1N;S!n4f;*8C-f!yP$QBfL*FZ!F{t zjkf2$C>&1O8+%l=)L*+sX^35kIBkzn$aLIwrn?8xUlLDsVRV2+6jDnTf;I$z*z?~6 zgG-Ba3b4u(c@%G371(w*fF3}ljno%X=jJN0!Yt=P8&OgtxVa8Ba$xf z$S4CGfi%1%{uTkZIoqG5J$n$ew@&RrbAofr2m2LTCRm6n01e0!u@y)`C9_m*g800S zD-gzGW8H(X=~EKw!7O}$1ImiglH*285vzWle>z}dDGI6kwQXH`^P+|^WzHK?u*Nq3 z0KG;4ADWzjo}2m832fm>InqcfNZ%c4H$VVu15Q+GIP@O0Z3H%UDh@g0Z*MK=o!j6L zLNPg94qj9%iCrZvF$_u4MoywgsjwW93E_km(!q{Y5xD7!2-l-JaI469RuX(}8&D^c zXEi+Jz6%X~M_)=30hn4QV4#eXj1HeVu|3{|3xS|=&-c9}95MofN+4|GJi*6bl^tP| zE0D^>>Qk`Cm;LDgWQASzETPC7C>w+Q{{R};yBLWtYj{G2I$rE9bN~m0j8?f7qZb4K znzBh&0RI3Em9us&B3{>UJq?F9kq$?F@xRuJ8|eK9{{RfVt39Cm9_*C25(y@T#Vsdb zbBSU5v-JuO&bqe{;nvq$R`T9Em_up_B8D0EHSrJl82d$zNO-?uFPJUFU;ZOzN!`;5 zk}u1^erCSox3~_PB<1H?`7##2;TD`u)orii6GRAPIU$BO3>a=0d5y<9sFMBoZ2tfc z#tg3qC@`J#1>QU3t&WAj=iw`w?S^NVSwHdlw0z1o;u`v%#+;U=`%xKXy4%fhav zv6%TWWj=>+->DvyNqFtyw>I6R;c)SbKg`P*ljcAf{{ZM7^vOCl#;{NxmCfuEhD^Hq zA=*2>2*dvX)5_xF*dHRUNMB&f?N*15_OpYiZYRMl`0cO%0Jz~ZVaW6DpZPJMX5R1u zHmz5La67w>Cv(TR{CYTq%3!s(EF4+e1F`}(F60JsI^jXW@9Ph>4$a-nEv~N{gOW9B|Ry5 zEw(rm3W}{224Af{_T+LD28$fQph8TPHe7QQ+ehg_WZr-jWvDO|(?>vc=|BNACM*ub zQID+*fM^32)2vemnh`J@l(s7Hc8Ob^u^!L=07}=G`B5QK=V6)v*q4h5KMXkcY8BrY zOE`Pml94Y?q@O|7weBIuZTMQ44CurQG@Q1IP}7nnG;5 z!O*MtH!9C>GI zsJgvW1(|}V86=zxQ0BQC?_W9p0EiE@V`U7TmEaTN?q$XF$ajh}`2qdr>*Oh|hh~lT z*Y>D8X~$QEaOp|x{e;+J2?FK8@j1uk8-ewzemBIpe`)(meaCno2#baA;T$m8Si~@) z)-*$b{$~Zh;;K6 z9rU1eCuJW@pPh8c*eG|Af<~Z{$Djiw*DK*Rb9RHZhVJE9K+>6tleUG%k^cbOS!JEd zi-c)x-upSj8-(y{OH-clP&9;}&UZi4;zngwT@{guZ%vQ5^FuI+b{vJ#1MUrh0=?4t|0E^m| zUS2||M3ynOfDSjFhc0z6#3;@y1CY)@=|n<7B#>7W z0fi@VosTh7BX9xIG@!4D3n}K<)UNs;9GroUXR!RH9p;wkMlgk}FD(?Ca zLo%$cBXI!pKn%a31280QpFiRBquasXLM4@gfXbkavy9|{&Z~|$T!ts7O!}HM3p+b9 zF$|j_7{=qJ6g1CVvdH9QD+V|RkoWVTNy8*|?+ojY#g}oP|^{fhI-4)sf_Jj=AOyF6Q3R5in6x{{S%V3uAl^ zcpiTAJD&PgNk@sv1*B218{iT*!N%P=(OVtpZtCVcd8CdqLcWcQ>uAQEz&&}9pDMqx z?C%xg-wS(mBDS^)rXw@p%aVLrMtT8^`}^tFY-MEo#asnR%BBe!+d0Qit}|P$&u*k( z@L4FdQc5NPB@>WPbBtrsvzltCai`cGA;IS1w-GqJQG=37l_Sgz_dc~n63ra)mIhP+ zE~UuV)O_~+GmWX4q%wmg+`uOIVn}B8&re=di9R^YO3IleIc@6>7;>p5Oq8uCWkfQ+&zZSmLTMF2;(3KwDGIT+lLS%Xuy z!Nxx}Hx*+AlgSWptOw^LU}LAXVoO0HW;qEt-+UTYIxxNrX$Mv_o`cSdH04G}4JM*N zUQ~Nae+XGqIz~x5w#TLw?SYc z5{s!NiJakb!&2Z9*}(vjU!>iG?avGC*JrHw-vX7I+J6JZAjFpPkH-{?`CU%Ldl9j% z>fYKo^3dtf>|6!qzWJ^n_K(;eJJ@c}-Tk5cliK`a+>rgE?C?5Mh^p`3L_aLxXK*}T zxghqnXEE9%xbW#aiXz^`F&wN9mVdqLpZ%Wwqwp`ZUIx5VvsY$l)P%UVkZGFMPO-|L zmA^yG6INV8>oQ;-wJCwH-SAj$!FxFZH#5AAzgV#us4i&_PSyneZn0S9)RZqk_8ti+^IfL!8w#VyCMyGSy+l!dK8@0Rw z--^N1h{U^j>@^RPeCn##hju#dQr(#298a=$yv%m9vmidPoRRYao8n!Y;`W?w-ttS! zh-{;Q;+Dk5rYV@5teTG|9JVJTYC`rDxZ+lp_m;8G1d&UXmLT{mrvzke%<`pv#L<0k zC)ytlQzr!C{2R6wtNB|IOOH~g8LXeSTfPk4-)y@n#W-0T8DmcNKd#@-wYj@DY8**p zJ>86hlSMIbK2NBRkrYMZy|Lkdt&bVE;&Anb^$5uF@oWD8?iExmqkiF^YyFMlO|LHktTgFH)s+wmxuo57L~%?pY`v$#tU6s)U?;p6;9-rBKFG1+sa)y!sNn?pVOpOgDn7uz?H#3>7QMTM*#|Q$s(^h>X?$Ppm$6(ZpNs7!iDAtl zkj&pi2EJc=KEO7oyqAbyI3JGU`I{%@8LCb*H)aul;8rD5ADUSkC+3SuO<2-9S8YGU zwS!tsYuKx6iSC>gTf3P1sN?ktd`y0o^QFY{T~95=)Qxd-GnbM^IV{JSGt-gPux&e_GK_F)2ls-Jamr5wFKwrV&O_3EU2OBi6dVVD0qqo1P^g@!w2J z{{ZmQD!;Cy^Q{tKW9;kRhMj|h-G*!e+v8!(lU-uk)p%bDvEk}5@bIKZ)T)4gT-Gtk zZO{E8+Ug)U%oAVm+lzZ?7CB{20h>4jBRst`N$^ey7X(=em98g~0o;rL2S7RsvMxGg zeCTfZIXl)3nh?F85wII-*jF9xzZDmJS>aaut2NQ?{M-2tm2{pv#FvNJT-`~^2+INH z-3Qkd!FY|+7}=e*ODht`%#)M)(_3O?Me7+@%6XH$PS9zGC-WTrD02S*xITm$VN*nE zI)RLO*2EGgQ1z-Y5(~^4Bf$|gqLBMBsQ_D@ z029y4$kq<;I}A`-)Ao#g?6*DG3_w%^EOXESk4^DfDJ9vP z?4yq3QB{&WA^bW=FY83rD#pSBLyfW*C9~J{r&qXxOsK);jE5n)0i0lYbNwnYswLV0gN|oyhZZ*BpT5ODcwZ$tIzb zkPdOR4hUNFyp@8Gd|n)2W03ZscSG+Si#S`jBcAP7#ommaL<6@Wx1K1x9u%3RStk;S zwII$;l0hqdVz?SFNUad}{!3z=4yGl`(J&N?0yK#~B>JJ^}p?&Vy{TgNs~7&8Vs` z;vfULtrnFKWZgWH8E)fL5pzYzBhY))n3){SA;>4&KhvEGTgas%F&>HaVV)zU53V_9 z+iJ7niuS-x!%mWWoo70qp+1B7;)|tVwk$0qSwVOmf`O}8)2BXp@~7cMlS#rV9mW_C zeKU$$?lm$@$*M>E+Mhf5pE?|F?>(!@{^~b5)Z2a?xo<~cF>Kl2C zQt?a`g2|J&JnjDgdd0*t7>c%{#AS+%g*cnKAgy}J+Yi4MH;Xi$>uTX@~8ipso|T z!>c_ zorVUS@6!W)vsEzn(W1{Z#^7K+hiuBg?!GO+8*Vw^Z;Hv=MQIx!1i7}JDQ#krUQj@2 z(S}C*u1Cn^^3%?q;yg8OI4#<A#g`)4N3}D%tu6_5s^f?cdo)XLyUq9`Xxi zv%KGNG(agQ{Xi>!#a%bvzdU}xKG?24$+%w?nA?8U;j%V~ZA9JK?-%g8whlHs?YPI4 zPq%;K_qM&E?;_#1R^5}~?UAO>#W^0bu+RLewd(e9pGAGCeUN-FYNPF!*-sv^?1yb< z@Z8$mNU3YZmpiPHle>C>zoGF~r#IWZpJ#hbKWBc^>@GVc#N&N2-Ax;tJAwP@AsJOa z<{19~HGH@BclOW0J1^V2UeI>B%ZYZ0V{&DYon(=-*D{>9u_C^_wC%TGKHB(Pw!N9U z;k{Zh?r{Nr0Ui*j4e1zC4KlzFe`Kn{>%e38sxQfT^ud%m$ zBVN(*ZW_Jyti$(!$pn9)+uD^epGGa?A<8*J<_$Y-+AF(KF!@o?ZT|pI(z(xN`+wQ) z+1q?aW%$LX6I^A|DL(N()fvMt%nH93Lm!JYtPX{3+Zdg6-J+0rdi45yHBvH6ebTAvo|Zx)IB z!){~%bB%TWs&Xqf-JrU)Y{w@3NIFURj8)CAZ>}Q1pU2b2p}$ds`Wk9Nqf7$g4-LW; zk#^>D`tL~b2rYPm>EbrfU5_c^Suy_rFgUKRov-11MRZ@~Dfu9b8Yeu)sgesGt4MPQ^_5n&YwjCC*+ctxQj0_YMO!hq7nMlHU9u? zKF)Xm)5H5W4cx2AxQ*Cv{)I9h{*gtY(aifH**I?};ygz2ac#nQea+*j>Q*KQ50+V% z=~^xFEHk~lV@z?Xp-_3@KtHZ)sP?z*n&XY|*|<%my}hprmuW35t%bZ&0C_VKT#qmU zXO(h^WNBo1Bsq6t%zaMvE!fhdjrPOrzH3I3^RtTA7RSWa*~`cNpWYsv$E|a91_>Kl zWU0d|gY@$U^{=U!s)vkrSA%i6JKiN@9Fu?Qp(y9vj(<7^eDGybaw8xg{pNp_O=eX) zz)9(u2>un@580PzPvN$@9@kA?{{Y#S1Lm+^{{a60TTw!@hs`5S`k6xX0Y(7!tS-2m zGGrc|Yq}4z{swv5-2Cp|r~d%69?#>&Ya#c!kpBQW0O7LSG>MMl6y@SoApZawUsHql zkF{=HyzGkhV)7|0wVMl<fs-IOe(Qw&UmJT7C$8oP4&VGrDn}&n=%}{>$Pw4DZIS z4AMkK0;0?P0j;L;Q!LuPe_GZ!ryh%qac{Ez9b#Qmf^a8kLkwbH8OZeU4eI(=Eh91K zSgk}OeoXJ{Lt+pXZj}jc77z@K7d*Vr%dI0Uvd`xl`t$m9`O-8rue5F^Qr_ChPl{$T z$Un+Cey8-VPkSeMJ_jYOm$iIQU-6sCkme}Qbs6&uwtiq)skd%!TQvc9gk0X!i*0($OAaRJJ1GH=hJ-D_G6JzO)5@7 zBYaed1e_hZU{D4@$vrXYQW@9`a<38L$UBeWSxjJV0QR5{F66FoJo8foj05tl5Dq`Q z)YM^cPC6X_04e~G7-5Y&jN>)nF^qH1I`Kld1mv2M{w;aI9Pzej17noi{{Z=nDIxRS zu>{DYnZoC9bJne(M7tL}LgFKET^$%H8CGJd2V9(IuS(V;(GrbHUENC+$Xt)?Rpqtn zi@$+_U$`StaDqKV0@?ulywA{A6r#=36IkG$DP+93ju8}{(bRh9@Z&kh^EjpD;+Epx zM2B2qbEQ{G0;dC><$%ZiYNLxJQ{76|0lUAg^z(LH|% zNA8wFS&10|um;?Y+tR8SAjKpS!x>2i;pA;YpQbTWwzZn-?Zu)NS7U`#2LNt=jycmj zD}{d7xOY;v@Tey!nF@L@u5r$+Z6OI9OC&|&R%d-}pfLvk!^yP)QBP6CL!;Dwl;A1w=o+=6r^IO&f{NZ^Zbgc`eAdNp29Y zbsl@YfFvG3?bp|CRKFgYHJ*4Zi--|`5=kLv(Ts192_%D@)^^^FDJ`<^i(T90?L?09 zNW?(J0)f;FZWnBM`_{7@gT9t&T})8m!pt|8BZ+r_pC(?wbJ-z_+`{^jxuxRD`l?C-o+L|(8Nn)O6V9Lh>iCj zIt+5j1;i6X3}!ajPEP*-(wcbft>jSiyT4Dl#(i>4V7!u%-GT-~Y9n%b{LNL899Tw@ zxf1Gtl2ifWW%=^wf@uCfh}9ro?EngZWl?;yC19@{OQQ9 zqO}bjwX#m?sukwS_>On|J+o5TLhUMdgrs|%v!e`bH^SrFm!&}!?&w<0Z89`O1#-v6rzh~^e3D4tU}=afyUUSlb#j77Rnsh_ z;x{8;{Xjg4>CeusxHl7i9ceYS$THZ@OFT?MuyApyT;!{C@;yPU+wxLg3d>O`Mb#S| zV@6mvh@MO9wsGlK0&m&38Pp&wnRSvp_h+sbr&?lLD6K7^xVD{5lWB}kqepgGd}v23 z^1;ajXRjq@jw_3SX>9V{L?jAdyZmjaoM3FA=K$2FVZO)8*-HvXa>$J#Izfjo$Pt0( z$YA66(@DXni&8zlnL>~YA;8Zgw#9jG)Q+6$%{XK+yxN3sBFB;l?zN#9AdHX?n9g?c zreuauJ--u@^~Al%S2puAn2;zK$z!$v7z580pCd{ro3m1>hT?07y0&FH&;Xz~15#w; zagmReHE+X&J=#rk6mm`og;YPsZ z2G7qN>b6L}EXNawrIIYXO#5fTd^?hHw=s{c9G{U=T@ZOqlrcAOjP95Xt4^iQuaM7L z$%+Yej9&;O#^=NitqYJxL$D;$Phw{_~)h2394VJx<=yreXIvb@tE@;XTM=~`Xq z67JZuv65d?pQ)~a+fE62dlFA!-d$YFAKuK#hIc?Wz}t1n2h-ZQU9=%?bX~EGX9bAP zljeNIdQjX`Ipx_CeL0yVF43Ij2-sEPamj5Zf?I;|Tp`sJ#0{a)^k7Eg+*aMjyE6O} zvivEye-n?2L&f3vtF9d-ww~ytx+_NcI>s~OjN^PBSQ-Zzg44aTkAZI z_`mFzx!P_K#bZMR#Pl(~VSknnwR{}QHM`-1B#F!sf<9EtTu9?g>yhV*C=a$fLH2Rj zeld2Jjl*xd55@u1oJ+H=S`d8AvoK{J{5SbgxP8ak{{UrUc{@AAcwgGr1%PFe2R|Je z{{WgY7GQnV$E|#=+0V4D&~|ZFE*r$;lGT6xEyv!)@>9#?MRZ;f_R(Q^EKkO)<>9wz za`b?9d)U6vasL3bE-jpaGqvrTAO0*@l}Gx~sZd$4BV;7B2`s1f~ANZE+t@Pc;Xdu7rT4;)CW}4H(jf8-arEU{p9k$%3+)5~eh0*@IFv+wD{0nDdw_C2bn(3%4)g%; z6ymo_8x=7hco6>pD$=g_)$XPVaAE%d;YO%_ssXKn;&Cbwo|UHYP-Aw%+NlUa_q@Jh zZtozFjvVY8I3S;b=KQ2C0A~Q%e z)Tcjs=l&`YxR;rczO<|;up)*Y0f)3aCgQ?#ptubj znEVboH}}w*j-jR-JYB zy$B>&@TnWgrkF420sb^w8L)O+v3I-^i8b}#5VyUT;mitwTm=M!_>cN=j8@Fi%^^MH zx(0bfG5VeV0EKJ)gza?wse=AXi%9K_yP|^|k{ckNy>|U;Wz-EsnMeac93YfjG3LbO zkIe5>y`$m-g>Y-jkZ|iBl>Y!zY(I{ERe3q^k9y|6X`Dw1Wy93|B4&_!4VU@UrEH^` z-N}Rs4(FXHnZU=DPT}=RakyH-4Tk+IWuq|BlfHbYtC9d7f29sD4(ES*AwHimObqfQ zkF8HR&z~+;kHdg`_pIlx0Qvs30paWXDno7d!0BE)X9lyD01RMf=|CCAtN=mP(2AB$ zFbM!}jOL-!o=E6+sqWdq&nf_tlB!00Cw(=YIMcpWzrI!CG?9b3+=2WmN(*d*#7EYE zGm@yI!>2t(U}HOH6_sE!t98!6?^$QdWq>&H7@!7BLjzkkix_rr03Aj#PscFf^1QM~ zZybsX#IAK{Ab}w&Nw(pI__gT=iyvuUAz4 z`l<-!A=MP}&q3@#`cQ;r0bP{yIbHB+wY-Rld$17@9`9JsJaYd0s8a`N6`hHVw?oyj z(;W7vZ2$&@l_`c$Fhdm}pYP6#7+9tzWLcI>fWT)M9V#=-E+@>=NWf4*?P`CLQWB z5?=>UI2}ik!NH)q#I@PBI;l}dEh;aRuXI)S@<|JLf^$ZD9 z(;(6?PKeMSpY#bRgR&P=BV?fCyvY0D*#hyz1Hu8=xa-pktB>f(Ij=$TZ7Y#~Lgy zPsB(IcvCnZyU#i6>_utGB50*cb1a%8W>sDCd4cDOWW^+LhmagAjRW&!pH645%AU7; z&h{3t9a1)ubAsh^b|m@Z=}tw%?bxn_W{H9oIlwG3Fg(fUlj&0AJ|!C4htjJX#Lmx; z5d@vQKKti-v$WwVd+6RZe6UbU4xI1~-(2H6RlF7kF*^_<{LZX{o;mDtas6n^VR9V2 z3S(lTL5PUR{qQ<_A1+l6W$|Tg?Yy$yvOI|$yKGw_K>%!hNXIWKi$y4aLnMhJNOUWs zs^NeGQ2zJLT#F)_O!ItXicqX1F;-FuCs4uIFFKrj9d14+x@h=q$^cMc4JtM*hS)LA zaxi~d&z7ik%I+g=J3C%oY1eS$Tm-U(Il~<9;bK2hDN8#jZWN>I3#=ry5&u(Os*0xR&Jdj3=w#xD7bYjRdszJ%Y+kBpWwMjU{ zOS2ZX791+hd93ERx|A<>Ae!8Wob%I>lgx}CJawx+64rK+CB$tS+`A9Gn=DTIuS{o_ z2+y^8OSrBg_&A|^So@VP6HS7I~b-17(ZtV1e65~^CoBl$S9XKKs+#~C0~z`C^`gZC#FNVG@Fb3^%0B|CgQ(WljBT1Fm8HF#$vmpjC{R}|yo7)T2Ip+@z#h9+ zA-ikGkz$BL5@lKffNS4#k~bK^2hTqBIU*65;1d|8a$Cs4K?EG0xFqA3y)g={s_x9) z91sH5tc-U%VJh)NSItP2EX@g1}6w!o80#YCf%Q%W8Cr5b`p ztE>eZ4gP0w)bhxoN*K(s-9ad_7y-MW8zFyl(BrAw9I!Aruc9osHtRFM(U7{Ww3XNp zNIZuk4?VW$QoR2FYlhRl9n?`stg%OxC9nogPQ-z}b4Ue~hg^5AW09aSv+8))LlRGb zZioY_}E;;tCYzc zq3lKP?7lYyC4_>&pmAlEhRPhf=Cl3eh|SmvFH7V{NVcH2GMfE4rDNLKVU z-H$ACe`XSkiO-|HeF~DQ+(ZV-{MXp?6*CMo?{4~7{{ZF6aDGk5{*{ZHJDwSI&|SeL zwZvz{k|9z)MvoQMv*2CZC_ld#$JAEDo=7cLM@zvpGG{8|ksUUpwUnWC+03^w`DcHv zPTg(V8DpANMp+mHlW~mgvHn=4BuJu=7^V=vMn}}Y)PA&poHz2?%o$!wX_V~1l_S`S z+xTzVM;Wt47c#B`%B@mjia`@ONp`T6X=;wrcHe1YugSZ z00LPB%K1n#exrK0wC$7)D$62EYjD4YSeqn!5r!0Li)U~@;tPp(cIV)}(YqVOcqPi5 zF-2tG{St*gMm2RG+XrKNK^X2h)OND4{{VM*jRSp8hxM*|!M@$QS8Hr6{{U~N9^v@M z!EuOE`aF4JL8yF8q}yKE$v?Blk)LJNc%p33^!4AGA=0YXQp$n~Jl_6^%U z8UFzIe%M6dat$o-{{a1k6c%sKAoHO!*EXB6A8VX9J=YP6@kj37#`*a=ib_ZOTCmT3 z!k*v`qTU%Z{{ShXm|4_|dDI!ja#=fL_JL=$-)CQLWbo-fk)_-1JGAg`aoP0)u5G4T zRKm`@%}ZTf!D$OkcQkhJuQ!pxkEyONfA+P0zF%q`qrjNCz&4A5`dm${OQmI&#yX*Hg**b?0FxpO;iv2(*v{pl3VvZn!KFz zp6rbJDuMjXZxyZmpIiJlV>ou$GV7H80K~`n0b3noF3LdWI|0p`4jR1HYk&nVQ^V*~^ zFjG3ZLYzv&(5+USz1Wd#=PYgHY9WN=60+#&vv0= zAyIrMPz0Y-^YW}CtR+|sHiM;s+dV(+T;_xa-Le-aSC%&&D>6Ztm9;8lkQqta=jTkp zXs)iV7gmtjk%$TpukakrERn3Pk>*1hjEpXo1Jime30g=k-#Adf76hCDx7RtRVvI^r z9ZNAeAQ0Pf`uoz$3lPez2A$X@7bDD`bkuPwhHmiqTNxnXkGGa60?!_LQ{*M`9( z@g$j+Y0Xx6c-BvQ5>V!)LOIh^f+HuV)>VQAMn zT#*W+WU8S98TKRl>rYaO;>HFsw@vFAKNelC#G3LM?L_%P z&iVi?f({1x$iUy|HmSiBBVLJ8b#>CKfKEWp#E+3QG`9x=+9nKJ61-rsjRrE<&MIk_xjt7E9|>6r4LM$sk8z7}BRX&VMo6 z`E{)E%h{|SFxxTsw!$+JmWyGe5HW&5-vI5sB-g_BNNu5+46Jm@RY=Dnk%7K>ll&GpcX|B!K|II+eag` z6FMw^c3x0ajYoWKf-pJkN$Is>FmMNmF8=w*290HXXDz!m z$|URDzO?ZdlN^sPKu01v)@i=Sb_Q!;{{Vj+F3e*xw5U|E&oUPo+XJRX4*S*r01n}) zYjF*mv?@$vy!+kGR26MXNMJ!6xp`z#_Ruxt?k(VwJH%FWFn!wS$A<&~wsL&3M*Qd! z@d)nb0{(P@DNerkxm7D5I3vOeuP;%*F0`G!6tr4FW+oFDrtb-JBGF^rSsd1E_J z?5?hFd$}yT#vS0xht^dJ;Oqu7@6L&74XwS4#|)CPTqzosY=(SzS+GtAGEX6=2AOb( zvlh%r8sV~}NaO+mJ#)Cmae`0IIyApVY$X!id@4b2Y+25PiB=p8=>f{`r+#Ct3yW|& z(J-3oH&{fktc@zg5S#;~ej-m@_8wH#q^K?8x3XJUWkS8)a8YtWQcqm(k&sAkn6FKH za|Nr+wzoI!42sj6WdNe%WiG^q!6P208|M^x(6(3n1RCSBYg8BtD>}@ z!6o$R++;HuM~tZ)`g9&!=a$rmW%#ZC01=sEzpxP7GD)uF94>G(zD6*A+->(Dq@HsAmA|{i^K<*-^!gh=vQRg-reM8w~|S96(DLOC43+Z?7#vBIc?Vz zI3c;XE}(*vNF~z>FO0VONzX21ob%h#g6i?XWp6A|L?kjrqr76kjm8^0o`)L@`_l&O zGO&@xtSEL2Sn>+_AWsj118wL2;n0QAL=QB8@V`NKiNHgM*X5 zA_YqX4k;R2d#NrYd8CN81NeL=@QiiXft=%Qhg!nCnj#ul%Mb)%LlrC+kj`*C@CI>= z)U4xJ43RsFnWS77E2k@{eo^5A7z3|AB510SM>8bEOFoq@q>@1L+nzQXlg^cvzd{n) zLo-0P5jz%4g!fH>QhYeD0EgEs8O(6y^)0xOakna6VmW38h=xY5U;m9)@;iAyFg2$RMA6{R!z?}-2 z2IzOEb}F;)S<_+&IjVbv89QXx&;I~s{{U?_U7lEYS83i`?hr7sPxz*`9$)8APGhH) zeFqK2zZ0{D^M}tJ-K6WPDWYvk!_d+vXs4Y>&IumXUmOQ%v)7l(eFV?*+Is* z4-Vtj<{m9+9m<|eMjcO@u-qEtU8egezTv^<;0}0=xZk>L36y^Z`C$52a&@3EKPm=% zme9!)x=xIeA;T*S6+T2(Hm{|-LD?=9+B843?SY#ZS>y1?zwveUIj&LK53;T)!*$ET z7;!s(dQ@qY5A^asFQouz9Qsy24o}XDEN^ckM{uSo6ycS|k`Jgf#|+`q@k{$_YiOQl zmPKCzlYma=+Y|xxHw3o$E(OBvv4b6~VEgGA{xsn@6*&M>6?rJY_MtlfD+k$V=ih2v zNX21kVvW4=YjOTn{{X4|E0IoeaphZo+6NN&?$O*ToX2XS755vr>QB4CR;8H9r?m;Zz{8?ayyom;h&Uw^Ox64isk}I-ciHBLGkaFi9EQ z0a6M9>x^=&uaOxipseqalY)7BPzSOOaxv$co<^>I+f+2*5I4x_Qe2GbJDLEQ&zQ+O zZ=an=3^31x`H`A}V_}>RP4}XeQ;kCa^2Gp51^gp&Hpi7?p4h_HRhI-32pqPr2h5m^ zU~&Pm%78NJc2ZQXbCFpJ3GomP2TJyU22>5n!5p($48wl7#(Dmf0hM&SkRV^YC^Oxr z`*S{CrkOU&ZEX{hH8EyF`(y)+TlCM@dcwu$f7%r?EuxRsUQ!XT1d-o zRb(nlFNKqt>9%kCF!< zG1L0d-oUa;i0#!(AB{H;ubXt(oC?kF4_U4YuW_Nk1iFBsvA+IQSk4q8Lli*jJ|+jn z?rEbdN{y~Sip)VMFf*O@{+?AeTN!o3?yl^kyMo&8J4ml0?&Z`&7hG<@^51X|O!?A+ z%I)unyqV^PG)$LmwBu}NW6S#{q2IKP6S4xRoz}0zsB;@~^WLOyL=73TU|aD>bodbC zrrRIkQRH@5+oy49qlMa-CYnYUZ5hjG7+^skOb(pQO3h@~CG^)TB=8s05g?-m+g=AE zxFDhP_ZCkhT(}h&V0F6Wa5_gH`7OKvs^-AT{1I-2>8zD^Bvd^E&0^8 z+tDt8C8fQoMP3@W4+=NMI~GtzFjO7!zw1Shh|PH<0qk~4E}ty#2{3!@l3 z^S;@^rmtnBJ7~v-S+%39#L`A%&a%jPcs9a}9r4$o>rS2&BY`2fifh@G01>DWL;xAU z=y%3XupW=tL2ld)c$LZpxwKW%LK7r`i5Wx473GW!k(^)}5;g76mS{MP)&k@@!)!cr z=+1Ijr~@2^2Tr-H+cX%1R}J8o8l-}I!4Vemlm#`g(H`S%i6@uk^r=C^LE%t;w9kEY zuQyOLsurxFV&4q!I{fCQ77T$7e_z6UQX)?wl#+h6eu z#*R2Aneeg3U1K9i8Qf=JyA1O+C1{j`_IitJHjQ|MtC?6PsS&>}l?NR_-#G)$p=)8q zZ+LrJ@hiy;7Y(76SRs-X-)*unfw<|~MC^P3j1m zfBdDqEgAw)u+-#af?LxAmT*8j@3YvnLf*pK*Im7#fuN5H*d>7gfw9gHhn_iNi`=N1 zNg-IwkjSbGhaeG_)G$coM)=PD`klM7eT|%lu zzyi!$Ie?MNI3O__BKb`qW&Bv48FK2rVOq*0e*a!5Tfla9O8Ehp}+rFl`3 zJu4%3$1S-7Ry}+@Fm-#?m+bgw5wL59k18^unOJ*gIX_d8UUjzk`!;2c7`y^F)l162 z=NKJ1WOV1a#TR-IfxDV(XP1Fn_iR4+yvhMkUkdmeC>nqR7{DHQsa@HWyP7nXStE22 z#|&YdDyZ38Jjo%q#(Z8wc3iQycZM`peI=)Onnq;Uj4l8LiR-s6SgH;ub#n#1-JcKP zsdDb>?&`r~g@G9gc@oDt=a7BsmvjvlmNpWt^@Z!(+$GDx&k%W4OlseS6>wJ>$EN4f zpFwHg+{tw;##L6yjvo6iFf`y5198`pJLJ|ZdqXc0;Z__j;suN0WbkT|IaW2(p6k5) zSUYD{!vg~*mxkg7nT&V3ip+hVPwNiH2Ekc75nX3{s*NgDx;wg>6cHBZ9ln*Cbl8Kavh z5oaetE7r1vZh?82r0KmWiY3g!uzcSpq)U6}QaOjT|jjSpHH10I6 z+Z<$X{c3VCGZ!C;Wkps-A;zXV@BUDC+Z^`Zp|PVs6WkDpvVf1@2M?sLiM3#z#s&cc zmIqR3F~+b*8m6~!r}1QJkduLrVUlzFMIHny!KGju2V3MGxjwkx>x!HzV~jM=GRW#c zjJBc&L7Z=%zO@aOmc*opG49Z`j2NA|lg#;f0rI8pUSedox6y`#A=FrvIq`q1Ab$f- zT^W!DQmDEBKrlD!zrSB0L$)ZE>6tS#|WO1TZ} zIg{!ty{?QUD10_LXH|k3fxg3samqbxc9iNDimO3V^jn6!ie_(2Dd$va~_y=j|yQee{{V-X-#mud^aB;6l@Uclc7DLYrr1c*k+sS>S70zaKzL41MhC7d zRpUK@?*y_%3dx}1nh+wBJM%-~Ze1~3)6Ve612~j?O9v`|xfxt|ur?(8`3ig=r~Tv-j(pVD zcH%z=b!s4&MDWGo(;?1rf)7KEeqObwc7wC>TE=I#Ux~2Nt=&$0$B#a|@#I0S$YxU0 z(Tt$iMuRsJnptF!=soU|;I48)fuGW#s}08HSnb=$p#rQW;r6nVqrA0ja>lg&qL7z{ z&loI$W=}ZIe@=C|n8D$9Dw^SCojlRAUbtiMV%hDIHb6;mrCjIFea3w%zBwg{$8^mb za`5bF&LMv_<)RteR+EK2dS`k70$aua5lw@RPBFi|OcNLT%oX!fno%4OFleE5o*ye_;>uS0xAh;o-{v1Y){PSw&N-p!fhCIjznCLv{r`i`U2WL2|pr<-`?frq54{cGa*W@uzrhDMG>_uBI zYWUn>1mgFg4>^hcH0V@)XxswfiNS~GDv#$%OTna%C8o9?{Pq5|^0Riok7I5xcmDwU zC{5bFIR5~Pi{799aTKTzs3o>Y$<-`m`ZzSZn{@nc0I!%N08_W2^(v@mdjB5(v;8C?^O34x$NDLD!fA6?ehouSJ zor$Hz4#{0iW|7&XX&u)^^4Rmec_y$RyyE0*QifxX)Fg zfs?*>@;s_+Fhc>)^q>!E+a!CO?Owp^*Og=p>9FnVJZHNY7;BN!xaLL&{hz}OlwI@Ii(xdV^;&;_-<+)`<|opnUMCooK_03u~d#(ng!J1oNimteq4^9 zQfU-v1FV2)h-^v5a(tMiEE8wn1x zr1BZ*=ZdCST2vsrWr--EGppar-kOHVVEgOIUTIk{ioPtLG3AOVYh$r9LXbr52uJ~y z6>93_X(7sj!9tvqov=+*G-^|nV37+W`%It{xjLe0>ZHkSt)B1Cw%fq3$A{2^9m1NA1-{~`j2k;;n z89D2}@Xg3+4Nb$6$l0AyOqOBTI;dbv5rT8kaB-iN3N9BCPJ@ab=F(3Xl1Dg=N!^Y| zjgANzB$5Ce>3acjacLxy7l^#MF#u!lIbk&%^2t4U8ntPzZLV$|JG&0_1+<1@8wXeg zi6Dk0F@ksNfy$w_T10z2#OJ!ZlG&}yS4|@<5;`e(f!wf6ek~-7oPm>!92&Fzmv0;9 zX`_u}XD41&nTXZmAn8rFAmm<<(kq8?#W;n;mThw-vfH|~#9@Hd%P3CgI*$8i z8*NQNYStVZgj;Y(;fC=G$$NDp0M}zXWRa2o0PzFEoSL(Hd0|UrzPUEA%E>fw_lV`j zk(Ol}u{(6edGAWfBrPnKJ=lxBF|^LbRQL*#ta*{Pdh;HoCt%V-R~NI-#Uqk?XnZcN zf=LuA4Y?ig4&5?ACmV`7%4fALGHTMMQ9N3Nje~{D9m4ZC12_~5jv*zew6kXuDquwl zE|VZ&KJmXcNL`7}00YXHgIh;E*^3fj9V+0J)NppsQL#RRdC^Rp3N0go&__GK3d%yk z-4$jZ4up$lN(vDo^P)bgyvms80)I;4>)SfzG}$pC@2ql_Gb zzQdIrqe*mYSri}*DL6V99md;ZmfbVgVMU-k)Az*;tq@lalvX1If=lw`6ypPIoNb-i zw-MV%6!P51Wf@>4W*#QVIMfsz=RJOX2nswF%Q-WbMLZwVDu(EJaJL!=Jfg7f4`9B;cty{2+zglUmY5>;?Vv+*_)_9Fil0Y?1fn zJKlTbZ=fg`=gOreur^BwoFt&9c8!*Bgyg@F2PAHG&iSBOo7-o*xZ-z_cSO5LW>kp7 zs2xRPB$4<|0nf3`2GTKhrVB!mTg1yLcyP>Bw<~~l7~dysX9EXvlISe9;oZvH_h?iq zxck8xvDk(L1;NHgER2oLK*cX$Qed6(Nj0e&L(FuS*_1P5sUDl)XPK;CUrEC|=tay> zS}K!uE_(Q{zqUN;ZtQhkjXnBB!{nWm z+!rb%ReX1#&PNiH2j4fO&>klJydm?Db`6S%Tfo;V>Q3#EjI!OntD zJn|c7)0dXQyOAuT@1342D^~8o&JLnHC5J#r82KH!(Kv}s~dqg~^jnTq(W#i=&Lg=r2o4J30T zm^6o)Vm&vmvnY}~)*4KcvXxD=1`LD}0O`{`dSiM@ z{@&W^(%bf%D&_|eM$+nM!(p(1^Xtnyo$xbT!y}JoQi^9KcuVBImLKNa{Y5V{2{o!l z7eb*d4l~czx9%y}^)V^otEy5?P~-wP&JVv%g0(rVo&Z)z+BeaVt}st8>MO1}IT<+7 zKx|o0g+mWRLSR#3K1Qy)zGMX}`sRb(>mw|sNXP_a=gSqQh${oH<_F|ys5G)hLB&N>XT%4X^r8eTSw?o{j=r=3TX z1Hu98%78Ge58@ua^&kcp9DwqyY&LE3b3{msrWN$AGEREX1H4Ys-Yd6QSg;R-mNAvl zfrlPvpFVlzwI|$MM2hF{V&&eBUq31*63wms!uO9UNeE_Pf=JuWmP`4PP_P^;4b`wg z9=>(W6zI?$**L;?jB**bJ|_PF`hYM6KCt{;5lte6jR96>9Jf4x>*YzNxl5qJBza~) zPLMh6R@^oQ;wsUmmGEjh_XHDwG3okJkO*mWG>WX*ZbP6M82)ohuc9l)2_Lb zTE@OC4Du(T^!29x4U>vTY;@&JddMflajT!mQFx%9{n~4pK}%*-(4zwqd^!d$r6-B6u=M1nTrB+dQh;-+;#NYi!t4_(|OAW4R~O ze0->u*D`R^Gt}|BAaH;QM1WYvCKvTPXY0Ac1Q6Wb_et6!W>|b}r zc%KomY%GE?Atoj6fLNK71m;eml1bmFtel-1sH+VJWiMBQTHneIu(@Fstdi-BCRZV{ z+hpSd13BgANLb%kqP4FKb-KBOTq=>}*L--Hv4-DPJi#?;{gdM#Xwxso@rf@ch2+!m zPo*PO$RQO}fJq0S*a{V=WvzH?OB`^=D@AayYjQvhjN~ZfF^rt#@5pUgC##WdZjdNA zHKeIBwY|p?iZ&#}D}^{2!2w23h;N+os=g)J1(x~;y}ObNCjsS}Sw`|5*rH=mRTv*S zx8hu4D0rL})^M3Ph0+IEWc#&=M*3Gl<&QC)&h%;C1H~bZ_|C#Nk~GlS15r(TH~PQq zHl&=c#?t7iP7ONoFt%5ZX~Sc6RA|l%fw6qC-0jE=bm>=@kg10D)ZEDG@RyejtXd&E zYU!OTowphFpmv9e%NGu^;+A?1#E?haiK7HXr(Ov4*!z>esg}ZVZ7dvlmg-x(Xr&?< z9TXuM(s7LEVBXsrmu*=3EA3l{FAd{S+32{MIMq0Hx=LA{ixZLoASfyrjGlu#?xYWT z%4EEPEi)u2Ow(#{A;D0p4>RIyH%-ndnD|w<1BAq(nmGcXRXE5ea(udS-($a!_^%a9 z3#cqDZKIar1E7GVKYP zHDzI`KnzP@u0hE-863I!jgsO#MYwgeG1)0pxs0@uJ8CLHBW2}{w%l_&RBZT!*C}%D zOpwN|Rke3*gd2dPVV|8OwPSsa@s1!8;wM+|Tqc-Kbe{?EqVp_v!N3_D&J81d1Q%8k zrSrFecW3}PDZWb$Tk62}#^7>3i;*FoVt(=e00`7h;CPO7;A9OTbIm(vU|pIi z!b=UbZ4+xHwW?=12cCS%>&woTiR_$QBjQjikvM$|p(G@Nx^MyHG^(Cnha~2gxP@7} z+l6a><~+dAC?(@%z`@*;jz_Nkd8QiQ;_N%C8>@&SnR0~VQl@eg9r2$QdGzwYSBF_7 z7K$%{mEPdPA&s=^3m$QvgYbeezT<2PYuPT4MTX~!W<%YJ6$zGD)wD0-!PSwdt^ph2 zbL1(jmRmR^gSlX`K(6b95^Tc+u;w?&0AOhZovB$SzMJ7t%^!IrWuujsEfi>1O0F;l z2_q*vb*yo#*f>))rOZt7$rNTY7#P;rU|5~-0OUIP3QmTEqz`5K_qsw05mQR zG8pG4XUWFHZg{J{9Wg;1Zuo~mAO52 zmkyH29D)G2Y{q;<5$flBZARAo*Zp<>U#a0eIxR zzPNoo`XiWi=^F8ciF2?}aysV(^`_n*h`744kSjDD*nFP@YE4IOe2M4O(qiGRc$EGH z9|7Ye?nx(@_3)q5ogD*aZ07FLO>BeF4dWFQ&i&HB)fDja7+m|hX z$Nab>Z(&)vnc$vo`HMOh83&&%o}CWeIr-A}a!DoBq2F9^uXGa_9u@(Q%=Gfdl{B_1 z(dG?qj?9Hd4m?-{<8Vhjocd%I=e<`$Zzi;oNS!C~Gn|LjjOjYR2wqqtbBqjR(^FzN z)C_+KiehCd(~JvaG*q*pgGTq-=X%detF%B&22 zE^UnNaxySE=AF8VTWDG-3iD{#sn3T%Cu{+esEnW1h`b?<%<)g%OWnxG-Qd=``VcnT zk;j-6u7ZmGA8+xt?<~=_ajhS_+?B?5&cSyi`W|~_Mvvj7S!Y1yoz*3FI+`=$7~7F2 z8)GCBup*y(p#`yt+F2C{4p@wt80^E-Ad}1WG^OtZJWq)5xgl5~j@QQei!(B|%A;|N zY0gGBG~)NMHPIODVvJkOJS7;cf?fOB9Ax0*$ODXLbDniw6|54+9+TZXb82ux>4boB zgOimiS0|~?Hl{8uB!E1{^(c&?R$|8hWN)`px%0t3gc4sQ*22nUXD=Uk#9?qUazPvF zILAScI<n$G*YzXk1gVt8Rwo(pP8|HI$zLj_2@@a8E70{(7ay4Z7jiO>cVykW7wsD-03K zKgPQxcG6th0U21{-3w&ohlR+-agP@+TMV{2&@Q-qcRVvZK|?~a2vL^|<-&E;FcS&L zZaNz1Sz7GL!9I>SV+_RP4-N^_w=Q2jsd*KM8t_8&71a3G+1lHdJUVp|t-457Voq|o z8{==*x&6hLbu8%Q9_`Ap9Y{acw}jNTjNE9cc=pLWs7D}UBYn+TG+-m({s2xXPVZiH z;2))Goscf-e=){0Lo1!YJol#A4wB4r2O*k)+-GgDF-!nx+~=9Bl<@DrmT0;L8~_Nw z$vo>JFsxVtb3g!8+;5M1z)`E0UGh0m)pAaqur}Vj$?9@>PykvbDi4{g2v-CFzh1P$ zra@D-S0~pM?HZ1Hf&3@|Uh8gI^XrPobGZ6bYKyBF=6TjisuT~M`C@t67M*Mrlo830|e zk`BP`1pp7b*c@Py^{J}h0girjMrLg!om*sT-vY2e103_6IUKP-5eiPsGJOyCnwE{S zhks6WGNDFx<+jz8j9>yuEz8R^0TK^V30`?MG*hG(85(hwJdH<6t2TW1#TQPkEzc(d zBdq`oC?^c0 dict[str, tuple[dict, dict]]: + result = {} + for path in sorted(MANIFEST_ROOT.glob("*.json")): + manifest = json.loads(path.read_text(encoding="utf-8")) + assert manifest["family"] == FAMILY + assert manifest["task"] == "object_detection" + for case in manifest["testcases"]: + name = str(case["name"]) + assert name not in result + result[name] = (manifest, case) + assert result + return result + + +CASES = _cases() + + +def _selection(config) -> set[str]: + selected = set() + for option in ("--e2e-model", "--e2e-testcase"): + for raw in config.getoption(option, default=[]) or []: + selected.update(value.strip() for value in str(raw).split(",") if value.strip()) + models_file = config.getoption("--e2e-models-file", default=None) + if models_file: + selected.update( + line.strip() + for line in Path(models_file).read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + return selected + + +def pytest_generate_tests(metafunc) -> None: + if "case_name" not in metafunc.fixturenames: + return + selected = _selection(metafunc.config) + names = [ + name + for name, (manifest, _) in CASES.items() + if not selected or selected & {FAMILY, name, manifest["name"]} + ] + if not selected: + names = [ + pytest.param( + name, + marks=pytest.mark.skip(reason="real YOLOX E2E requires explicit selection"), + id=name, + ) + for name in names + ] + metafunc.parametrize("case_name", names) + + +def _required_path(value: str | None, label: str) -> Path: + assert value, f"selected YOLOX E2E requires {label}" + path = Path(value) + assert path.exists(), f"selected YOLOX E2E {label} does not exist: {path}" + return path + + +def _reference_root(manifest): + root = _required_path( + os.environ.get("TRTMC_REFERENCE_SOURCE_DIR"), "TRTMC_REFERENCE_SOURCE_DIR" + ) + revision = subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "HEAD"], text=True + ).strip() + assert revision == manifest["reference_revision"], ( + "reference checkout must match the pinned revision" + ) + dirty = subprocess.check_output( + ["git", "-C", str(root), "diff", "--name-only", "HEAD"], text=True + ) + assert not dirty.strip(), "the official reference must be unmodified" + metadata = json.loads((TEST_ROOT / "reference-source.json").read_text()) + assert metadata["revision"] == manifest["reference_revision"] + sys.path.insert(0, str(root)) + return root + + +def _native_pixels(image, binary, tmp_path): + height, width = image.shape[:2] + rgb = np.ascontiguousarray(image[..., ::-1], dtype=np.float32) / 255.0 + input_path, output_path = tmp_path / "rgb.f32", tmp_path / "preprocessed.f32" + rgb.tofile(input_path) + subprocess.run( + [str(binary), str(input_path), str(height), str(width), str(output_path)], + check=True, + capture_output=True, + timeout=30, + ) + return np.fromfile(output_path, dtype=np.float32).reshape(3, 640, 640) + + +def _engine_outputs(bundle, pixels): + import tensorrt as trt + import torch + + # Read the public bundle container to replay the exact engine built above. + with bundle.open("rb") as stream: + assert stream.read(8) == b"BUNDLE\x01\x00" + size = struct.unpack(" 0, "fixture must exercise real detections" + assert len(boxes) == len(expected), (actual, expected.tolist()) + np.testing.assert_array_equal(classes, expected[:, 6].astype(np.int32)) + np.testing.assert_allclose(scores, expected[:, 4] * expected[:, 5], rtol=0, atol=0.01) + target = expected[:, :4] / ratio + # Two pixels in network coordinates, independent of original image size. + np.testing.assert_allclose(boxes * ratio, target * ratio, rtol=0, atol=2.0) + intersection = np.maximum( + 0, np.minimum(boxes[:, 2:], target[:, 2:]) - np.maximum(boxes[:, :2], target[:, :2]) + ).prod(axis=1) + union = ( + (boxes[:, 2:] - boxes[:, :2]).prod(axis=1) + + (target[:, 2:] - target[:, :2]).prod(axis=1) + - intersection + ) + assert np.all(intersection / union >= 0.98), intersection / union + + +def test_official_checkpoint_e2e(case_name: str, tmp_path: Path) -> None: + manifest, case = CASES[case_name] + reference_root = _reference_root(manifest) + import cv2 + import torch + import yolox + from yolox.exp import get_exp + from yolox.data.data_augment import preproc + from yolox.utils import postprocess + + assert Path(yolox.__file__).resolve().is_relative_to(reference_root.resolve()) + binary = _required_path(os.environ.get("TRTMC_BINARY"), "TRTMC_BINARY") + runtime_root = _required_path(os.environ.get("TRTMC_RUNTIME_ROOT"), "TRTMC_RUNTIME_ROOT") + native_build = Path(os.environ.get("TRTMC_NATIVE_BUILD_DIR", str(runtime_root))) + seam = native_build / "families/yolox/test_yolox_image_preprocess" + assert seam.is_file(), f"selected YOLOX E2E requires the native seam test: {seam}" + model_dir = _required_path(os.environ.get("TRTMC_YOLOX_MODEL_DIR"), "TRTMC_YOLOX_MODEL_DIR") + assert (runtime_root / "libtrtmc_backend_trt.so").is_file() + assert (runtime_root / "libtrtmc_model_yolox.so").is_file() + checkpoint_path = model_dir / manifest["external_files"][0]["path"] + assert torch.cuda.is_available(), "selected YOLOX E2E requires a CUDA GPU" + bundle = tmp_path / manifest["bundle"] + build( + BuildRequest( + model_dir=model_dir, + output_path=bundle, + family=FAMILY, + task=manifest["task"], + precision=manifest["precision"], + ) + ) + reference = ( + get_exp(str(reference_root / "exps/default/yolox_s.py"), None).get_model().eval().cuda() + ) + reference.load_state_dict( + torch.load(checkpoint_path, map_location="cpu", weights_only=True)["model"], strict=True + ) + torch.backends.cuda.matmul.allow_tf32 = False + torch.backends.cudnn.allow_tf32 = False + original = cv2.imread(str(TEST_ROOT / case["test_image"])) + assert original is not None + height, width = original.shape[:2] + portrait = np.full((width * 2, width, 3), 114, dtype=np.uint8) + portrait[:height] = original + for label, image in (("landscape", original), ("portrait", portrait)): + official_pixels, ratio = preproc(image, (640, 640)) + native_pixels = _native_pixels(image, seam, tmp_path) + # OpenCV's byte resize uses fixed-point rounding. No channel, padding, + # scale or normalization error can fit within this one-byte bound. + np.testing.assert_allclose(native_pixels, official_pixels, rtol=0, atol=1.0) + actual_raw = _engine_outputs(bundle, native_pixels) + with torch.no_grad(): + same_input = reference(torch.from_numpy(native_pixels[None]).cuda())[0].cpu().numpy() + official = reference(torch.from_numpy(official_pixels[None]).cuda()) + expected = postprocess( + official.clone(), 80, conf_thre=0.25, nms_thre=0.45, class_agnostic=False + )[0] + expected_scores = same_input[:, 4] * same_input[:, 5:].max(axis=1) + expected_classes = same_input[:, 5:].argmax(axis=1) + expected_boxes = np.concatenate( + ( + same_input[:, :2] - same_input[:, 2:4] * 0.5, + same_input[:, :2] + same_input[:, 2:4] * 0.5, + ), + axis=1, + ) + assert actual_raw["boxes"].shape == (8400, 4) + assert all(np.isfinite(value).all() for value in actual_raw.values()) + np.testing.assert_allclose(actual_raw["scores"], expected_scores, rtol=0, atol=0.01) + foreground = (expected_scores >= 0.1) | (actual_raw["scores"] >= 0.1) + assert foreground.any() + np.testing.assert_array_equal( + actual_raw["classes"][foreground], expected_classes[foreground] + ) + np.testing.assert_allclose( + actual_raw["boxes"][foreground], expected_boxes[foreground], rtol=0, atol=2.0 + ) + image_path = tmp_path / f"{label}.png" + assert cv2.imwrite(str(image_path), image) + completed = subprocess.run( + [ + str(binary), + "detect", + str(bundle), + "--runtime-root", + str(runtime_root), + "--image", + str(image_path), + ], + check=True, + capture_output=True, + text=True, + timeout=120, + ) + actual = json.loads(completed.stdout) + assert expected is not None + _compare_detections(actual, expected, ratio) + print(f"{case_name} {label}: {len(expected)} detections match the official reference") diff --git a/families/yolox/tests/test_model.py b/families/yolox/tests/test_model.py new file mode 100644 index 0000000000..bfb26b646d --- /dev/null +++ b/families/yolox/tests/test_model.py @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Checkpoint, request, BN-folding and TensorRT activation contracts.""" + +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pytest +import tensorrt as trt +import torch + +from families.yolox import graph +from families.yolox.checkpoint import Checkpoint +from families.yolox.model import _fold, build +from families.yolox.support import describe +from tensorrt_model_connect import BuildRequest +from tensorrt_model_connect.model_support import ModelMetadata + + +def test_exact_checkpoint_identity(): + assert describe(ModelMetadata(config={}, model_index={}, files=("yolox_s.pth",))) is not None + for name in ("yolox_m.pth", "yolov5n.pt", "yolox.pth"): + assert describe(ModelMetadata(config={}, model_index={}, files=(name,))) is None + + +@pytest.mark.parametrize( + "field,value", + [ + ("backend", "trt_rtx"), + ("task", "image_classification"), + ("dynamic_kv_cache", True), + ("image_height", 320), + ("image_width", 320), + ("video_num_frames", 2), + ("max_batch_size", 2), + ("tensor_parallel_size", 2), + ("context_parallel_size", 2), + ("quantization", "fp8"), + ("fp32_layers", (0,)), + ("max_sequence_length", 2), + ], +) +def test_unsupported_request_fails_before_checkpoint_access(field, value): + request = BuildRequest( + model_dir=Path("missing"), + output_path=Path("unused.bundle"), + family="yolox", + task="object_detection", + precision="fp16", + ) + with pytest.raises((NotImplementedError, ValueError)): + build(replace(request, **{field: value}), None) + + +def test_fold_matches_pytorch_including_small_variance(): + generator = torch.Generator().manual_seed(10) + conv = torch.nn.Conv2d(3, 4, 3, padding=1, bias=False).eval() + norm = torch.nn.BatchNorm2d(4, eps=1e-3).eval() + with torch.no_grad(): + conv.weight.copy_(torch.randn(conv.weight.shape, generator=generator) * 0.1) + norm.weight.copy_(torch.tensor([0.5, 2.0, -0.1, 1.0])) + norm.bias.copy_(torch.tensor([1.0, -2.0, 0.3, 0.0])) + norm.running_mean.copy_(torch.tensor([0.2, -0.4, 0.0, 1.0])) + norm.running_var.copy_(torch.tensor([0.0001, 0.01, 1.0, 4.0])) + state = { + "block.conv.weight": conv.weight.detach(), + **{f"block.bn.{name}": value for name, value in norm.state_dict().items()}, + } + weight, bias = _fold(Checkpoint(state), "block", np.float32) + pixels = torch.randn((1, 3, 7, 9), generator=generator) + actual = torch.nn.functional.conv2d( + pixels, torch.from_numpy(weight), torch.from_numpy(bias), padding=1 + ) + torch.testing.assert_close(actual, norm(conv(pixels)), atol=2e-5, rtol=2e-5) + # Wrong PyTorch-default epsilon must fail this numerical oracle. + norm.eps = 1e-5 + assert (actual - norm(conv(pixels))).abs().max() > 1.0 + + +def test_checkpoint_rejects_missing_extra_and_nonfinite_tensors(tmp_path): + torch.save({"model": {"head.weight": torch.ones(1)}}, tmp_path / "yolox_s.pth") + checkpoint = Checkpoint.open(tmp_path) + with pytest.raises(ValueError, match="missing"): + checkpoint.tensor("backbone.weight") + with pytest.raises(ValueError, match="Unsupported"): + checkpoint.assert_consumed() + with pytest.raises(ValueError, match="non-finite"): + Checkpoint({"weight": torch.tensor([float("nan")])}) + + +@pytest.mark.trt +@pytest.mark.skipif(not torch.cuda.is_available(), reason="TensorRT activation test requires CUDA") +def test_half_silu_matches_the_official_pytorch_activation(): + values = torch.tensor( + [8.0078125, -8.0078125, 1, -1, 0, 3, 7], device="cuda", dtype=torch.float16 + ) + expected = torch.nn.functional.silu(values) + # The old two-operation FP16 expression rounds the positive probe to 8.0. + assert not torch.equal(values * values.sigmoid(), expected) + logger = trt.Logger(trt.Logger.WARNING) + builder = trt.Builder(logger) + network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.STRONGLY_TYPED)) + config = builder.create_builder_config() + config.builder_optimization_level = 1 + tensor = network.add_input("input", trt.float16, tuple(values.shape)) + output = graph.silu(network, tensor) + output.name = "output" + network.mark_output(output) + plan = builder.build_serialized_network(network, config) + assert plan is not None + runtime = trt.Runtime(logger) + engine = runtime.deserialize_cuda_engine(plan) + assert engine is not None + context = engine.create_execution_context() + actual = torch.empty_like(values) + assert context.set_tensor_address("input", values.data_ptr()) + assert context.set_tensor_address("output", actual.data_ptr()) + stream = torch.cuda.Stream() + stream.wait_stream(torch.cuda.current_stream()) + assert context.execute_async_v3(stream.cuda_stream) + stream.synchronize() + torch.testing.assert_close(actual, expected, rtol=0, atol=0) From 8500bae23903a508a500db0db8a047aae01a14cb Mon Sep 17 00:00:00 2001 From: wyh88 <2469410854@qq.com> Date: Sun, 13 Sep 2026 08:29:28 -0400 Subject: [PATCH 2/3] fix(yolox): declare tensor parallel size Signed-off-by: wyh88 <2469410854@qq.com> --- families/yolox/tests/manifests/yolox-s.json | 1 + 1 file changed, 1 insertion(+) diff --git a/families/yolox/tests/manifests/yolox-s.json b/families/yolox/tests/manifests/yolox-s.json index d07e393f95..f530d0584c 100644 --- a/families/yolox/tests/manifests/yolox-s.json +++ b/families/yolox/tests/manifests/yolox-s.json @@ -4,6 +4,7 @@ "task": "object_detection", "bundle": "yolox-s.bundle", "precision": "fp16", + "tensor_parallel_size": 1, "external_files": [ { "path": "yolox_s.pth", From 5b22571c7b84b5b16352b092a9e2cba4153438b1 Mon Sep 17 00:00:00 2001 From: wyh88 <2469410854@qq.com> Date: Sun, 13 Sep 2026 10:02:31 -0400 Subject: [PATCH 3/3] feat(yolox): support all official variants Signed-off-by: wyh88 <2469410854@qq.com> --- families/yolox/checkpoint.py | 19 ++- families/yolox/graph.py | 8 + families/yolox/model.py | 160 ++++++++++++++---- families/yolox/runtime/pipeline.cpp | 8 +- families/yolox/runtime/plugin.cpp | 22 ++- families/yolox/support.py | 20 ++- .../tests/cpp/test_image_preprocess_seam.cpp | 6 +- families/yolox/tests/test_e2e.py | 30 ++-- families/yolox/tests/test_model.py | 40 ++++- 9 files changed, 240 insertions(+), 73 deletions(-) diff --git a/families/yolox/checkpoint.py b/families/yolox/checkpoint.py index fcb9d3ce35..97b3c3eac6 100644 --- a/families/yolox/checkpoint.py +++ b/families/yolox/checkpoint.py @@ -1,19 +1,22 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Read the official YOLOX-s state dictionary without unpickling model code.""" +"""Read an official YOLOX state dictionary without unpickling model code.""" from pathlib import Path import numpy as np import torch +from .support import ARCHIVES + class Checkpoint: - def __init__(self, state: dict[str, torch.Tensor]) -> None: + def __init__(self, state: dict[str, torch.Tensor], *, image_size: int = 640) -> None: if not isinstance(state, dict) or not state: raise ValueError("YOLOX checkpoint must contain a non-empty model state dictionary") self.state = state + self.image_size = image_size self.used: set[str] = set() for name, tensor in state.items(): if not isinstance(name, str) or not isinstance(tensor, torch.Tensor): @@ -23,10 +26,16 @@ def __init__(self, state: dict[str, torch.Tensor]) -> None: @classmethod def open(cls, model_dir: Path) -> "Checkpoint": - archive = torch.load(model_dir / "yolox_s.pth", map_location="cpu", weights_only=True) + paths = [model_dir / name for name in ARCHIVES if (model_dir / name).is_file()] + if len(paths) != 1: + raise ValueError("YOLOX model directory must contain exactly one official checkpoint") + path = paths[0] + archive = torch.load(path, map_location="cpu", weights_only=True) if not isinstance(archive, dict) or "model" not in archive: raise ValueError("YOLOX checkpoint must contain a model state dictionary") - return cls(archive["model"]) + # Training/evaluation resolution is not stored in a state dictionary. + image_size = 416 if path.name in {"yolox_nano.pth", "yolox_tiny.pth"} else 640 + return cls(archive["model"], image_size=image_size) def tensor(self, name: str) -> np.ndarray: if name not in self.state: @@ -38,4 +47,4 @@ def assert_consumed(self) -> None: unused = set(self.state) - self.used unused = {name for name in unused if not name.endswith(".bn.num_batches_tracked")} if unused: - raise ValueError(f"Unsupported YOLOX-s checkpoint tensors: {sorted(unused)}") + raise ValueError(f"Unsupported YOLOX checkpoint tensors: {sorted(unused)}") diff --git a/families/yolox/graph.py b/families/yolox/graph.py index d9e5d6b60b..3de4418e18 100644 --- a/families/yolox/graph.py +++ b/families/yolox/graph.py @@ -62,6 +62,14 @@ def silu(network, tensor): return output +def leaky_relu(network, tensor): + layer = network.add_activation(tensor, trt.ActivationType.LEAKY_RELU) + if layer is None: + raise RuntimeError("TensorRT rejected a YOLOX leaky ReLU") + layer.alpha = 0.1 + return layer.get_output(0) + + def add(network, left, right): layer = network.add_elementwise(left, right, trt.ElementWiseOperation.SUM) if layer is None: diff --git a/families/yolox/model.py b/families/yolox/model.py index 64c691bb7e..267ff497d3 100644 --- a/families/yolox/model.py +++ b/families/yolox/model.py @@ -1,10 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""YOLOX-s: Focus/CSPDarknet, PAFPN and a decoupled anchor-free head. +"""YOLOX: CSPDarknet/PAFPN or Darknet/FPN and a decoupled anchor-free head. The topology follows Megvii-BaseDetection/YOLOX at -6ddff4824372906469a7fae2dc3206c7aa4bbaee, exps/default/yolox_s.py. +6ddff4824372906469a7fae2dc3206c7aa4bbaee, exps/default/. TensorRT owns lowering and execution; this family specifies the graph. """ @@ -26,7 +26,6 @@ # Exp.get_model overrides the PyTorch default epsilon before loading weights. _BATCH_NORM_EPSILON = 1e-3 -_IMAGE_SIZE = 640 _NUM_CLASSES = 80 _STRIDES = (8, 16, 32) @@ -63,24 +62,68 @@ def conv(self, prefix: str) -> tuple[np.ndarray, np.ndarray]: def raw(self, name: str) -> np.ndarray: return self._checkpoint.tensor(name).astype(self._dtype) + def exists(self, name: str) -> bool: + return name in self._checkpoint.state + def _conv(network, tensor, weights: _Weights, prefix: str, dtype, *, stride: int = 1): + if weights.exists(f"{prefix}.dconv.conv.weight"): + # Rounding between depthwise and pointwise convolutions amplifies score error. + if dtype == np.float16: + cast = network.add_cast(tensor, trt.float32) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX depthwise input cast") + tensor = cast.get_output(0) + tensor = _conv(network, tensor, weights, f"{prefix}.dconv", np.float32, stride=stride) + tensor = _conv(network, tensor, weights, f"{prefix}.pconv", np.float32) + if dtype == np.float16: + cast = network.add_cast(tensor, trt.float16) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX depthwise output cast") + tensor = cast.get_output(0) + return tensor weight, bias = weights.conv(prefix) - if weight.shape[1] != int(tensor.shape[1]): - raise ValueError(f"YOLOX-s input channel mismatch: {prefix}") + groups = int(tensor.shape[1]) if prefix.endswith(".dconv") else 1 + if weight.shape[1] * groups != int(tensor.shape[1]): + raise ValueError(f"YOLOX input channel mismatch: {prefix}") tensor = graph.convolution( - network, tensor, weight, bias, stride=stride, padding=weight.shape[2] // 2, dtype=dtype + network, + tensor, + weight, + bias, + stride=stride, + padding=weight.shape[2] // 2, + groups=groups, + dtype=dtype, ) + if weights.exists("backbone.backbone.stem.0.conv.weight"): + return graph.leaky_relu(network, tensor) return graph.silu(network, tensor) -def _csp(network, tensor, weights: _Weights, prefix: str, dtype, *, count: int, residual: bool): +def _csp(network, tensor, weights: _Weights, prefix: str, dtype, *, residual: bool): left = _conv(network, tensor, weights, f"{prefix}.conv1", dtype) right = _conv(network, tensor, weights, f"{prefix}.conv2", dtype) - for index in range(count): - inner = _conv(network, left, weights, f"{prefix}.m.{index}.conv1", dtype) - inner = _conv(network, inner, weights, f"{prefix}.m.{index}.conv2", dtype) + # Preserve the residual path around depthwise bottlenecks in FP32 as well. + inner_dtype = np.float32 if weights.exists(f"{prefix}.m.0.conv2.dconv.conv.weight") else dtype + if inner_dtype != dtype: + cast = network.add_cast(left, trt.float32) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX bottleneck input cast") + left = cast.get_output(0) + index = 0 + while weights.exists(f"{prefix}.m.{index}.conv1.conv.weight"): + inner = _conv(network, left, weights, f"{prefix}.m.{index}.conv1", inner_dtype) + inner = _conv(network, inner, weights, f"{prefix}.m.{index}.conv2", inner_dtype) left = graph.add(network, left, inner) if residual else inner + index += 1 + if index == 0: + raise ValueError(f"YOLOX CSP block has no bottlenecks: {prefix}") + if inner_dtype != dtype: + cast = network.add_cast(left, right.dtype) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX bottleneck output cast") + left = cast.get_output(0) return _conv( network, graph.concatenate(network, [left, right]), weights, f"{prefix}.conv3", dtype ) @@ -100,7 +143,48 @@ def _focus(network, tensor): return graph.concatenate(network, parts) +def _spp(network, tensor, weights: _Weights, prefix: str, dtype): + entry = _conv(network, tensor, weights, f"{prefix}.conv1", dtype) + # Parallel SPP pools in the order used by the official implementation. + parts = [entry] + [ + graph.max_pool(network, entry, kernel=k, stride=1, padding=k // 2) for k in (5, 9, 13) + ] + return _conv(network, graph.concatenate(network, parts), weights, f"{prefix}.conv2", dtype) + + +def _darknet(network, pixels, weights: _Weights, dtype): + prefix = "backbone.backbone" + tensor = _conv(network, pixels, weights, f"{prefix}.stem.0", np.float32) + if dtype == np.float16: + cast = network.add_cast(tensor, trt.float16) + if cast is None: + raise RuntimeError("TensorRT rejected the YOLOX Darknet feature cast") + tensor = cast.get_output(0) + outputs = [] + for stage in ("stem", "dark2", "dark3", "dark4", "dark5"): + index = 1 if stage == "stem" else 0 + block = f"{prefix}.{stage}" + tensor = _conv(network, tensor, weights, f"{block}.{index}", dtype, stride=2) + index += 1 + while weights.exists(f"{block}.{index}.layer1.conv.weight"): + inner = _conv(network, tensor, weights, f"{block}.{index}.layer1", dtype) + inner = _conv(network, inner, weights, f"{block}.{index}.layer2", dtype) + tensor = graph.add(network, tensor, inner) + index += 1 + if stage == "dark5": + tensor = _conv(network, tensor, weights, f"{block}.{index}", dtype) + tensor = _conv(network, tensor, weights, f"{block}.{index + 1}", dtype) + tensor = _spp(network, tensor, weights, f"{block}.{index + 2}", dtype) + tensor = _conv(network, tensor, weights, f"{block}.{index + 3}", dtype) + tensor = _conv(network, tensor, weights, f"{block}.{index + 4}", dtype) + if stage in {"dark3", "dark4", "dark5"}: + outputs.append(tensor) + return outputs + + def _backbone(network, pixels, weights: _Weights, dtype): + if weights.exists("backbone.backbone.stem.0.conv.weight"): + return _darknet(network, pixels, weights, dtype) prefix = "backbone.backbone" # Preserve small color differences in the unnormalized BGR byte input. tensor = _conv(network, _focus(network, pixels), weights, f"{prefix}.stem.conv", np.float32) @@ -109,32 +193,17 @@ def _backbone(network, pixels, weights: _Weights, dtype): if cast is None: raise RuntimeError("TensorRT rejected the YOLOX feature cast") tensor = cast.get_output(0) - if int(tensor.shape[1]) != 32: - raise ValueError("Only the YOLOX-s width=0.50 checkpoint is supported") outputs = [] - for stage, count in ((2, 1), (3, 3), (4, 3), (5, 1)): + for stage in range(2, 6): tensor = _conv(network, tensor, weights, f"{prefix}.dark{stage}.0", dtype, stride=2) if stage == 5: - entry = _conv(network, tensor, weights, f"{prefix}.dark5.1.conv1", dtype) - # Parallel SPP pools, rather than the SPPF block of later YOLOs. - parts = [entry] + [ - graph.max_pool(network, entry, kernel=k, stride=1, padding=k // 2) - for k in (5, 9, 13) - ] - tensor = _conv( - network, - graph.concatenate(network, parts), - weights, - f"{prefix}.dark5.1.conv2", - dtype, - ) + tensor = _spp(network, tensor, weights, f"{prefix}.dark5.1", dtype) tensor = _csp( network, tensor, weights, f"{prefix}.dark{stage}.{2 if stage == 5 else 1}", dtype, - count=count, residual=stage != 5, ) if stage >= 3: @@ -142,6 +211,20 @@ def _backbone(network, pixels, weights: _Weights, dtype): return outputs +def _fpn(network, sources, weights: _Weights, dtype): + dark3, dark4, tensor = sources + outputs = [tensor] + for level, source in enumerate((dark4, dark3), start=1): + tensor = _conv(network, tensor, weights, f"backbone.out{level}_cbl", dtype) + tensor = graph.concatenate(network, [graph.nearest_upsample(network, tensor, 2), source]) + index = 0 + while weights.exists(f"backbone.out{level}.{index}.conv.weight"): + tensor = _conv(network, tensor, weights, f"backbone.out{level}.{index}", dtype) + index += 1 + outputs.append(tensor) + return tuple(reversed(outputs)) + + def _neck(network, sources, weights: _Weights, dtype): # PAFPN and the head need FP32 to keep score error within 0.01. promoted = [] @@ -152,28 +235,30 @@ def _neck(network, sources, weights: _Weights, dtype): raise RuntimeError("TensorRT rejected the YOLOX PAFPN feature cast") source = cast.get_output(0) promoted.append(source) + if weights.exists("backbone.out1_cbl.conv.weight"): + return _fpn(network, promoted, weights, dtype) dark3, dark4, dark5 = promoted lateral = _conv(network, dark5, weights, "backbone.lateral_conv0", dtype) merged = graph.concatenate(network, [graph.nearest_upsample(network, lateral, 2), dark4]) - upper = _csp(network, merged, weights, "backbone.C3_p4", dtype, count=1, residual=False) + upper = _csp(network, merged, weights, "backbone.C3_p4", dtype, residual=False) reduced = _conv(network, upper, weights, "backbone.reduce_conv1", dtype) merged = graph.concatenate(network, [graph.nearest_upsample(network, reduced, 2), dark3]) - p3 = _csp(network, merged, weights, "backbone.C3_p3", dtype, count=1, residual=False) + p3 = _csp(network, merged, weights, "backbone.C3_p3", dtype, residual=False) merged = graph.concatenate( network, [_conv(network, p3, weights, "backbone.bu_conv2", dtype, stride=2), reduced] ) - p4 = _csp(network, merged, weights, "backbone.C3_n3", dtype, count=1, residual=False) + p4 = _csp(network, merged, weights, "backbone.C3_n3", dtype, residual=False) merged = graph.concatenate( network, [_conv(network, p4, weights, "backbone.bu_conv1", dtype, stride=2), lateral] ) - p5 = _csp(network, merged, weights, "backbone.C3_n4", dtype, count=1, residual=False) + p5 = _csp(network, merged, weights, "backbone.C3_n4", dtype, residual=False) return p3, p4, p5 def _predict(network, tensor, weights: _Weights, prefix: str, dtype, *, channels: int): weight, bias = weights.raw(f"{prefix}.weight"), weights.raw(f"{prefix}.bias") if weight.shape != (channels, int(tensor.shape[1]), 1, 1) or bias.shape != (channels,): - raise ValueError(f"Unsupported YOLOX-s prediction shape: {prefix}") + raise ValueError(f"Unsupported YOLOX prediction shape: {prefix}") return graph.convolution(network, tensor, weight, bias, dtype=dtype) @@ -243,7 +328,8 @@ def _build_engine(checkpoint: Checkpoint, precision: str, verbose: bool) -> byte config.builder_optimization_level = 1 config.clear_flag(trt.BuilderFlag.TF32) config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30) - pixels = network.add_input("pixel_values", trt.float32, (1, 3, _IMAGE_SIZE, _IMAGE_SIZE)) + size = checkpoint.image_size + pixels = network.add_input("pixel_values", trt.float32, (1, 3, size, size)) if pixels is None: raise RuntimeError("TensorRT rejected the YOLOX input") sources = _backbone(network, pixels, weights, numpy_dtype) @@ -260,7 +346,7 @@ def _build_engine(checkpoint: Checkpoint, precision: str, verbose: bool) -> byte def build(request: "BuildRequest", writer: "BundleWriter") -> None: - """Build the official 80-class YOLOX-s, batch one, 640 x 640 detector.""" + """Build an official 80-class YOLOX detector at its published input size.""" if request.backend != "trt": raise NotImplementedError("yolox supports only backend=trt") if request.task != "object_detection": @@ -290,12 +376,12 @@ def build(request: "BuildRequest", writer: "BundleWriter") -> None: writer.add_json( "runtime.json", { - "input_image_h": _IMAGE_SIZE, - "input_image_w": _IMAGE_SIZE, + "input_image_h": checkpoint.image_size, + "input_image_w": checkpoint.image_size, "pad_value": 114, "score_threshold": 0.25, "iou_threshold": 0.45, "num_classes": _NUM_CLASSES, - "max_detections": 8400, + "max_detections": sum((checkpoint.image_size // stride) ** 2 for stride in _STRIDES), }, ) diff --git a/families/yolox/runtime/pipeline.cpp b/families/yolox/runtime/pipeline.cpp index f5d31d919a..7b04a599ea 100644 --- a/families/yolox/runtime/pipeline.cpp +++ b/families/yolox/runtime/pipeline.cpp @@ -104,9 +104,13 @@ ObjectDetectionResult YoloxObjectDetectionPipeline::detect(const float* pixels, static_cast(classes->numel()) != count) throw std::runtime_error("YOLOX detection outputs disagree on their length"); - if (count != 8400 || boxes->data == nullptr || scores->data == nullptr || + std::size_t expected_count = 0; + for (const int stride : {8, 16, 32}) + expected_count += static_cast(preprocess_config_.input_image_h / stride) * + (preprocess_config_.input_image_w / stride); + if (count != expected_count || boxes->data == nullptr || scores->data == nullptr || classes->data == nullptr) - throw std::runtime_error("YOLOX-s requires 8400 populated detection slots"); + throw std::runtime_error("YOLOX detection slots do not match the configured input size"); const auto* box_values = static_cast(boxes->data); const auto* score_values = static_cast(scores->data); diff --git a/families/yolox/runtime/plugin.cpp b/families/yolox/runtime/plugin.cpp index d9674c0236..28634f4d23 100644 --- a/families/yolox/runtime/plugin.cpp +++ b/families/yolox/runtime/plugin.cpp @@ -17,7 +17,13 @@ namespace { constexpr float kScoreThreshold = 0.25F; constexpr float kIouThreshold = 0.45F; -constexpr std::int32_t kMaxDetections = 8400; + +std::int32_t detection_slots(const YoloxPreprocessConfig& config) { + std::int32_t count = 0; + for (const int stride : {8, 16, 32}) + count += (config.input_image_h / stride) * (config.input_image_w / stride); + return count; +} std::vector require_section(const BundleReader& bundle, const char* name) { const auto* section = bundle.find_section(name); @@ -35,10 +41,11 @@ YoloxPreprocessConfig parse_config(const std::vector& data) { const auto score = json.at("score_threshold").get(); const auto iou = json.at("iou_threshold").get(); const auto maximum = json.at("max_detections").get(); - if (config.input_image_h != 640 || config.input_image_w != 640 || config.pad_value != 114.0F || - json.at("num_classes").get() != 80 || maximum != kMaxDetections || + if ((config.input_image_h != 416 && config.input_image_h != 640) || + config.input_image_w != config.input_image_h || config.pad_value != 114.0F || + json.at("num_classes").get() != 80 || maximum != detection_slots(config) || score != kScoreThreshold || iou != kIouThreshold) - throw std::runtime_error("YOLOX-s runtime.json does not match its contract"); + throw std::runtime_error("YOLOX runtime.json does not match its contract"); return config; } @@ -60,7 +67,8 @@ extern "C" trtmc::ITask* trtmc_create_family(const trtmc::FamilyContext& context const auto plan = trtmc::yolox::require_section(context.reader, "engine.plan"); auto config = trtmc::yolox::parse_config(config_data); auto engine = trtmc::yolox::load_engine(context.backend, plan); - return new trtmc::YoloxObjectDetectionPipeline( - std::move(engine), std::move(config), trtmc::yolox::kScoreThreshold, - trtmc::yolox::kIouThreshold, trtmc::yolox::kMaxDetections); + const auto maximum = trtmc::yolox::detection_slots(config); + return new trtmc::YoloxObjectDetectionPipeline(std::move(engine), std::move(config), + trtmc::yolox::kScoreThreshold, + trtmc::yolox::kIouThreshold, maximum); } diff --git a/families/yolox/support.py b/families/yolox/support.py index 3f2a3a89b5..d907a59cfe 100644 --- a/families/yolox/support.py +++ b/families/yolox/support.py @@ -3,11 +3,21 @@ """Exact local checkpoint identity and public task owned by YOLOX.""" -from tensorrt_model_connect.model_support import family_support +from tensorrt_model_connect.model_support import FamilySupport, ModelMetadata -describe = family_support( - required_files=("yolox_s.pth",), - tasks=("object_detection",), - default_task="object_detection", +ARCHIVES = ( + "yolox_nano.pth", + "yolox_tiny.pth", + "yolox_s.pth", + "yolox_m.pth", + "yolox_l.pth", + "yolox_x.pth", + "yolox_darknet.pth", ) + + +def describe(metadata: ModelMetadata) -> FamilySupport | None: + if any(name in metadata.files for name in ARCHIVES): + return FamilySupport(tasks=("object_detection",), default_task="object_detection") + return None diff --git a/families/yolox/tests/cpp/test_image_preprocess_seam.cpp b/families/yolox/tests/cpp/test_image_preprocess_seam.cpp index 9ed6b6e8cc..2e44720373 100644 --- a/families/yolox/tests/cpp/test_image_preprocess_seam.cpp +++ b/families/yolox/tests/cpp/test_image_preprocess_seam.cpp @@ -24,10 +24,12 @@ int main(int argc, char** argv) { try { trtmc::YoloxPreprocessConfig config; trtmc::YoloxLetterbox letterbox; - if (argc == 5) { + if (argc == 5 || argc == 6) { // Family test seam: raw interleaved RGB float32 in, BGR CHW out. const int height = std::stoi(argv[2]); const int width = std::stoi(argv[3]); + if (argc == 6) + config.input_image_h = config.input_image_w = std::stoi(argv[5]); require(height > 0 && width > 0, "invalid image dimensions"); std::vector pixels(static_cast(height) * width * 3U); std::ifstream input(argv[1], std::ios::binary); @@ -41,7 +43,7 @@ int main(int argc, char** argv) { require(static_cast(output), "could not write preprocessed pixels"); return 0; } - require(argc == 1, "expected no arguments or input height width output"); + require(argc == 1, "expected no arguments or input height width output [size]"); config.input_image_h = config.input_image_w = 2; const float red_blue[] = {1, 0, 0, 0, 0, 1}; const auto values = trtmc::preprocess_yolox_image(red_blue, 1, 2, config, letterbox); diff --git a/families/yolox/tests/test_e2e.py b/families/yolox/tests/test_e2e.py index 54d86b4118..d8ea3759f4 100644 --- a/families/yolox/tests/test_e2e.py +++ b/families/yolox/tests/test_e2e.py @@ -103,18 +103,18 @@ def _reference_root(manifest): return root -def _native_pixels(image, binary, tmp_path): +def _native_pixels(image, binary, tmp_path, image_size=640): height, width = image.shape[:2] rgb = np.ascontiguousarray(image[..., ::-1], dtype=np.float32) / 255.0 input_path, output_path = tmp_path / "rgb.f32", tmp_path / "preprocessed.f32" rgb.tofile(input_path) subprocess.run( - [str(binary), str(input_path), str(height), str(width), str(output_path)], + [str(binary), str(input_path), str(height), str(width), str(output_path), str(image_size)], check=True, capture_output=True, timeout=30, ) - return np.fromfile(output_path, dtype=np.float32).reshape(3, 640, 640) + return np.fromfile(output_path, dtype=np.float32).reshape(3, image_size, image_size) def _engine_outputs(bundle, pixels): @@ -192,10 +192,15 @@ def test_official_checkpoint_e2e(case_name: str, tmp_path: Path) -> None: native_build = Path(os.environ.get("TRTMC_NATIVE_BUILD_DIR", str(runtime_root))) seam = native_build / "families/yolox/test_yolox_image_preprocess" assert seam.is_file(), f"selected YOLOX E2E requires the native seam test: {seam}" - model_dir = _required_path(os.environ.get("TRTMC_YOLOX_MODEL_DIR"), "TRTMC_YOLOX_MODEL_DIR") + checkpoints = _required_path(os.environ.get("TRTMC_YOLOX_MODEL_DIR"), "TRTMC_YOLOX_MODEL_DIR") assert (runtime_root / "libtrtmc_backend_trt.so").is_file() assert (runtime_root / "libtrtmc_model_yolox.so").is_file() - checkpoint_path = model_dir / manifest["external_files"][0]["path"] + checkpoint_path = checkpoints / manifest["external_files"][0]["path"] + assert checkpoint_path.is_file(), f"selected YOLOX checkpoint is missing: {checkpoint_path}" + # Each build sees exactly its selected archive, even when CI stages all sizes together. + model_dir = tmp_path / "model" + model_dir.mkdir() + (model_dir / checkpoint_path.name).symlink_to(checkpoint_path.resolve()) assert torch.cuda.is_available(), "selected YOLOX E2E requires a CUDA GPU" bundle = tmp_path / manifest["bundle"] build( @@ -205,11 +210,13 @@ def test_official_checkpoint_e2e(case_name: str, tmp_path: Path) -> None: family=FAMILY, task=manifest["task"], precision=manifest["precision"], + tensor_parallel_size=manifest["tensor_parallel_size"], ) ) - reference = ( - get_exp(str(reference_root / "exps/default/yolox_s.py"), None).get_model().eval().cuda() - ) + experiment_name = "yolov3" if checkpoint_path.stem == "yolox_darknet" else checkpoint_path.stem + experiment = get_exp(str(reference_root / f"exps/default/{experiment_name}.py"), None) + reference = experiment.get_model().eval().cuda() + image_size = experiment.test_size[0] reference.load_state_dict( torch.load(checkpoint_path, map_location="cpu", weights_only=True)["model"], strict=True ) @@ -221,8 +228,8 @@ def test_official_checkpoint_e2e(case_name: str, tmp_path: Path) -> None: portrait = np.full((width * 2, width, 3), 114, dtype=np.uint8) portrait[:height] = original for label, image in (("landscape", original), ("portrait", portrait)): - official_pixels, ratio = preproc(image, (640, 640)) - native_pixels = _native_pixels(image, seam, tmp_path) + official_pixels, ratio = preproc(image, experiment.test_size) + native_pixels = _native_pixels(image, seam, tmp_path, image_size) # OpenCV's byte resize uses fixed-point rounding. No channel, padding, # scale or normalization error can fit within this one-byte bound. np.testing.assert_allclose(native_pixels, official_pixels, rtol=0, atol=1.0) @@ -242,7 +249,8 @@ def test_official_checkpoint_e2e(case_name: str, tmp_path: Path) -> None: ), axis=1, ) - assert actual_raw["boxes"].shape == (8400, 4) + prediction_count = sum((image_size // stride) ** 2 for stride in (8, 16, 32)) + assert actual_raw["boxes"].shape == (prediction_count, 4) assert all(np.isfinite(value).all() for value in actual_raw.values()) np.testing.assert_allclose(actual_raw["scores"], expected_scores, rtol=0, atol=0.01) foreground = (expected_scores >= 0.1) | (actual_raw["scores"] >= 0.1) diff --git a/families/yolox/tests/test_model.py b/families/yolox/tests/test_model.py index bfb26b646d..bf78094017 100644 --- a/families/yolox/tests/test_model.py +++ b/families/yolox/tests/test_model.py @@ -14,17 +14,49 @@ from families.yolox import graph from families.yolox.checkpoint import Checkpoint from families.yolox.model import _fold, build -from families.yolox.support import describe +from families.yolox.support import ARCHIVES, describe from tensorrt_model_connect import BuildRequest from tensorrt_model_connect.model_support import ModelMetadata -def test_exact_checkpoint_identity(): - assert describe(ModelMetadata(config={}, model_index={}, files=("yolox_s.pth",))) is not None - for name in ("yolox_m.pth", "yolov5n.pt", "yolox.pth"): +@pytest.mark.parametrize("name", ARCHIVES) +def test_exact_checkpoint_identity(name): + assert describe(ModelMetadata(config={}, model_index={}, files=(name,))) is not None + + +def test_other_checkpoint_names_are_not_claimed(): + for name in ("yolov5n.pt", "yolox.pth", "yolox_custom.pth"): assert describe(ModelMetadata(config={}, model_index={}, files=(name,))) is None +@pytest.mark.parametrize( + "name,size", + [ + ("nano", 416), + ("tiny", 416), + ("s", 640), + ("m", 640), + ("l", 640), + ("x", 640), + ("darknet", 640), + ], +) +def test_checkpoint_selection_and_published_image_size(tmp_path, name, size): + torch.save({"model": {"head.weight": torch.ones(1)}}, tmp_path / f"yolox_{name}.pth") + checkpoint = Checkpoint.open(tmp_path) + assert checkpoint.image_size == size + np.testing.assert_array_equal(checkpoint.tensor("head.weight"), [1.0]) + + +def test_checkpoint_selection_rejects_missing_or_ambiguous_archives(tmp_path): + with pytest.raises(ValueError, match="exactly one"): + Checkpoint.open(tmp_path) + for name in ("yolox_s.pth", "yolox_m.pth"): + torch.save({"model": {"head.weight": torch.ones(1)}}, tmp_path / name) + with pytest.raises(ValueError, match="exactly one"): + Checkpoint.open(tmp_path) + + @pytest.mark.parametrize( "field,value", [