mirror of
https://github.com/azaion/detections.git
synced 2026-06-22 23:31:08 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a70ec1834f | |||
| fad7513369 | |||
| 7d5b0aba6f | |||
| c7d5d11f6a | |||
| 73c9d57827 | |||
| 4ec9633902 | |||
| 9abe08617b | |||
| 096db0bf0c | |||
| 022142655e | |||
| fbc8782602 | |||
| 6bc59bb61b | |||
| 71d4b5309f | |||
| d405c03055 | |||
| 242fb11bbe | |||
| e1cc76e616 | |||
| a7c055a373 | |||
| 7db192a967 | |||
| 9f073293cc | |||
| b6516274f6 | |||
| 36f90732a9 | |||
| bd83552109 | |||
| 4b006678b3 | |||
| 3346091a96 | |||
| 911da5cb1c | |||
| 00164d9e54 | |||
| 5cfcdb5fd5 |
@@ -0,0 +1,39 @@
|
|||||||
|
# Manual-trigger only. Validation fixtures (sample videos + expected-detections
|
||||||
|
# baseline) are not yet authored; running the e2e on push would produce red
|
||||||
|
# builds until that data lands. Flip `event: [manual]` back to
|
||||||
|
# `event: [push, pull_request, manual]` once validation fixtures are in place,
|
||||||
|
# and add `depends_on: [01-test]` to 02-build-push.yml.
|
||||||
|
#
|
||||||
|
# Tracking: see plan "CI Auto Tests Epic", story S3b — blocked by e2e validation data.
|
||||||
|
|
||||||
|
when:
|
||||||
|
event: [manual]
|
||||||
|
branch: [dev, stage, main]
|
||||||
|
|
||||||
|
labels:
|
||||||
|
platform: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: e2e
|
||||||
|
image: docker:24
|
||||||
|
environment:
|
||||||
|
COMPOSE_PROJECT_NAME: detections-e2e
|
||||||
|
commands:
|
||||||
|
- cd e2e
|
||||||
|
- mkdir -p results logs
|
||||||
|
- docker compose -f docker-compose.test.yml --profile cpu up --build --abort-on-container-exit --exit-code-from e2e-runner
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
- name: e2e-report
|
||||||
|
image: docker:24
|
||||||
|
when:
|
||||||
|
status: [success, failure]
|
||||||
|
environment:
|
||||||
|
COMPOSE_PROJECT_NAME: detections-e2e
|
||||||
|
commands:
|
||||||
|
- cd e2e
|
||||||
|
- docker compose -f docker-compose.test.yml --profile cpu down -v || true
|
||||||
|
- test -f results/report.csv && cat results/report.csv || echo "no report"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
@@ -1,9 +1,27 @@
|
|||||||
|
# No `depends_on: [01-test]` clause yet — the test workflow is manual-only
|
||||||
|
# while validation fixtures are being authored (see 01-test.yml header comment).
|
||||||
|
# When 01-test flips to push/pull_request triggers, add `depends_on: [01-test]`
|
||||||
|
# here so builds are gated on a green test run.
|
||||||
|
|
||||||
when:
|
when:
|
||||||
event: [push, manual]
|
event: [push, manual]
|
||||||
branch: [dev, stage, main]
|
branch: [dev, stage, main]
|
||||||
|
|
||||||
|
# Multi-arch matrix. detections is the only repo today with split per-arch
|
||||||
|
# Dockerfiles (Jetson uses Dockerfile.jetson with TensorRT/CUDA on L4T;
|
||||||
|
# amd64 uses the plain Dockerfile). Adding amd64 = uncommenting the second
|
||||||
|
# entry once an amd64 agent and base image are in place.
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- PLATFORM: arm64
|
||||||
|
TAG_SUFFIX: arm
|
||||||
|
DOCKERFILE: Dockerfile.jetson
|
||||||
|
# - PLATFORM: amd64
|
||||||
|
# TAG_SUFFIX: amd
|
||||||
|
# DOCKERFILE: Dockerfile
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
platform: arm64
|
platform: ${PLATFORM}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-push
|
- name: build-push
|
||||||
@@ -17,10 +35,10 @@ steps:
|
|||||||
from_secret: registry_token
|
from_secret: registry_token
|
||||||
commands:
|
commands:
|
||||||
- echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
|
- echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
|
||||||
- export TAG=${CI_COMMIT_BRANCH}-arm
|
- export TAG=${CI_COMMIT_BRANCH}-${TAG_SUFFIX}
|
||||||
- export BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
- export BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
- |
|
- |
|
||||||
docker build -f Dockerfile.jetson \
|
docker build -f ${DOCKERFILE} \
|
||||||
--build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA \
|
--build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA \
|
||||||
--label org.opencontainers.image.revision=$CI_COMMIT_SHA \
|
--label org.opencontainers.image.revision=$CI_COMMIT_SHA \
|
||||||
--label org.opencontainers.image.created=$BUILD_DATE \
|
--label org.opencontainers.image.created=$BUILD_DATE \
|
||||||
@@ -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
|
||||||
@@ -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
@@ -13,7 +13,7 @@ WORKDIR /app
|
|||||||
COPY requirements.txt requirements-jetson.txt ./
|
COPY requirements.txt requirements-jetson.txt ./
|
||||||
RUN pip3 install --no-cache-dir -r requirements-jetson.txt
|
RUN pip3 install --no-cache-dir -r requirements-jetson.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN python3 setup.py build_ext --inplace
|
RUN BUILD_TENSORRT_EXTENSIONS=1 python3 setup.py build_ext --inplace
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
RUN adduser --disabled-password --no-create-home --gecos "" appuser \
|
RUN adduser --disabled-password --no-create-home --gecos "" appuser \
|
||||||
&& chown -R appuser /app
|
&& chown -R appuser /app
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- Detections with confidence below `probability_threshold` (default: 0.25) are filtered out.
|
- Detections with confidence below `probability_threshold` (default: 0.25) are filtered out.
|
||||||
- Overlapping detections with containment ratio > `tracking_intersection_threshold` (default: 0.6) are deduplicated, keeping the higher-confidence detection.
|
- Overlapping detections with containment ratio > `tracking_intersection_threshold` (default: 0.6) are deduplicated, keeping the higher-confidence detection.
|
||||||
- Tile duplicate detections are identified when all bounding box coordinates differ by less than 0.01 (TILE_DUPLICATE_CONFIDENCE_THRESHOLD).
|
- Tile duplicate detections are identified when all bounding box coordinates differ by less than 0.01 (TILE_DUPLICATE_CONFIDENCE_THRESHOLD).
|
||||||
- Physical size filtering: detections exceeding `max_object_size_meters` for their class (defined in classes.json, range 2–20 meters) are removed.
|
- Physical size filtering: detections exceeding `max_object_size_meters` for their class (defined in classes.json, range 2–20 meters) are removed when ground sampling distance can be computed from camera settings.
|
||||||
|
|
||||||
## Video Processing
|
## Video Processing
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
- Images ≤ 1.5× model dimensions (1280×1280): processed as single frame.
|
- Images ≤ 1.5× model dimensions (1280×1280): processed as single frame.
|
||||||
- Larger images: tiled based on ground sampling distance. Tile physical size: 25 meters (METERS_IN_TILE). Tile overlap: `big_image_tile_overlap_percent` (default: 20%).
|
- Larger images: tiled based on ground sampling distance. Tile physical size: 25 meters (METERS_IN_TILE). Tile overlap: `big_image_tile_overlap_percent` (default: 20%).
|
||||||
- GSD calculation: `sensor_width * altitude / (focal_length * image_width)`.
|
- GSD calculation: `sensor_width * altitude / (focal_length * image_width)` when `altitude` is provided.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Media path is resolved from the Annotations service via `GET /api/media/{media_i
|
|||||||
| tracking_intersection_threshold | float | 0.6 | Overlap ratio for NMS deduplication |
|
| tracking_intersection_threshold | float | 0.6 | Overlap ratio for NMS deduplication |
|
||||||
| model_batch_size | int | 8 | Inference batch size |
|
| model_batch_size | int | 8 | Inference batch size |
|
||||||
| big_image_tile_overlap_percent | int | 20 | Tile overlap for large images (0-100%) |
|
| big_image_tile_overlap_percent | int | 20 | Tile overlap for large images (0-100%) |
|
||||||
| altitude | float | 400 | Camera altitude in meters |
|
| altitude | float | optional | Camera altitude in meters. When omitted, GSD-based size filtering and image tiling are skipped. |
|
||||||
| focal_length | float | 24 | Camera focal length in mm |
|
| focal_length | float | 24 | Camera focal length in mm |
|
||||||
| sensor_width | float | 23.5 | Camera sensor width in mm |
|
| sensor_width | float | 23.5 | Camera sensor width in mm |
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
center_x,center_y,width,height,label,confidence_min
|
center_x,center_y,width,height,label,confidence_min
|
||||||
|
0.104737,0.375955,0.154999,0.144996,Truck,0.73
|
||||||
|
0.576021,0.728599,0.1495,0.142923,Truck,0.83
|
||||||
|
|||||||
|
@@ -1 +1,4 @@
|
|||||||
center_x,center_y,width,height,label,confidence_min
|
center_x,center_y,width,height,label,confidence_min
|
||||||
|
0.214701,0.770089,0.274945,0.28434,ArmorVehicle,0.73
|
||||||
|
0.588816,0.581807,0.269273,0.209632,ArmorVehicle,0.49
|
||||||
|
0.314031,0.56274,0.039824,0.060367,MilitaryMan,0.32
|
||||||
|
|||||||
|
@@ -1 +1,2 @@
|
|||||||
center_x,center_y,width,height,label,confidence_min
|
center_x,center_y,width,height,label,confidence_min
|
||||||
|
0.465599,0.20807,0.137469,0.194655,ArmorVehicle,0.77
|
||||||
|
|||||||
|
@@ -37,21 +37,18 @@ For videos, the additional field:
|
|||||||
### Images
|
### Images
|
||||||
|
|
||||||
| # | Input File | Description | Expected Result File | Expected Detection Count | Notes |
|
| # | Input File | Description | Expected Result File | Expected Detection Count | Notes |
|
||||||
|---|------------|-------------|---------------------|-------------------------|-------|
|
|---|------------|-------------|---------------------|---|-------|
|
||||||
| 1 | `image_small.jpg` | 1280×720 aerial, contains detectable objects | `image_small_expected.csv` | ? | Primary test image for single-frame detection |
|
| 1 | `image_small.jpg`| 1280×720 aerial, contains detectable objects| `image_small_expected.csv`| 1 | Primary test image for single-frame detection|
|
||||||
| 2 | `image_large.JPG` | 6252×4168 aerial, triggers GSD-based tiling | `image_large_expected.csv` | ? | Coordinates normalized to full image (not tile) |
|
| 2 | `image_large.JPG`| 6252×4168 aerial, triggers GSD-based tiling| `image_large_expected.csv`| 3 | Coordinates normalized to full image (not tile)|
|
||||||
| 3 | `image_dense01.jpg` | 1280×720 dense scene, many clustered objects | `image_dense01_expected.csv` | ? | Used for dedup and max-detection-cap tests |
|
| 3 | `image_different_types.jpg`| 900×1600, varied object classes| `image_different_types_expected.csv`| 2 | Must contain multiple distinct class labels|
|
||||||
| 4 | `image_dense02.jpg` | 1920×1080 dense scene variant | `image_dense02_expected.csv` | ? | Borderline tiling, dedup variant |
|
| 4 | `image_empty_scene.jpg`| 1920×1080, no detectable objects| `image_empty_scene_expected.csv`| 0 | CSV has headers only — zero detections expected|
|
||||||
| 5 | `image_different_types.jpg` | 900×1600, varied object classes | `image_different_types_expected.csv` | ? | Must contain multiple distinct class labels |
|
|
||||||
| 6 | `image_empty_scene.jpg` | 1920×1080, no detectable objects | `image_empty_scene_expected.csv` | 0 | CSV has headers only — zero detections expected |
|
|
||||||
|
|
||||||
### Videos
|
### Videos
|
||||||
|
|
||||||
| # | Input File | Description | Expected Result File | Notes |
|
| # | Input File | Description | Expected Result File | Notes |
|
||||||
|---|------------|-------------|---------------------|-------|
|
|---|------------|-------------|---------------------|-------|
|
||||||
| 7 | `video_test01.mp4` | Standard test video | `video_test01_expected.csv` | Primary async/SSE/video test. List key-frame detections. |
|
| 5 | `video_test01.mp4` | Standard test video | `video_test01_expected.csv` | Primary async/SSE/video test. List key-frame detections. |
|
||||||
| 8 | `video_1.mp4` | Video variant | `video_1_expected.csv` | Secondary local fixture for resilience and concurrent-style validation. |
|
| 6 | `video_1_faststart.mp4` | Faststart video variant | `video_1_faststart_expected.csv` | Streaming compatibility variant. Separate long-video overflow fixture is not currently present in local fixtures. |
|
||||||
| 9 | `video_1_faststart.mp4` | Faststart video variant | `video_1_faststart_expected.csv` | Streaming compatibility variant. Separate long-video overflow fixture is not currently present in local fixtures. |
|
|
||||||
|
|
||||||
## How to Fill
|
## How to Fill
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,81 @@
|
|||||||
time_sec,center_x,center_y,width,height,label,confidence_min
|
time_sec,center_x,center_y,width,height,label,confidence_min
|
||||||
|
0,0.289802,0.512695,0.077206,0.072666,ArmorVehicle,0.89
|
||||||
|
2,0.653329,0.376178,0.078665,0.081241,ArmorVehicle,0.89
|
||||||
|
4,0.197519,0.416036,0.067097,0.083232,ArmorVehicle,0.83
|
||||||
|
6,0.276864,0.60931,0.060284,0.066828,ArmorVehicle,0.8
|
||||||
|
8,0.379446,0.434878,0.071124,0.077504,ArmorVehicle,0.55
|
||||||
|
10,0.753483,0.198423,0.057173,0.070441,ArmorVehicle,0.72
|
||||||
|
14,0.956159,0.68494,0.075992,0.034521,Trenches,0.62
|
||||||
|
30,0.343423,0.763509,0.098184,0.079214,TyreTracks,0.82
|
||||||
|
32,0.438461,0.687812,0.09794,0.07654,TyreTracks,0.85
|
||||||
|
41,0.700711,0.590761,0.10644,0.088566,TyreTracks,0.74
|
||||||
|
47,0.830646,0.762364,0.108269,0.091804,TyreTracks,0.37
|
||||||
|
47,0.698529,0.00825,0.022697,0.01672,Vehicle,0.34
|
||||||
|
49,0.349817,0.663337,0.075551,0.061795,TyreTracks,0.77
|
||||||
|
67,0.846456,0.527966,0.03052,0.058466,Trenches,0.32
|
||||||
|
69,0.68478,0.514788,0.025761,0.053171,Trenches,0.46
|
||||||
|
71,0.368416,0.460942,0.030577,0.050027,Trenches,0.78
|
||||||
|
73,0.177122,0.455578,0.031931,0.048597,Trenches,0.36
|
||||||
|
87,0.128406,0.138269,0.040148,0.032909,Smoke,0.56
|
||||||
|
90,0.118965,0.089119,0.037023,0.033037,Smoke,0.32
|
||||||
|
95,0.570426,0.373717,0.031503,0.021361,Trenches,0.48
|
||||||
|
108,0.621403,0.45839,0.026638,0.037245,Smoke,0.58
|
||||||
|
110,0.637573,0.465766,0.027141,0.040499,Smoke,0.58
|
||||||
|
112,0.610838,0.480663,0.0283,0.041891,Smoke,0.72
|
||||||
|
114,0.60922,0.486313,0.026929,0.042859,Smoke,0.68
|
||||||
|
116,0.615395,0.492105,0.028959,0.041361,Smoke,0.67
|
||||||
|
118,0.612622,0.49208,0.027159,0.039772,Smoke,0.61
|
||||||
|
120,0.488272,0.653743,0.028969,0.045788,Smoke,0.67
|
||||||
|
122,0.276008,0.692814,0.061306,0.10955,Smoke,0.74
|
||||||
|
127,0.5029,0.24006,0.116369,0.208533,Smoke,0.74
|
||||||
|
130,0.350402,0.278989,0.122529,0.179202,Smoke,0.7
|
||||||
|
132,0.525031,0.469289,0.103297,0.193989,Smoke,0.48
|
||||||
|
134,0.475175,0.52287,0.099367,0.178105,Smoke,0.27
|
||||||
|
136,0.042595,0.458038,0.073769,0.201478,Smoke,0.72
|
||||||
|
138,0.398975,0.280201,0.132485,0.076752,Smoke,0.3
|
||||||
|
149,0.115298,0.585385,0.037456,0.030006,Trenches,0.64
|
||||||
|
149,0.149621,0.602923,0.039252,0.032263,Trenches,0.73
|
||||||
|
149,0.534584,0.082995,0.03861,0.036615,Trenches,0.51
|
||||||
|
151,0.363107,0.353294,0.036212,0.030509,Trenches,0.52
|
||||||
|
151,0.395121,0.377565,0.03571,0.034257,Trenches,0.52
|
||||||
|
153,0.540678,0.549795,0.04493,0.040794,Trenches,0.55
|
||||||
|
155,0.124097,0.466852,0.074959,0.051802,Trenches,0.32
|
||||||
|
163,0.687639,0.261656,0.116238,0.125195,TyreTracks,0.43
|
||||||
|
168,0.378062,0.769232,0.032526,0.027289,Trenches,0.46
|
||||||
|
174,0.435846,0.904734,0.03305,0.03245,Trenches,0.27
|
||||||
|
176,0.942677,0.589411,0.024974,0.052446,Trenches,0.33
|
||||||
|
181,0.612998,0.232146,0.08565,0.060314,Smoke,0.52
|
||||||
|
191,0.390291,0.036179,0.054409,0.033613,Trenches,0.39
|
||||||
|
193,0.413883,0.208317,0.053158,0.031181,Trenches,0.76
|
||||||
|
193,0.430828,0.171384,0.030674,0.029638,Trenches,0.27
|
||||||
|
193,0.484856,0.182248,0.04184,0.063353,Trenches,0.65
|
||||||
|
193,0.491091,0.316695,0.031371,0.024474,Trenches,0.37
|
||||||
|
195,0.556304,0.101112,0.046918,0.036026,Smoke,0.71
|
||||||
|
197,0.519331,0.120967,0.044832,0.039692,Smoke,0.67
|
||||||
|
199,0.176271,0.118214,0.047294,0.053538,Smoke,0.63
|
||||||
|
202,0.171963,0.138687,0.043301,0.04702,Smoke,0.46
|
||||||
|
214,0.413986,0.12009,0.050383,0.046863,Smoke,0.5
|
||||||
|
219,0.364435,0.552301,0.028486,0.0314,ArmorVehicle,0.72
|
||||||
|
240,0.502753,0.535129,0.036657,0.033246,Trenches,0.32
|
||||||
|
242,0.019098,0.607661,0.038072,0.024671,Trenches,0.27
|
||||||
|
242,0.02406,0.680742,0.044204,0.023859,Trenches,0.37
|
||||||
|
242,0.027672,0.720891,0.033973,0.04163,Trenches,0.65
|
||||||
|
242,0.041207,0.628713,0.021846,0.044712,Trenches,0.29
|
||||||
|
242,0.053195,0.682519,0.027647,0.038862,Trenches,0.65
|
||||||
|
242,0.84993,0.631653,0.046269,0.042584,Trenches,0.47
|
||||||
|
244,0.017405,0.7017,0.034866,0.023287,Trenches,0.35
|
||||||
|
244,0.018653,0.733875,0.03717,0.025044,Trenches,0.48
|
||||||
|
244,0.032765,0.621231,0.064777,0.023041,Trenches,0.45
|
||||||
|
244,0.05303,0.734179,0.034922,0.041688,Trenches,0.7
|
||||||
|
244,0.070526,0.649712,0.023196,0.061101,Trenches,0.44
|
||||||
|
244,0.076041,0.69735,0.028326,0.039375,Trenches,0.65
|
||||||
|
246,0.025853,0.893559,0.051857,0.02183,Trenches,0.33
|
||||||
|
246,0.055409,0.908165,0.020511,0.040618,Trenches,0.32
|
||||||
|
252,0.735668,0.309579,0.026179,0.044193,Trenches,0.61
|
||||||
|
252,0.760952,0.371567,0.026674,0.062061,Trenches,0.52
|
||||||
|
282,0.566367,0.010329,0.036472,0.020469,Smoke,0.49
|
||||||
|
284,0.341962,0.011659,0.03561,0.023243,Smoke,0.59
|
||||||
|
287,0.423856,0.023553,0.03954,0.044283,Smoke,0.42
|
||||||
|
290,0.397077,0.010032,0.037649,0.019987,Smoke,0.29
|
||||||
|
292,0.415613,0.011793,0.036875,0.023537,Smoke,0.37
|
||||||
|
294,0.385953,0.009416,0.037843,0.018655,Smoke,0.39
|
||||||
|
|||||||
|
@@ -1 +1,5 @@
|
|||||||
time_sec,center_x,center_y,width,height,label,confidence_min
|
time_sec,center_x,center_y,width,height,label,confidence_min
|
||||||
|
0,0.289857,0.512138,0.07729,0.072891,ArmorVehicle,0.89
|
||||||
|
2,0.653617,0.376064,0.078636,0.08118,ArmorVehicle,0.89
|
||||||
|
4,0.197561,0.416208,0.068112,0.084079,ArmorVehicle,0.85
|
||||||
|
6,0.27662,0.609538,0.059521,0.067212,ArmorVehicle,0.8
|
||||||
|
|||||||
|
@@ -20,7 +20,7 @@ Data class holding all AI recognition configuration parameters, with factory met
|
|||||||
| `tracking_intersection_threshold` | double | 0.6 | IoU threshold for overlapping detection removal |
|
| `tracking_intersection_threshold` | double | 0.6 | IoU threshold for overlapping detection removal |
|
||||||
| `model_batch_size` | int | 1 | Batch size for inference |
|
| `model_batch_size` | int | 1 | Batch size for inference |
|
||||||
| `big_image_tile_overlap_percent` | int | 20 | Tile overlap percentage for large image splitting |
|
| `big_image_tile_overlap_percent` | int | 20 | Tile overlap percentage for large image splitting |
|
||||||
| `altitude` | double | 400 | Camera altitude in meters |
|
| `altitude` | double? | optional | Camera altitude in meters. When missing, GSD-based filtering is disabled |
|
||||||
| `focal_length` | double | 24 | Camera focal length in mm |
|
| `focal_length` | double | 24 | Camera focal length in mm |
|
||||||
| `sensor_width` | double | 23.5 | Camera sensor width in mm |
|
| `sensor_width` | double | 23.5 | Camera sensor width in mm |
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ Data class holding all AI recognition configuration parameters, with factory met
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Camera/altitude parameters (`altitude`, `focal_length`, `sensor_width`) are used for ground sampling distance calculation in aerial image processing.
|
Camera/altitude parameters (`altitude`, `focal_length`, `sensor_width`) are used for ground sampling distance calculation in aerial image processing. If `altitude` is missing, the service skips GSD-based size filtering and does not tile large images by physical size.
|
||||||
|
|
||||||
## External Integrations
|
## External Integrations
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,9 @@
|
|||||||
| classes-json | `classes.json` (repo root) | 19 detection classes with Id, Name, Color, MaxSizeM | All tests | Volume mount to detections `/app/classes.json` | Container restart |
|
| classes-json | `classes.json` (repo root) | 19 detection classes with Id, Name, Color, MaxSizeM | All tests | Volume mount to detections `/app/classes.json` | Container restart |
|
||||||
| image-small | `input_data/image_small.jpg` | JPEG 1280×720 — below tiling threshold (1920×1920) | FT-P-01..03, 05, 07, 13..15, FT-N-03, 06, NFT-PERF-01..02, NFT-RES-01, 03, NFT-SEC-01, NFT-RES-LIM-01 | Volume mount to consumer `/media/` | N/A (read-only) |
|
| image-small | `input_data/image_small.jpg` | JPEG 1280×720 — below tiling threshold (1920×1920) | FT-P-01..03, 05, 07, 13..15, FT-N-03, 06, NFT-PERF-01..02, NFT-RES-01, 03, NFT-SEC-01, NFT-RES-LIM-01 | Volume mount to consumer `/media/` | N/A (read-only) |
|
||||||
| image-large | `input_data/image_large.JPG` | JPEG 6252×4168 — above tiling threshold, triggers GSD tiling | FT-P-04, 16, NFT-PERF-03 | Volume mount to consumer `/media/` | N/A (read-only) |
|
| image-large | `input_data/image_large.JPG` | JPEG 6252×4168 — above tiling threshold, triggers GSD tiling | FT-P-04, 16, NFT-PERF-03 | Volume mount to consumer `/media/` | N/A (read-only) |
|
||||||
| image-dense-01 | `input_data/image_dense01.jpg` | JPEG 1280×720 — dense scene with many clustered objects | FT-P-06, NFT-RES-LIM-03 | Volume mount to consumer `/media/` | N/A (read-only) |
|
|
||||||
| image-dense-02 | `input_data/image_dense02.jpg` | JPEG 1920×1080 — dense scene variant, borderline tiling | FT-P-06 (variant) | Volume mount to consumer `/media/` | N/A (read-only) |
|
|
||||||
| image-different-types | `input_data/image_different_types.jpg` | JPEG 900×1600 — varied object classes for class variant tests | FT-P-13 | Volume mount to consumer `/media/` | N/A (read-only) |
|
| image-different-types | `input_data/image_different_types.jpg` | JPEG 900×1600 — varied object classes for class variant tests | FT-P-13 | Volume mount to consumer `/media/` | N/A (read-only) |
|
||||||
| image-empty-scene | `input_data/image_empty_scene.jpg` | JPEG 1920×1080 — clean scene with no detectable objects | Edge case (zero detections) | Volume mount to consumer `/media/` | N/A (read-only) |
|
| image-empty-scene | `input_data/image_empty_scene.jpg` | JPEG 1920×1080 — clean scene with no detectable objects | Edge case (zero detections) | Volume mount to consumer `/media/` | N/A (read-only) |
|
||||||
| video-test-01 | `input_data/video_test01.mp4` | MP4 video — standard async/SSE/video detection tests | FT-P-08..12, FT-N-04, 07, NFT-PERF-04, NFT-RES-02, NFT-SEC-03 | Volume mount to consumer `/media/` | N/A (read-only) |
|
| video-test-01 | `input_data/video_test01.mp4` | MP4 video — standard async/SSE/video detection tests | FT-P-08..12, FT-N-04, 07, NFT-PERF-04, NFT-RES-02, NFT-SEC-03 | Volume mount to consumer `/media/` | N/A (read-only) |
|
||||||
| video-1 | `input_data/video_1.mp4` | MP4 video — local variant for concurrent and resilience-style tests | NFT-RES-02 (variant), NFT-RES-04 | Volume mount to consumer `/media/` | N/A (read-only) |
|
|
||||||
| video-1-faststart | `input_data/video_1_faststart.mp4` | MP4 video — faststart/local streaming variant | Streaming compatibility checks | Volume mount to consumer `/media/` | N/A (read-only) |
|
| video-1-faststart | `input_data/video_1_faststart.mp4` | MP4 video — faststart/local streaming variant | Streaming compatibility checks | Volume mount to consumer `/media/` | N/A (read-only) |
|
||||||
| empty-image | Generated at build time | Zero-byte file | FT-N-01 | Generated in e2e/fixtures/ | N/A |
|
| empty-image | Generated at build time | Zero-byte file | FT-N-01 | Generated in e2e/fixtures/ | N/A |
|
||||||
| corrupt-image | Generated at build time | Random binary garbage (not valid image format) | FT-N-02 | Generated in e2e/fixtures/ | N/A |
|
| corrupt-image | Generated at build time | Random binary garbage (not valid image format) | FT-N-02 | Generated in e2e/fixtures/ | N/A |
|
||||||
@@ -31,12 +28,9 @@ Each test run starts with fresh containers (`docker compose down -v && docker co
|
|||||||
| azaion.onnx | `_docs/00_problem/input_data/azaion.onnx` | YOLO ONNX detection model | All detection tests |
|
| azaion.onnx | `_docs/00_problem/input_data/azaion.onnx` | YOLO ONNX detection model | All detection tests |
|
||||||
| image_small.jpg | `_docs/00_problem/input_data/image_small.jpg` | 1280×720 aerial image | Single-frame detection, health, negative, perf tests |
|
| image_small.jpg | `_docs/00_problem/input_data/image_small.jpg` | 1280×720 aerial image | Single-frame detection, health, negative, perf tests |
|
||||||
| image_large.JPG | `_docs/00_problem/input_data/image_large.JPG` | 6252×4168 aerial image | Tiling tests |
|
| image_large.JPG | `_docs/00_problem/input_data/image_large.JPG` | 6252×4168 aerial image | Tiling tests |
|
||||||
| image_dense01.jpg | `_docs/00_problem/input_data/image_dense01.jpg` | Dense scene 1280×720 | Dedup, detection cap tests |
|
|
||||||
| image_dense02.jpg | `_docs/00_problem/input_data/image_dense02.jpg` | Dense scene 1920×1080 | Dedup variant |
|
|
||||||
| image_different_types.jpg | `_docs/00_problem/input_data/image_different_types.jpg` | Varied classes 900×1600 | Class variant tests |
|
| image_different_types.jpg | `_docs/00_problem/input_data/image_different_types.jpg` | Varied classes 900×1600 | Class variant tests |
|
||||||
| image_empty_scene.jpg | `_docs/00_problem/input_data/image_empty_scene.jpg` | Empty scene 1920×1080 | Zero-detection edge case |
|
| image_empty_scene.jpg | `_docs/00_problem/input_data/image_empty_scene.jpg` | Empty scene 1920×1080 | Zero-detection edge case |
|
||||||
| video_test01.mp4 | `_docs/00_problem/input_data/video_test01.mp4` | Standard video | Async, SSE, video, perf tests |
|
| video_test01.mp4 | `_docs/00_problem/input_data/video_test01.mp4` | Standard video | Async, SSE, video, perf tests |
|
||||||
| video_1.mp4 | `_docs/00_problem/input_data/video_1.mp4` | Video variant | Resilience, concurrent tests |
|
|
||||||
| video_1_faststart.mp4 | `_docs/00_problem/input_data/video_1_faststart.mp4` | Faststart video variant | Streaming compatibility checks |
|
| video_1_faststart.mp4 | `_docs/00_problem/input_data/video_1_faststart.mp4` | Faststart video variant | Streaming compatibility checks |
|
||||||
| classes.json | repo root `classes.json` | 19 detection classes | All tests |
|
| classes.json | repo root `classes.json` | 19 detection classes | All tests |
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ e2e/
|
|||||||
├── fixtures/
|
├── fixtures/
|
||||||
│ ├── image_small.jpg (1280×720 JPEG, aerial, detectable objects)
|
│ ├── image_small.jpg (1280×720 JPEG, aerial, detectable objects)
|
||||||
│ ├── image_large.JPG (6252×4168 JPEG, triggers tiling)
|
│ ├── image_large.JPG (6252×4168 JPEG, triggers tiling)
|
||||||
│ ├── image_dense01.jpg (1280×720 JPEG, dense scene, clustered objects)
|
|
||||||
│ ├── image_dense02.jpg (1920×1080 JPEG, dense scene variant)
|
|
||||||
│ ├── image_different_types.jpg (900×1600 JPEG, varied object classes)
|
│ ├── image_different_types.jpg (900×1600 JPEG, varied object classes)
|
||||||
│ ├── image_empty_scene.jpg (1920×1080 JPEG, no detectable objects)
|
│ ├── image_empty_scene.jpg (1920×1080 JPEG, no detectable objects)
|
||||||
│ ├── video_short01.mp4 (short MP4 with moving objects)
|
│ ├── video_short01.mp4 (short MP4 with moving objects)
|
||||||
@@ -130,8 +128,6 @@ Two Docker Compose profiles:
|
|||||||
| `reset_mocks` | function (autouse) | Calls `POST /mock/reset` on both mocks before each test |
|
| `reset_mocks` | function (autouse) | Calls `POST /mock/reset` on both mocks before each test |
|
||||||
| `image_small` | session | Reads `image_small.jpg` from `/media/` volume |
|
| `image_small` | session | Reads `image_small.jpg` from `/media/` volume |
|
||||||
| `image_large` | session | Reads `image_large.JPG` from `/media/` volume |
|
| `image_large` | session | Reads `image_large.JPG` from `/media/` volume |
|
||||||
| `image_dense` | session | Reads `image_dense01.jpg` from `/media/` volume |
|
|
||||||
| `image_dense_02` | session | Reads `image_dense02.jpg` from `/media/` volume |
|
|
||||||
| `image_different_types` | session | Reads `image_different_types.jpg` from `/media/` volume |
|
| `image_different_types` | session | Reads `image_different_types.jpg` from `/media/` volume |
|
||||||
| `image_empty_scene` | session | Reads `image_empty_scene.jpg` from `/media/` volume |
|
| `image_empty_scene` | session | Reads `image_empty_scene.jpg` from `/media/` volume |
|
||||||
| `video_short_path` | session | Path to `video_short01.mp4` on `/media/` volume |
|
| `video_short_path` | session | Path to `video_short01.mp4` on `/media/` volume |
|
||||||
@@ -150,8 +146,6 @@ Two Docker Compose profiles:
|
|||||||
| classes.json | repo root `classes.json` | JSON (19 objects with Id, Name, Color, MaxSizeM) | All tests (volume mount to detections) |
|
| classes.json | repo root `classes.json` | JSON (19 objects with Id, Name, Color, MaxSizeM) | All tests (volume mount to detections) |
|
||||||
| image_small.jpg | `input_data/image_small.jpg` | JPEG 1280×720 | Health, single image, filtering, negative, performance tests |
|
| image_small.jpg | `input_data/image_small.jpg` | JPEG 1280×720 | Health, single image, filtering, negative, performance tests |
|
||||||
| image_large.JPG | `input_data/image_large.JPG` | JPEG 6252×4168 | Tiling tests, performance tests |
|
| image_large.JPG | `input_data/image_large.JPG` | JPEG 6252×4168 | Tiling tests, performance tests |
|
||||||
| image_dense01.jpg | `input_data/image_dense01.jpg` | JPEG 1280×720 dense scene | Dedup tests, detection cap tests |
|
|
||||||
| image_dense02.jpg | `input_data/image_dense02.jpg` | JPEG 1920×1080 dense scene | Dedup variant |
|
|
||||||
| image_different_types.jpg | `input_data/image_different_types.jpg` | JPEG 900×1600 varied classes | Weather mode class variant tests |
|
| image_different_types.jpg | `input_data/image_different_types.jpg` | JPEG 900×1600 varied classes | Weather mode class variant tests |
|
||||||
| image_empty_scene.jpg | `input_data/image_empty_scene.jpg` | JPEG 1920×1080 empty | Zero-detection edge case |
|
| image_empty_scene.jpg | `input_data/image_empty_scene.jpg` | JPEG 1920×1080 empty | Zero-detection edge case |
|
||||||
| video_short01.mp4 | `input_data/video_short01.mp4` | MP4 short video | Async, SSE, video processing tests |
|
| video_short01.mp4 | `input_data/video_short01.mp4` | MP4 short video | Async, SSE, video processing tests |
|
||||||
|
|||||||
@@ -30,3 +30,13 @@ step: 14 (Deploy) — DONE (deploy_status_report.md + deploy_scripts.md updated
|
|||||||
## Rollback Note
|
## Rollback Note
|
||||||
2026-04-10: Rolled back from step 8 (New Task) to step 2 (Test Spec).
|
2026-04-10: Rolled back from step 8 (New Task) to step 2 (Test Spec).
|
||||||
Reason: All 9 expected result CSV files in _docs/00_problem/input_data/expected_results/ contain headers only — zero data rows. results_report.md has "?" for detection counts. Phase 1 and Phase 3 BLOCKING gates were not enforced. E2E tests cannot verify detection accuracy without ground truth data.
|
Reason: All 9 expected result CSV files in _docs/00_problem/input_data/expected_results/ contain headers only — zero data rows. results_report.md has "?" for detection counts. Phase 1 and Phase 3 BLOCKING gates were not enforced. E2E tests cannot verify detection accuracy without ground truth data.
|
||||||
|
|
||||||
|
## Recovery Note
|
||||||
|
2026-04-23: Expected-result artifacts were populated for the active local fixture set.
|
||||||
|
|
||||||
|
- Image CSVs now exist for: `image_small`, `image_large`, `image_different_types`, `image_empty_scene`
|
||||||
|
- Video CSVs now exist for: `video_test01`, `video_1_faststart`
|
||||||
|
- `results_report.md` was updated to match the active fixture set and populated image detection counts
|
||||||
|
- Obsolete fixtures were removed from the active test-data set: `image_dense01`, `image_dense02`, `video_1`
|
||||||
|
|
||||||
|
Implication: the original expected-results blocker recorded in the rollback note no longer reflects the current repository state for the active fixture set. Resume Step 2 / Phase 3 validation from the current artifacts rather than assuming CSV ground truth is still missing.
|
||||||
|
|||||||
@@ -228,17 +228,6 @@ def image_small():
|
|||||||
def image_large():
|
def image_large():
|
||||||
return _read_media("image_large.JPG")
|
return _read_media("image_large.JPG")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def image_dense():
|
|
||||||
return _read_media("image_dense01.jpg")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def image_dense_02():
|
|
||||||
return _read_media("image_dense02.jpg")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def image_different_types():
|
def image_different_types():
|
||||||
return _read_media("image_different_types.jpg")
|
return _read_media("image_different_types.jpg")
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: detections-e2e
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
mock-loader:
|
mock-loader:
|
||||||
build: ./mocks/loader
|
build:
|
||||||
volumes:
|
context: .
|
||||||
- ./fixtures:/models
|
dockerfile: mocks/loader/Dockerfile
|
||||||
networks:
|
networks:
|
||||||
- e2e-net
|
- e2e-net
|
||||||
|
|
||||||
@@ -58,16 +58,41 @@ services:
|
|||||||
aliases:
|
aliases:
|
||||||
- detections
|
- detections
|
||||||
|
|
||||||
|
detections-jetson:
|
||||||
|
profiles:
|
||||||
|
- jetson
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile.jetson
|
||||||
|
runtime: nvidia
|
||||||
|
depends_on:
|
||||||
|
- mock-loader
|
||||||
|
- mock-annotations
|
||||||
|
environment:
|
||||||
|
LOADER_URL: http://mock-loader:8080
|
||||||
|
ANNOTATIONS_URL: http://mock-annotations:8081
|
||||||
|
JWT_SECRET: test-secret-e2e-only
|
||||||
|
CLASSES_JSON_PATH: /app/classes.json
|
||||||
|
volumes:
|
||||||
|
- ./fixtures:/media:ro
|
||||||
|
shm_size: 512m
|
||||||
|
networks:
|
||||||
|
e2e-net:
|
||||||
|
aliases:
|
||||||
|
- detections
|
||||||
|
|
||||||
e2e-runner:
|
e2e-runner:
|
||||||
profiles:
|
profiles:
|
||||||
- cpu
|
- cpu
|
||||||
- gpu
|
- gpu
|
||||||
|
- jetson
|
||||||
build: .
|
build: .
|
||||||
depends_on:
|
depends_on:
|
||||||
- mock-loader
|
- mock-loader
|
||||||
- mock-annotations
|
- mock-annotations
|
||||||
environment:
|
environment:
|
||||||
JWT_SECRET: test-secret-e2e-only
|
JWT_SECRET: test-secret-e2e-only
|
||||||
|
MEDIA_DIR: /app/fixtures
|
||||||
volumes:
|
volumes:
|
||||||
- ./fixtures:/media
|
- ./fixtures:/media
|
||||||
- ./results:/results
|
- ./results:/results
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN pip install --no-cache-dir flask gunicorn
|
RUN pip install --no-cache-dir flask gunicorn
|
||||||
COPY app.py .
|
COPY mocks/loader/app.py .
|
||||||
|
COPY fixtures /models
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["gunicorn", "-b", "0.0.0.0:8080", "-w", "1", "--timeout", "120", "app:app"]
|
CMD ["gunicorn", "-b", "0.0.0.0:8080", "-w", "1", "--timeout", "120", "app:app"]
|
||||||
|
|||||||
+13
-1
@@ -31,6 +31,16 @@ def _resolve_disk_path(filename: str, folder: str | None) -> Path | None:
|
|||||||
return 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:
|
def _should_fail_load() -> bool:
|
||||||
global _first_fail_remaining
|
global _first_fail_remaining
|
||||||
if _mode == "error":
|
if _mode == "error":
|
||||||
@@ -73,7 +83,9 @@ def upload(filename):
|
|||||||
f = request.files.get("data")
|
f = request.files.get("data")
|
||||||
if not f:
|
if not f:
|
||||||
return "", 400
|
return "", 400
|
||||||
_uploads[(folder, filename)] = f.read()
|
data = f.read()
|
||||||
|
_uploads[(folder, filename)] = data
|
||||||
|
_write_disk_path(filename, folder, data)
|
||||||
_upload_count += 1
|
_upload_count += 1
|
||||||
return "", 200
|
return "", 200
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pytest
|
pytest
|
||||||
pytest-csv
|
pytest-csv
|
||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
|
PyJWT==2.12.1
|
||||||
sseclient-py
|
sseclient-py
|
||||||
pytest-timeout
|
pytest-timeout
|
||||||
flask
|
flask
|
||||||
|
|||||||
+41
-6
@@ -1,7 +1,32 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
COMPOSE="docker compose -f docker-compose.test.yml --profile cpu"
|
PROFILE="${E2E_PROFILE:-cpu}"
|
||||||
|
case "$PROFILE" in
|
||||||
|
cpu)
|
||||||
|
DETECTIONS_SERVICE="detections"
|
||||||
|
;;
|
||||||
|
gpu)
|
||||||
|
DETECTIONS_SERVICE="detections-gpu"
|
||||||
|
;;
|
||||||
|
jetson)
|
||||||
|
DETECTIONS_SERVICE="detections-jetson"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: unsupported E2E_PROFILE=$PROFILE (expected: cpu, gpu, jetson)"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
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() {
|
usage() {
|
||||||
echo "Usage: $0 <test_path> [pytest_args...]"
|
echo "Usage: $0 <test_path> [pytest_args...]"
|
||||||
@@ -11,27 +36,37 @@ usage() {
|
|||||||
echo " $0 tests/test_video.py::test_ft_p_10_frame_sampling_ac1 # run single test"
|
echo " $0 tests/test_video.py::test_ft_p_10_frame_sampling_ac1 # run single test"
|
||||||
echo " $0 tests/test_video.py -k 'frame_sampling' # run by keyword"
|
echo " $0 tests/test_video.py -k 'frame_sampling' # run by keyword"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Environment:"
|
||||||
|
echo " E2E_PROFILE=cpu|gpu|jetson compose profile to run (default: cpu)"
|
||||||
|
echo ""
|
||||||
echo "Flags -v -x -s are always included."
|
echo "Flags -v -x -s are always included."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
[[ $# -lt 1 ]] && usage
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
$COMPOSE up -d --build detections
|
$COMPOSE up -d --build "$DETECTIONS_SERVICE"
|
||||||
echo "--- Waiting for detections service to become healthy..."
|
echo "--- Waiting for detections service to become healthy..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if $COMPOSE exec -T detections python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" 2>/dev/null; then
|
if $COMPOSE exec -T "$DETECTIONS_SERVICE" python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" 2>/dev/null; then
|
||||||
echo "--- Detections service is healthy"
|
echo "--- Detections service is healthy"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
if [[ "$i" == "60" ]]; then
|
||||||
|
echo "ERROR: detections service did not become healthy"
|
||||||
|
$COMPOSE logs "$DETECTIONS_SERVICE" --tail "$LOG_TAIL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "--- Running: pytest $* -v -x -s --csv=/results/report.csv"
|
echo "--- Running: pytest $* -v -x -s --csv=/results/report.csv"
|
||||||
$COMPOSE run --rm --no-deps e2e-runner pytest "$@" -v -x -s --csv=/results/report.csv
|
set +e
|
||||||
|
$COMPOSE run --rm --build --no-deps "${RUNNER_ENV_ARGS[@]}" e2e-runner pytest "$@" -v -x -s --csv=/results/report.csv
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
echo "--- Test finished with exit code $EXIT_CODE"
|
echo "--- Test finished with exit code $EXIT_CODE"
|
||||||
echo "--- Detections logs (last 100 lines):"
|
echo "--- Detections logs (last $LOG_TAIL lines):"
|
||||||
$COMPOSE logs detections --tail 100
|
$COMPOSE logs "$DETECTIONS_SERVICE" --tail "$LOG_TAIL"
|
||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -7,6 +8,13 @@ import pytest
|
|||||||
import sseclient
|
import sseclient
|
||||||
|
|
||||||
_DETECT_TIMEOUT = 60
|
_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):
|
def _get_health(http_client):
|
||||||
@@ -20,6 +28,24 @@ def _assert_active_ai(data):
|
|||||||
assert data["aiAvailability"] not in ("None", "Downloading")
|
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
|
@pytest.mark.cpu
|
||||||
class TestHealthEngineStep01PreInit:
|
class TestHealthEngineStep01PreInit:
|
||||||
def test_ft_p_01_pre_init_health(self, http_client):
|
def test_ft_p_01_pre_init_health(self, http_client):
|
||||||
@@ -92,8 +118,12 @@ class TestHealthEngineStep03Warmed:
|
|||||||
def _warm(self, warm_engine):
|
def _warm(self, warm_engine):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.timeout(_ENGINE_WAIT_TIMEOUT + 30)
|
||||||
def test_ft_p_02_post_init_health(self, http_client):
|
def test_ft_p_02_post_init_health(self, http_client):
|
||||||
data = _get_health(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_active_ai(data)
|
||||||
assert data.get("errorMessage") is None
|
assert data.get("errorMessage") is None
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ h11==0.16.0
|
|||||||
python-multipart==0.0.22
|
python-multipart==0.0.22
|
||||||
Cython==3.2.4
|
Cython==3.2.4
|
||||||
opencv-python==4.10.0.84
|
opencv-python==4.10.0.84
|
||||||
numpy==2.2.6
|
numpy==1.26.4
|
||||||
|
onnx==1.17.0
|
||||||
pynvml==12.0.0
|
pynvml==12.0.0
|
||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
loguru==0.7.3
|
loguru==0.7.3
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from setuptools import setup, Extension
|
from setuptools import setup, Extension
|
||||||
from Cython.Build import cythonize
|
from Cython.Build import cythonize
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import os
|
||||||
|
|
||||||
SRC = "src"
|
SRC = "src"
|
||||||
np_inc = [np.get_include(), SRC]
|
np_inc = [np.get_include(), SRC]
|
||||||
@@ -18,16 +19,22 @@ extensions = [
|
|||||||
Extension('inference', [f'{SRC}/inference.pyx'], include_dirs=np_inc),
|
Extension('inference', [f'{SRC}/inference.pyx'], include_dirs=np_inc),
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
build_tensorrt = os.environ.get("BUILD_TENSORRT_EXTENSIONS", "").lower() in ("1", "true", "yes")
|
||||||
import tensorrt # pyright: ignore[reportMissingImports]
|
|
||||||
|
if not build_tensorrt:
|
||||||
|
try:
|
||||||
|
import tensorrt # pyright: ignore[reportMissingImports]
|
||||||
|
build_tensorrt = True
|
||||||
|
except ImportError:
|
||||||
|
build_tensorrt = False
|
||||||
|
|
||||||
|
if build_tensorrt:
|
||||||
extensions.append(
|
extensions.append(
|
||||||
Extension('engines.tensorrt_engine', [f'{SRC}/engines/tensorrt_engine.pyx'], include_dirs=np_inc)
|
Extension('engines.tensorrt_engine', [f'{SRC}/engines/tensorrt_engine.pyx'], include_dirs=np_inc)
|
||||||
)
|
)
|
||||||
extensions.append(
|
extensions.append(
|
||||||
Extension('engines.jetson_tensorrt_engine', [f'{SRC}/engines/jetson_tensorrt_engine.pyx'], include_dirs=np_inc)
|
Extension('engines.jetson_tensorrt_engine', [f'{SRC}/engines/jetson_tensorrt_engine.pyx'], include_dirs=np_inc)
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="azaion.detections",
|
name="azaion.detections",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ cdef class AIRecognitionConfig:
|
|||||||
|
|
||||||
cdef public int model_batch_size
|
cdef public int model_batch_size
|
||||||
|
|
||||||
|
cdef public bint has_altitude
|
||||||
cdef public double altitude
|
cdef public double altitude
|
||||||
cdef public double focal_length
|
cdef public double focal_length
|
||||||
cdef public double sensor_width
|
cdef public double sensor_width
|
||||||
|
|||||||
+4
-3
@@ -25,7 +25,8 @@ cdef class AIRecognitionConfig:
|
|||||||
|
|
||||||
self.big_image_tile_overlap_percent = big_image_tile_overlap_percent
|
self.big_image_tile_overlap_percent = big_image_tile_overlap_percent
|
||||||
|
|
||||||
self.altitude = altitude
|
self.has_altitude = altitude is not None
|
||||||
|
self.altitude = 0.0 if altitude is None else float(altitude)
|
||||||
self.focal_length = focal_length
|
self.focal_length = focal_length
|
||||||
self.sensor_width = sensor_width
|
self.sensor_width = sensor_width
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ cdef class AIRecognitionConfig:
|
|||||||
f'frame_period_recognition : {self.frame_period_recognition}, '
|
f'frame_period_recognition : {self.frame_period_recognition}, '
|
||||||
f'big_image_tile_overlap_percent: {self.big_image_tile_overlap_percent}, '
|
f'big_image_tile_overlap_percent: {self.big_image_tile_overlap_percent}, '
|
||||||
f'model_batch_size: {self.model_batch_size}, '
|
f'model_batch_size: {self.model_batch_size}, '
|
||||||
f'altitude: {self.altitude}, '
|
f'altitude: {self.altitude if self.has_altitude else None}, '
|
||||||
f'focal_length: {self.focal_length}, '
|
f'focal_length: {self.focal_length}, '
|
||||||
f'sensor_width: {self.sensor_width}'
|
f'sensor_width: {self.sensor_width}'
|
||||||
)
|
)
|
||||||
@@ -56,7 +57,7 @@ cdef class AIRecognitionConfig:
|
|||||||
|
|
||||||
data.get("big_image_tile_overlap_percent", 20),
|
data.get("big_image_tile_overlap_percent", 20),
|
||||||
|
|
||||||
data.get("altitude", 400),
|
data.get("altitude", None),
|
||||||
data.get("focal_length", 24),
|
data.get("focal_length", 24),
|
||||||
data.get("sensor_width", 23.5)
|
data.get("sensor_width", 23.5)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@ cdef class Detection:
|
|||||||
cdef class Annotation:
|
cdef class Annotation:
|
||||||
cdef public str name
|
cdef public str name
|
||||||
cdef public str original_media_name
|
cdef public str original_media_name
|
||||||
cdef long time
|
cdef public long time
|
||||||
cdef public list[Detection] detections
|
cdef public list[Detection] detections
|
||||||
cdef public bytes image
|
cdef public bytes image
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ cdef str SPLIT_SUFFIX
|
|||||||
cdef double TILE_DUPLICATE_CONFIDENCE_THRESHOLD
|
cdef double TILE_DUPLICATE_CONFIDENCE_THRESHOLD
|
||||||
cdef int METERS_IN_TILE
|
cdef int METERS_IN_TILE
|
||||||
|
|
||||||
cdef log(str log_message)
|
cpdef log(str log_message)
|
||||||
cdef logerror(str error)
|
cpdef logerror(str error)
|
||||||
cdef format_time(long ms)
|
cdef format_time(long ms)
|
||||||
|
|
||||||
cdef dict[int, AnnotationClass] annotations_dict
|
cdef dict[int, AnnotationClass] annotations_dict
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ def get_annotation_name(int cls_id):
|
|||||||
return (<AnnotationClass>annotations_dict[cls_id]).name
|
return (<AnnotationClass>annotations_dict[cls_id]).name
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
cdef log(str log_message):
|
cpdef log(str log_message):
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
|
|
||||||
cdef logerror(str error):
|
cpdef logerror(str error):
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
|
|
||||||
cdef format_time(long ms):
|
cdef format_time(long ms):
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class EngineFactory:
|
|||||||
def build_and_cache(self, bytes source_bytes, LoaderHttpClient loader_client, str models_dir):
|
def build_and_cache(self, bytes source_bytes, LoaderHttpClient loader_client, str models_dir):
|
||||||
cdef LoadResult res
|
cdef LoadResult res
|
||||||
engine_bytes, engine_filename = self.build_from_source(source_bytes, loader_client, models_dir)
|
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)
|
res = loader_client.upload_big_small_resource(engine_bytes, engine_filename, models_dir)
|
||||||
if res.err is not None:
|
if res.err is not None:
|
||||||
constants_inf.log(f"Failed to upload converted model: {res.err}")
|
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
|
from engines.jetson_tensorrt_engine import JetsonTensorRTEngine
|
||||||
return JetsonTensorRTEngine(model_bytes)
|
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):
|
def _get_ai_engine_filename(self):
|
||||||
from engines.tensorrt_engine import TensorRTEngine
|
from engines.tensorrt_engine import TensorRTEngine
|
||||||
return TensorRTEngine.get_engine_filename("int8")
|
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):
|
def build_from_source(self, onnx_bytes, LoaderHttpClient loader_client, str models_dir):
|
||||||
from engines.jetson_tensorrt_engine import JetsonTensorRTEngine
|
from engines.jetson_tensorrt_engine import JetsonTensorRTEngine
|
||||||
from engines.tensorrt_engine import TensorRTEngine
|
from engines.tensorrt_engine import TensorRTEngine
|
||||||
engine_bytes = JetsonTensorRTEngine.convert_from_source(onnx_bytes, loader_client, models_dir)
|
engine_bytes, precision = JetsonTensorRTEngine.convert_from_source_with_precision(onnx_bytes, loader_client, models_dir)
|
||||||
return engine_bytes, TensorRTEngine.get_engine_filename("int8")
|
return engine_bytes, TensorRTEngine.get_engine_filename(precision)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
cimport constants_inf
|
||||||
from engines.tensorrt_engine cimport TensorRTEngine
|
from engines.tensorrt_engine cimport TensorRTEngine
|
||||||
from loader_http_client cimport LoaderHttpClient, LoadResult
|
from loader_http_client cimport LoaderHttpClient, LoadResult
|
||||||
|
|
||||||
@@ -7,10 +8,19 @@ from loader_http_client cimport LoaderHttpClient, LoadResult
|
|||||||
cdef class JetsonTensorRTEngine(TensorRTEngine):
|
cdef class JetsonTensorRTEngine(TensorRTEngine):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def convert_from_source(bytes onnx_model, LoaderHttpClient loader_client, str models_dir):
|
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
|
cdef str calib_cache_path
|
||||||
calib_cache_path = JetsonTensorRTEngine._download_calib_cache(loader_client, models_dir)
|
calib_cache_path = JetsonTensorRTEngine._download_calib_cache(loader_client, models_dir)
|
||||||
try:
|
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:
|
finally:
|
||||||
if calib_cache_path is not None:
|
if calib_cache_path is not None:
|
||||||
try:
|
try:
|
||||||
@@ -21,7 +31,6 @@ cdef class JetsonTensorRTEngine(TensorRTEngine):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _download_calib_cache(LoaderHttpClient loader_client, str models_dir):
|
def _download_calib_cache(LoaderHttpClient loader_client, str models_dir):
|
||||||
cdef LoadResult res
|
cdef LoadResult res
|
||||||
import constants_inf
|
|
||||||
try:
|
try:
|
||||||
res = loader_client.load_big_small_resource(
|
res = loader_client.load_big_small_resource(
|
||||||
constants_inf.INT8_CALIB_CACHE_FILE, models_dir
|
constants_inf.INT8_CALIB_CACHE_FILE, models_dir
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -114,13 +114,21 @@ cdef class TensorRTEngine(InferenceEngine):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
gpu_mem = TensorRTEngine.get_gpu_memory_bytes(0)
|
||||||
workspace_bytes = int(gpu_mem * 0.9)
|
workspace_bytes = int(gpu_mem * 0.9)
|
||||||
|
|
||||||
explicit_batch_flag = 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
|
explicit_batch_flag = 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
|
||||||
trt_logger = trt.Logger(trt.Logger.WARNING)
|
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, \
|
with trt.Builder(trt_logger) as builder, \
|
||||||
builder.create_network(explicit_batch_flag) as network, \
|
builder.create_network(explicit_batch_flag) as network, \
|
||||||
trt.OnnxParser(network, trt_logger) as parser, \
|
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)
|
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, workspace_bytes)
|
||||||
|
|
||||||
if not parser.parse(onnx_model):
|
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
|
return None
|
||||||
|
|
||||||
input_tensor = network.get_input(0)
|
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
|
H = max(shape[2], 1280) if shape[2] != -1 else 1280
|
||||||
W = max(shape[3], 1280) if shape[3] != -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)
|
max_batch = TensorRTEngine.calculate_max_batch_size(gpu_mem, H, W)
|
||||||
profile = builder.create_optimization_profile()
|
profile = builder.create_optimization_profile()
|
||||||
profile.set_shape(
|
profile.set_shape(
|
||||||
|
|||||||
+17
-2
@@ -311,13 +311,22 @@ cdef class Inference:
|
|||||||
cdef double ground_sampling_distance
|
cdef double ground_sampling_distance
|
||||||
cdef int model_h, model_w
|
cdef int model_h, model_w
|
||||||
cdef int img_h, img_w
|
cdef int img_h, img_w
|
||||||
|
cdef bint has_gsd
|
||||||
model_h, model_w = self.engine.get_input_shape()
|
model_h, model_w = self.engine.get_input_shape()
|
||||||
img_h, img_w, _ = frame.shape
|
img_h, img_w, _ = frame.shape
|
||||||
ground_sampling_distance = ai_config.sensor_width * ai_config.altitude / (ai_config.focal_length * img_w)
|
has_gsd = ai_config.has_altitude and ai_config.focal_length > 0 and ai_config.sensor_width > 0 and img_w > 0
|
||||||
constants_inf.log(<str>f'ground sampling distance: {ground_sampling_distance}')
|
ground_sampling_distance = 0.0
|
||||||
|
if has_gsd:
|
||||||
|
ground_sampling_distance = ai_config.sensor_width * ai_config.altitude / (ai_config.focal_length * img_w)
|
||||||
|
constants_inf.log(<str>f'ground sampling distance: {ground_sampling_distance}')
|
||||||
|
else:
|
||||||
|
constants_inf.log(<str>'ground sampling distance: skipped (altitude unavailable)')
|
||||||
if img_h <= 1.5 * model_h and img_w <= 1.5 * model_w:
|
if img_h <= 1.5 * model_h and img_w <= 1.5 * model_w:
|
||||||
all_frame_data.append((frame, original_media_name, f'{original_media_name}_000000', ground_sampling_distance))
|
all_frame_data.append((frame, original_media_name, f'{original_media_name}_000000', ground_sampling_distance))
|
||||||
else:
|
else:
|
||||||
|
if not has_gsd:
|
||||||
|
all_frame_data.append((frame, original_media_name, f'{original_media_name}_000000', ground_sampling_distance))
|
||||||
|
return
|
||||||
tile_size = int(constants_inf.METERS_IN_TILE / ground_sampling_distance)
|
tile_size = int(constants_inf.METERS_IN_TILE / ground_sampling_distance)
|
||||||
constants_inf.log(<str> f'calc tile size: {tile_size}')
|
constants_inf.log(<str> f'calc tile size: {tile_size}')
|
||||||
res = self.split_to_tiles(frame, original_media_name, tile_size, ai_config.big_image_tile_overlap_percent)
|
res = self.split_to_tiles(frame, original_media_name, tile_size, ai_config.big_image_tile_overlap_percent)
|
||||||
@@ -410,6 +419,12 @@ cdef class Inference:
|
|||||||
if annotation.detections:
|
if annotation.detections:
|
||||||
constants_inf.log(<str> f'Initial ann: {annotation}')
|
constants_inf.log(<str> f'Initial ann: {annotation}')
|
||||||
|
|
||||||
|
if ground_sampling_distance <= 0:
|
||||||
|
if not annotation.detections:
|
||||||
|
return <bint>False
|
||||||
|
constants_inf.log(<str>'Skipping physical-size filtering (ground sampling distance unavailable)')
|
||||||
|
return <bint>True
|
||||||
|
|
||||||
cdef list[Detection] valid_detections = []
|
cdef list[Detection] valid_detections = []
|
||||||
for det in annotation.detections:
|
for det in annotation.detections:
|
||||||
m_w = det.w * img_w * ground_sampling_distance
|
m_w = det.w * img_w * ground_sampling_distance
|
||||||
|
|||||||
+14
-6
@@ -15,6 +15,7 @@ import cv2
|
|||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
from loguru import logger
|
||||||
from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
|
from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
@@ -163,7 +164,7 @@ class AIConfigDto(BaseModel):
|
|||||||
tracking_intersection_threshold: float = 0.6
|
tracking_intersection_threshold: float = 0.6
|
||||||
model_batch_size: int = 8
|
model_batch_size: int = 8
|
||||||
big_image_tile_overlap_percent: int = 20
|
big_image_tile_overlap_percent: int = 20
|
||||||
altitude: float = 400
|
altitude: Optional[float] = None
|
||||||
focal_length: float = 24
|
focal_length: float = 24
|
||||||
sensor_width: float = 23.5
|
sensor_width: float = 23.5
|
||||||
|
|
||||||
@@ -270,7 +271,8 @@ def _post_media_record(payload: dict, bearer: str) -> bool:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
return r.status_code in (200, 201)
|
return r.status_code in (200, 201)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to create media record in annotations service: {exc}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -284,7 +286,8 @@ def _put_media_status(media_id: str, media_status: int, bearer: str) -> bool:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
return r.status_code in (200, 204)
|
return r.status_code in (200, 204)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to update media status in annotations service for {media_id}: {exc}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -332,10 +335,13 @@ def _post_annotation_to_service(token_mgr: TokenManager, media_id: str,
|
|||||||
try:
|
try:
|
||||||
token = token_mgr.get_valid_token()
|
token = token_mgr.get_valid_token()
|
||||||
image_b64 = base64.b64encode(annotation.image).decode() if annotation.image else None
|
image_b64 = base64.b64encode(annotation.image).decode() if annotation.image else None
|
||||||
|
total_seconds = int(annotation.time // 1000) if annotation.time else 0
|
||||||
|
hours, remainder = divmod(total_seconds, 3600)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
payload = {
|
payload = {
|
||||||
"mediaId": media_id,
|
"mediaId": media_id,
|
||||||
"source": 0,
|
"source": 0,
|
||||||
"videoTime": f"00:00:{annotation.time // 1000:02d}" if annotation.time else "00:00:00",
|
"videoTime": f"{hours:02d}:{minutes:02d}:{seconds:02d}",
|
||||||
"detections": [d.model_dump() for d in dtos],
|
"detections": [d.model_dump() for d in dtos],
|
||||||
}
|
}
|
||||||
if image_b64:
|
if image_b64:
|
||||||
@@ -346,8 +352,10 @@ def _post_annotation_to_service(token_mgr: TokenManager, media_id: str,
|
|||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logger.warning(
|
||||||
|
f"Failed to post annotation to annotations service for media {media_id}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_channel(channel_id: str):
|
def _cleanup_channel(channel_id: str):
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ def test_ai_config_from_dict_defaults():
|
|||||||
assert cfg.model_batch_size == 8
|
assert cfg.model_batch_size == 8
|
||||||
assert cfg.frame_period_recognition == 4
|
assert cfg.frame_period_recognition == 4
|
||||||
assert cfg.frame_recognition_seconds == 2
|
assert cfg.frame_recognition_seconds == 2
|
||||||
|
assert cfg.has_altitude is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_config_from_dict_altitude_override_sets_flag():
|
||||||
|
from inference import ai_config_from_dict
|
||||||
|
|
||||||
|
cfg = ai_config_from_dict({"altitude": 400})
|
||||||
|
assert cfg.has_altitude is True
|
||||||
|
assert cfg.altitude == 400
|
||||||
|
|
||||||
|
|
||||||
def test_ai_config_from_dict_overrides():
|
def test_ai_config_from_dict_overrides():
|
||||||
|
|||||||
@@ -118,6 +118,23 @@ def test_resolve_media_for_detect_override_wins():
|
|||||||
assert "paths" not in cfg
|
assert "paths" not in cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_media_for_detect_omits_altitude_when_not_provided():
|
||||||
|
# Arrange
|
||||||
|
import main
|
||||||
|
|
||||||
|
tm = main.TokenManager(_access_jwt(), "")
|
||||||
|
mock_ann = MagicMock()
|
||||||
|
mock_ann.fetch_user_ai_settings.return_value = {"probabilityThreshold": 0.2}
|
||||||
|
mock_ann.fetch_media_path.return_value = "/m/v.mp4"
|
||||||
|
with patch("main.annotations_client", mock_ann):
|
||||||
|
# Act
|
||||||
|
cfg, path = main._resolve_media_for_detect("vid-2", tm, None)
|
||||||
|
# Assert
|
||||||
|
assert "altitude" not in cfg
|
||||||
|
assert cfg["probability_threshold"] == 0.2
|
||||||
|
assert path == "/m/v.mp4"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_media_for_detect_raises_when_no_media_path():
|
def test_resolve_media_for_detect_raises_when_no_media_path():
|
||||||
# Arrange
|
# Arrange
|
||||||
import main
|
import main
|
||||||
|
|||||||
Reference in New Issue
Block a user