18 Commits

Author SHA1 Message Date
Roman Meshko a70ec1834f Push model to docker registry
ci/woodpecker/manual/e2e-convert-jetson Pipeline failed
2026-05-04 21:48:01 +03:00
Roman Meshko fad7513369 Changed to not run step in parallel
ci/woodpecker/manual/e2e-convert-jetson Pipeline was successful
2026-05-04 12:04:05 +03:00
Roman Meshko 7d5b0aba6f Added step for model conversion
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was canceled
ci/woodpecker/manual/e2e-convert-jetson Pipeline was canceled
2026-05-03 20:27:54 +03:00
Roman Meshko c7d5d11f6a Changed smoke-test to wait AI conversion
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was canceled
2026-04-26 23:42:09 +03:00
Roman Meshko 73c9d57827 Changed to prepare onnx
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was successful
2026-04-26 23:14:08 +03:00
Roman Meshko 4ec9633902 Make Cython constans
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was successful
2026-04-26 22:39:48 +03:00
Roman Meshko 9abe08617b Make universal invocation
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was successful
2026-04-26 22:17:53 +03:00
Roman Meshko 096db0bf0c Changed to use NumPy 1.x for Jetson
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was successful
2026-04-26 21:47:25 +03:00
Roman Meshko 022142655e Added biuld TensorRT flag
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was canceled
2026-04-26 15:50:05 +03:00
Roman Meshko fbc8782602 Added rebuild
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was successful
2026-04-26 15:27:21 +03:00
Roman Meshko 6bc59bb61b Merge branch 'feature/run-jetson-e2e-tests' of https://github.com/azaion/detections into feature/run-jetson-e2e-tests
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was canceled
2026-04-26 13:54:51 +03:00
Roman Meshko 71d4b5309f Added rebuild 2026-04-26 13:53:45 +03:00
Roman Meshko d405c03055 Added files for e2e tests
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was successful
2026-04-26 10:34:53 +00:00
Roman Meshko 242fb11bbe Added rebuild
ci/woodpecker/manual/e2e-smoke-jetson Pipeline was successful
2026-04-25 20:26:17 +03:00
Roman Meshko e1cc76e616 Run tests
ci/woodpecker/manual/e2e-smoke-jetson Pipeline failed
2026-04-25 20:15:02 +03:00
Roman Meshko a7c055a373 Run tests
ci/woodpecker/manual/e2e-smoke-jetson Pipeline failed
2026-04-25 19:42:01 +03:00
Roman Meshko 7db192a967 Run tests 2026-04-25 18:59:58 +03:00
Roman Meshko 9f073293cc Run tests
ci/woodpecker/manual/e2e-smoke-jetson Pipeline failed
2026-04-25 18:48:35 +03:00
19 changed files with 294 additions and 28 deletions
+30
View File
@@ -0,0 +1,30 @@
when:
- event: [manual]
evaluate: 'E2E_CONVERT_JETSON == "1"'
labels:
platform: arm64
steps:
- name: e2e-convert-jetson
image: docker
environment:
REGISTRY_HOST:
from_secret: registry_host
REGISTRY_USER:
from_secret: registry_user
REGISTRY_TOKEN:
from_secret: registry_token
commands:
- apk add --no-cache bash docker-cli-compose
- echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
- cd e2e
- >
E2E_PROFILE=jetson
E2E_WAIT_FOR_ENGINE_ENABLED=1
E2E_ENGINE_WAIT_TIMEOUT=3600
E2E_LOG_TAIL=300
bash run_test.sh tests/test_health_engine.py::TestHealthEngineStep03Warmed
- bash scripts/publish_jetson_engine.sh
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+25
View File
@@ -0,0 +1,25 @@
when:
- event: [manual]
evaluate: 'E2E_CONVERT_JETSON != "1"'
labels:
platform: arm64
steps:
- name: e2e-smoke-jetson
image: docker
environment:
REGISTRY_HOST:
from_secret: registry_host
REGISTRY_USER:
from_secret: registry_user
REGISTRY_TOKEN:
from_secret: registry_token
commands:
- apk add --no-cache bash docker-cli-compose
- echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
- cd e2e
- bash scripts/pull_jetson_engine.sh
- E2E_PROFILE=jetson bash run_test.sh tests/test_health_engine.py
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+1 -1
View File
@@ -13,7 +13,7 @@ WORKDIR /app
COPY requirements.txt requirements-jetson.txt ./
RUN pip3 install --no-cache-dir -r requirements-jetson.txt
COPY . .
RUN python3 setup.py build_ext --inplace
RUN BUILD_TENSORRT_EXTENSIONS=1 python3 setup.py build_ext --inplace
ENV PYTHONPATH=/app/src
RUN adduser --disabled-password --no-create-home --gecos "" appuser \
&& chown -R appuser /app
+4 -5
View File
@@ -2,9 +2,9 @@ name: detections-e2e
services:
mock-loader:
build: ./mocks/loader
volumes:
- ./fixtures:/models
build:
context: .
dockerfile: mocks/loader/Dockerfile
networks:
- e2e-net
@@ -74,9 +74,7 @@ services:
JWT_SECRET: test-secret-e2e-only
CLASSES_JSON_PATH: /app/classes.json
volumes:
- ./fixtures/classes.json:/app/classes.json:ro
- ./fixtures:/media:ro
- ./logs:/app/Logs
shm_size: 512m
networks:
e2e-net:
@@ -94,6 +92,7 @@ services:
- mock-annotations
environment:
JWT_SECRET: test-secret-e2e-only
MEDIA_DIR: /app/fixtures
volumes:
- ./fixtures:/media
- ./results:/results
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
+2 -1
View File
@@ -1,6 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask gunicorn
COPY app.py .
COPY mocks/loader/app.py .
COPY fixtures /models
EXPOSE 8080
CMD ["gunicorn", "-b", "0.0.0.0:8080", "-w", "1", "--timeout", "120", "app:app"]
+13 -1
View File
@@ -31,6 +31,16 @@ def _resolve_disk_path(filename: str, folder: str | None) -> Path | None:
return None
def _write_disk_path(filename: str, folder: str | None, data: bytes) -> Path:
root = _models_root()
safe_filename = Path(filename).name
target_dir = root / folder if folder else root
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / safe_filename
target.write_bytes(data)
return target
def _should_fail_load() -> bool:
global _first_fail_remaining
if _mode == "error":
@@ -73,7 +83,9 @@ def upload(filename):
f = request.files.get("data")
if not f:
return "", 400
_uploads[(folder, filename)] = f.read()
data = f.read()
_uploads[(folder, filename)] = data
_write_disk_path(filename, folder, data)
_upload_count += 1
return "", 200
+1
View File
@@ -1,6 +1,7 @@
pytest
pytest-csv
requests==2.32.4
PyJWT==2.12.1
sseclient-py
pytest-timeout
flask
+12 -4
View File
@@ -19,6 +19,14 @@ case "$PROFILE" in
esac
COMPOSE="docker compose -f docker-compose.test.yml --profile $PROFILE"
LOG_TAIL="${E2E_LOG_TAIL:-100}"
RUNNER_ENV_ARGS=(-e E2E_PROFILE="$PROFILE")
if [[ "$PROFILE" == "jetson" ]]; then
RUNNER_ENV_ARGS+=(
-e E2E_WAIT_FOR_ENGINE_ENABLED="${E2E_WAIT_FOR_ENGINE_ENABLED:-0}"
-e E2E_ENGINE_WAIT_TIMEOUT="${E2E_ENGINE_WAIT_TIMEOUT:-900}"
)
fi
usage() {
echo "Usage: $0 <test_path> [pytest_args...]"
@@ -46,7 +54,7 @@ for i in $(seq 1 60); do
fi
if [[ "$i" == "60" ]]; then
echo "ERROR: detections service did not become healthy"
$COMPOSE logs "$DETECTIONS_SERVICE" --tail 100
$COMPOSE logs "$DETECTIONS_SERVICE" --tail "$LOG_TAIL"
exit 1
fi
sleep 2
@@ -54,11 +62,11 @@ done
echo "--- Running: pytest $* -v -x -s --csv=/results/report.csv"
set +e
$COMPOSE run --rm --no-deps e2e-runner pytest "$@" -v -x -s --csv=/results/report.csv
$COMPOSE run --rm --build --no-deps "${RUNNER_ENV_ARGS[@]}" e2e-runner pytest "$@" -v -x -s --csv=/results/report.csv
EXIT_CODE=$?
set -e
echo "--- Test finished with exit code $EXIT_CODE"
echo "--- Detections logs (last 100 lines):"
$COMPOSE logs "$DETECTIONS_SERVICE" --tail 100
echo "--- Detections logs (last $LOG_TAIL lines):"
$COMPOSE logs "$DETECTIONS_SERVICE" --tail "$LOG_TAIL"
exit $EXIT_CODE
+30
View File
@@ -1,4 +1,5 @@
import json
import os
import threading
import time
import uuid
@@ -7,6 +8,13 @@ import pytest
import sseclient
_DETECT_TIMEOUT = 60
_ENGINE_WAIT_TIMEOUT = int(os.environ.get("E2E_ENGINE_WAIT_TIMEOUT", "900"))
_WAIT_FOR_ENGINE_ENABLED = os.environ.get("E2E_WAIT_FOR_ENGINE_ENABLED", "").lower() in (
"1",
"true",
"yes",
"on",
)
def _get_health(http_client):
@@ -20,6 +28,24 @@ def _assert_active_ai(data):
assert data["aiAvailability"] not in ("None", "Downloading")
def _wait_for_engine_enabled(http_client):
deadline = time.time() + _ENGINE_WAIT_TIMEOUT
last = None
while time.time() < deadline:
last = _get_health(http_client)
availability = last.get("aiAvailability")
if availability == "Enabled":
assert last.get("errorMessage") is None
return last
if availability == "Error":
pytest.fail(f"engine conversion failed: {last.get('errorMessage')}")
time.sleep(3)
pytest.fail(
f"engine did not become Enabled within {_ENGINE_WAIT_TIMEOUT}s "
f"(last health: {last})"
)
@pytest.mark.cpu
class TestHealthEngineStep01PreInit:
def test_ft_p_01_pre_init_health(self, http_client):
@@ -92,7 +118,11 @@ class TestHealthEngineStep03Warmed:
def _warm(self, warm_engine):
pass
@pytest.mark.timeout(_ENGINE_WAIT_TIMEOUT + 30)
def test_ft_p_02_post_init_health(self, http_client):
if _WAIT_FOR_ENGINE_ENABLED:
data = _wait_for_engine_enabled(http_client)
else:
data = _get_health(http_client)
_assert_active_ai(data)
assert data.get("errorMessage") is None
+2 -1
View File
@@ -5,7 +5,8 @@ h11==0.16.0
python-multipart==0.0.22
Cython==3.2.4
opencv-python==4.10.0.84
numpy==2.2.6
numpy==1.26.4
onnx==1.17.0
pynvml==12.0.0
requests==2.32.4
loguru==0.7.3
+9 -2
View File
@@ -1,6 +1,7 @@
from setuptools import setup, Extension
from Cython.Build import cythonize
import numpy as np
import os
SRC = "src"
np_inc = [np.get_include(), SRC]
@@ -18,16 +19,22 @@ extensions = [
Extension('inference', [f'{SRC}/inference.pyx'], include_dirs=np_inc),
]
build_tensorrt = os.environ.get("BUILD_TENSORRT_EXTENSIONS", "").lower() in ("1", "true", "yes")
if not build_tensorrt:
try:
import tensorrt # pyright: ignore[reportMissingImports]
build_tensorrt = True
except ImportError:
build_tensorrt = False
if build_tensorrt:
extensions.append(
Extension('engines.tensorrt_engine', [f'{SRC}/engines/tensorrt_engine.pyx'], include_dirs=np_inc)
)
extensions.append(
Extension('engines.jetson_tensorrt_engine', [f'{SRC}/engines/jetson_tensorrt_engine.pyx'], include_dirs=np_inc)
)
except ImportError:
pass
setup(
name="azaion.detections",
+2 -2
View File
@@ -12,8 +12,8 @@ cdef str SPLIT_SUFFIX
cdef double TILE_DUPLICATE_CONFIDENCE_THRESHOLD
cdef int METERS_IN_TILE
cdef log(str log_message)
cdef logerror(str error)
cpdef log(str log_message)
cpdef logerror(str error)
cdef format_time(long ms)
cdef dict[int, AnnotationClass] annotations_dict
+2 -2
View File
@@ -78,10 +78,10 @@ def get_annotation_name(int cls_id):
return (<AnnotationClass>annotations_dict[cls_id]).name
return ""
cdef log(str log_message):
cpdef log(str log_message):
logger.info(log_message)
cdef logerror(str error):
cpdef logerror(str error):
logger.error(error)
cdef format_time(long ms):
+22 -2
View File
@@ -44,6 +44,10 @@ class EngineFactory:
def build_and_cache(self, bytes source_bytes, LoaderHttpClient loader_client, str models_dir):
cdef LoadResult res
engine_bytes, engine_filename = self.build_from_source(source_bytes, loader_client, models_dir)
if engine_bytes is None:
raise RuntimeError("TensorRT conversion failed: no engine bytes produced")
if engine_filename is None:
raise RuntimeError("TensorRT conversion failed: engine filename could not be resolved")
res = loader_client.upload_big_small_resource(engine_bytes, engine_filename, models_dir)
if res.err is not None:
constants_inf.log(f"Failed to upload converted model: {res.err}")
@@ -93,6 +97,22 @@ class JetsonTensorRTEngineFactory(TensorRTEngineFactory):
from engines.jetson_tensorrt_engine import JetsonTensorRTEngine
return JetsonTensorRTEngine(model_bytes)
def load_engine(self, LoaderHttpClient loader_client, str models_dir):
cdef str filename
cdef LoadResult res
from engines.tensorrt_engine import TensorRTEngine
for precision in ("int8", "fp16"):
filename = TensorRTEngine.get_engine_filename(precision)
if filename is None:
continue
try:
res = loader_client.load_big_small_resource(filename, models_dir)
if res.err is None:
return self.create(res.data)
except Exception:
pass
return None
def _get_ai_engine_filename(self):
from engines.tensorrt_engine import TensorRTEngine
return TensorRTEngine.get_engine_filename("int8")
@@ -100,5 +120,5 @@ class JetsonTensorRTEngineFactory(TensorRTEngineFactory):
def build_from_source(self, onnx_bytes, LoaderHttpClient loader_client, str models_dir):
from engines.jetson_tensorrt_engine import JetsonTensorRTEngine
from engines.tensorrt_engine import TensorRTEngine
engine_bytes = JetsonTensorRTEngine.convert_from_source(onnx_bytes, loader_client, models_dir)
return engine_bytes, TensorRTEngine.get_engine_filename("int8")
engine_bytes, precision = JetsonTensorRTEngine.convert_from_source_with_precision(onnx_bytes, loader_client, models_dir)
return engine_bytes, TensorRTEngine.get_engine_filename(precision)
+11 -2
View File
@@ -1,5 +1,6 @@
import os
import tempfile
cimport constants_inf
from engines.tensorrt_engine cimport TensorRTEngine
from loader_http_client cimport LoaderHttpClient, LoadResult
@@ -7,10 +8,19 @@ from loader_http_client cimport LoaderHttpClient, LoadResult
cdef class JetsonTensorRTEngine(TensorRTEngine):
@staticmethod
def convert_from_source(bytes onnx_model, LoaderHttpClient loader_client, str models_dir):
engine_bytes, precision = JetsonTensorRTEngine.convert_from_source_with_precision(
onnx_model, loader_client, models_dir
)
return engine_bytes
@staticmethod
def convert_from_source_with_precision(bytes onnx_model, LoaderHttpClient loader_client, str models_dir):
cdef str calib_cache_path
calib_cache_path = JetsonTensorRTEngine._download_calib_cache(loader_client, models_dir)
try:
return TensorRTEngine.convert_from_source(onnx_model, calib_cache_path)
engine_bytes = TensorRTEngine.convert_from_source(onnx_model, calib_cache_path, True)
precision = "int8" if calib_cache_path is not None else "fp16"
return engine_bytes, precision
finally:
if calib_cache_path is not None:
try:
@@ -21,7 +31,6 @@ cdef class JetsonTensorRTEngine(TensorRTEngine):
@staticmethod
def _download_calib_cache(LoaderHttpClient loader_client, str models_dir):
cdef LoadResult res
import constants_inf
try:
res = loader_client.load_big_small_resource(
constants_inf.INT8_CALIB_CACHE_FILE, models_dir
+111
View File
@@ -0,0 +1,111 @@
import ast
import io
import onnx
from onnx import helper, numpy_helper
_REDUCE_OPS_WITH_AXES_INPUT = {
"ReduceL1",
"ReduceL2",
"ReduceLogSum",
"ReduceLogSumExp",
"ReduceMax",
"ReduceMean",
"ReduceMin",
"ReduceProd",
"ReduceSum",
"ReduceSumSquare",
}
def _metadata(model):
return {p.key: p.value for p in model.metadata_props}
def _input_size(model):
try:
imgsz = _metadata(model).get("imgsz")
parsed = ast.literal_eval(imgsz)
if isinstance(parsed, (list, tuple)) and len(parsed) == 2:
h, w = int(parsed[0]), int(parsed[1])
if h > 0 and w > 0:
return h, w
except Exception:
pass
return 1280, 1280
def _constant_values(graph):
values = {init.name: numpy_helper.to_array(init) for init in graph.initializer}
for node in graph.node:
if node.op_type != "Constant" or not node.output:
continue
for attr in node.attribute:
if attr.name == "value":
values[node.output[0]] = numpy_helper.to_array(attr.t)
break
return values
def _as_int_list(value):
if value is None:
return None
if getattr(value, "shape", ()) == ():
return [int(value)]
return [int(v) for v in value.reshape(-1).tolist()]
def _set_static_input_shape(model, batch=1):
h, w = _input_size(model)
for graph_input in model.graph.input:
tensor_type = graph_input.type.tensor_type
if tensor_type.elem_type != onnx.TensorProto.FLOAT:
continue
dims = tensor_type.shape.dim
if len(dims) != 4:
continue
for dim, value in zip(dims, (batch, 3, h, w)):
dim.dim_value = value
return True
return False
def _rewrite_reduce_axes_inputs(model):
constants = _constant_values(model.graph)
changed = False
for node in model.graph.node:
if node.op_type not in _REDUCE_OPS_WITH_AXES_INPUT or len(node.input) < 2:
continue
axes = _as_int_list(constants.get(node.input[1]))
if axes is None:
continue
kept_attrs = [attr for attr in node.attribute if attr.name != "axes"]
del node.attribute[:]
node.attribute.extend(kept_attrs)
node.attribute.extend([helper.make_attribute("axes", axes)])
del node.input[1:]
changed = True
return changed
def _cap_default_opset(model, max_opset=17):
for opset in model.opset_import:
if opset.domain in ("", "ai.onnx") and opset.version > max_opset:
opset.version = max_opset
return True
return False
def prepare_for_tensorrt(model_bytes):
model = onnx.load_model_from_string(model_bytes)
changed = False
changed = _set_static_input_shape(model) or changed
changed = _rewrite_reduce_axes_inputs(model) or changed
changed = _cap_default_opset(model) or changed
if not changed:
return model_bytes
buffer = io.BytesIO()
onnx.save_model(model, buffer)
return buffer.getvalue()
+14 -2
View File
@@ -114,13 +114,21 @@ cdef class TensorRTEngine(InferenceEngine):
return None
@staticmethod
def convert_from_source(bytes onnx_model, str calib_cache_path=None):
def convert_from_source(bytes onnx_model, str calib_cache_path=None, bint force_static_input=False):
gpu_mem = TensorRTEngine.get_gpu_memory_bytes(0)
workspace_bytes = int(gpu_mem * 0.9)
explicit_batch_flag = 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
trt_logger = trt.Logger(trt.Logger.WARNING)
if force_static_input:
try:
from engines.onnx_tensorrt_compat import prepare_for_tensorrt
onnx_model = prepare_for_tensorrt(onnx_model)
constants_inf.log(<str>'Prepared ONNX model for TensorRT static Jetson build')
except Exception as e:
constants_inf.logerror(<str>f'ONNX TensorRT compatibility preparation failed: {str(e)}')
with trt.Builder(trt_logger) as builder, \
builder.create_network(explicit_batch_flag) as network, \
trt.OnnxParser(network, trt_logger) as parser, \
@@ -129,6 +137,8 @@ cdef class TensorRTEngine(InferenceEngine):
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, workspace_bytes)
if not parser.parse(onnx_model):
for i in range(parser.num_errors):
constants_inf.logerror(<str>f'TensorRT ONNX parser error: {parser.get_error(i)}')
return None
input_tensor = network.get_input(0)
@@ -137,7 +147,9 @@ cdef class TensorRTEngine(InferenceEngine):
H = max(shape[2], 1280) if shape[2] != -1 else 1280
W = max(shape[3], 1280) if shape[3] != -1 else 1280
if shape[0] == -1:
if force_static_input:
input_tensor.shape = (1, C, H, W)
elif shape[0] == -1 or shape[2] == -1 or shape[3] == -1:
max_batch = TensorRTEngine.calculate_max_batch_size(gpu_mem, H, W)
profile = builder.create_optimization_profile()
profile.set_shape(