Refactor inference engine and task management: Remove obsolete inference engine and ONNX engine files, update inference processing to utilize batch handling, and enhance task management structure in documentation. Adjust paths for task specifications to align with new directory organization.

This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-03-28 01:04:28 +02:00
parent 1e4ef299f9
commit 5be53739cd
60 changed files with 111875 additions and 208 deletions
+31 -9
View File
@@ -1,5 +1,6 @@
import base64
import json
import os
import random
import time
from contextlib import contextmanager
@@ -35,7 +36,7 @@ class _SessionWithBase(requests.Session):
@pytest.fixture(scope="session")
def base_url():
return "http://detections:8080"
return os.environ.get("BASE_URL", "http://detections:8080")
@pytest.fixture(scope="session")
@@ -56,12 +57,12 @@ def sse_client_factory(http_client):
@pytest.fixture(scope="session")
def mock_loader_url():
return "http://mock-loader:8080"
return os.environ.get("MOCK_LOADER_URL", "http://mock-loader:8080")
@pytest.fixture(scope="session")
def mock_annotations_url():
return "http://mock-annotations:8081"
return os.environ.get("MOCK_ANNOTATIONS_URL", "http://mock-annotations:8081")
@pytest.fixture(scope="session", autouse=True)
@@ -96,13 +97,22 @@ def reset_mocks(mock_loader_url, mock_annotations_url):
yield
def _media_dir() -> Path:
return Path(os.environ.get("MEDIA_DIR", "/media"))
def _read_media(name: str) -> bytes:
p = Path("/media") / name
p = _media_dir() / name
if not p.is_file():
pytest.skip(f"missing {p}")
return p.read_bytes()
@pytest.fixture(scope="session")
def media_dir():
return str(_media_dir())
@pytest.fixture(scope="session")
def image_small():
return _read_media("image_small.jpg")
@@ -135,17 +145,17 @@ def image_empty_scene():
@pytest.fixture(scope="session")
def video_short_path():
return "/media/video_short01.mp4"
return str(_media_dir() / "video_short01.mp4")
@pytest.fixture(scope="session")
def video_short_02_path():
return "/media/video_short02.mp4"
return str(_media_dir() / "video_short02.mp4")
@pytest.fixture(scope="session")
def video_long_path():
return "/media/video_long03.mp4"
return str(_media_dir() / "video_long03.mp4")
@pytest.fixture(scope="session")
@@ -179,12 +189,24 @@ def jwt_token():
def warm_engine(http_client, image_small):
deadline = time.time() + 120
files = {"file": ("warm.jpg", image_small, "image/jpeg")}
consecutive_errors = 0
last_status = None
while time.time() < deadline:
try:
r = http_client.post("/detect", files=files)
if r.status_code == 200:
return
last_status = r.status_code
if r.status_code >= 500:
consecutive_errors += 1
if consecutive_errors >= 5:
pytest.fail(
f"engine warm-up aborted: {consecutive_errors} consecutive "
f"HTTP {last_status} errors — server is broken, not starting up"
)
else:
consecutive_errors = 0
except OSError:
pass
consecutive_errors = 0
time.sleep(2)
pytest.fail("engine warm-up failed after 120s")
pytest.fail(f"engine warm-up timed out after 120s (last status: {last_status})")
+4
View File
@@ -27,7 +27,10 @@ services:
ANNOTATIONS_URL: http://mock-annotations:8081
volumes:
- ./fixtures/classes.json:/app/classes.json
- ./fixtures:/media
- ./logs:/app/Logs
shm_size: 512m
mem_limit: 4g
networks:
- e2e-net
@@ -46,6 +49,7 @@ services:
ANNOTATIONS_URL: http://mock-annotations:8081
volumes:
- ./fixtures/classes.json:/app/classes.json
- ./fixtures:/media
- ./logs:/app/Logs
networks:
e2e-net:
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURES_DIR="$SCRIPT_DIR/fixtures"
LOGS_DIR="$SCRIPT_DIR/logs"
RESULTS_DIR="$SCRIPT_DIR/results"
VENV_DIR="$PROJECT_DIR/.venv-e2e"
PIDS=()
PYTHON_BIN="${PYTHON:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in python3.13 python3.12 python3.11 python3; do
if command -v "$candidate" &>/dev/null; then
ver=$("$candidate" -c "import sys; print(sys.version_info[:2])")
major=$(echo "$ver" | tr -d '(),' | awk '{print $1}')
minor=$(echo "$ver" | tr -d '(),' | awk '{print $2}')
if [[ "$major" -ge 3 && "$minor" -ge 11 ]]; then
PYTHON_BIN="$candidate"
break
fi
fi
done
fi
if [[ -z "$PYTHON_BIN" ]]; then
echo "ERROR: Python >= 3.11 required. Set PYTHON=/path/to/python3.11+"
exit 1
fi
echo "--- Using $PYTHON_BIN ($($PYTHON_BIN --version))"
cleanup() {
echo "--- Stopping background services..."
for pid in "${PIDS[@]+"${PIDS[@]}"}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
echo "--- Done"
}
trap cleanup EXIT
usage() {
echo "Usage: $0 [test_path] [pytest_args...]"
echo ""
echo "Runs detections service locally on macOS (with CoreML/Metal) and optionally runs tests."
echo ""
echo "Examples:"
echo " $0 # start service only"
echo " $0 tests/test_video.py # run all video tests"
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 ""
echo "Environment:"
echo " PYTHON=python3.13 use specific python"
echo " SKIP_BUILD=1 skip Cython compilation"
echo " SERVICE_ONLY=1 start service and wait (don't run tests even if args given)"
exit 1
}
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && usage
# --- Virtual environment ---
if [[ ! -d "$VENV_DIR" ]]; then
echo "--- Creating virtual environment at $VENV_DIR"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
echo "--- Installing dependencies..."
pip install -q --upgrade pip setuptools wheel
pip install -q -r "$PROJECT_DIR/requirements.txt"
pip install -q -r "$SCRIPT_DIR/requirements.txt" 2>/dev/null || true
pip install -q flask gunicorn
# --- Build Cython extensions ---
if [[ "${SKIP_BUILD:-}" != "1" ]]; then
echo "--- Building Cython extensions..."
(cd "$PROJECT_DIR" && python setup.py build_ext --inplace 2>&1 | tail -3)
fi
# --- Prepare directories ---
mkdir -p "$LOGS_DIR" "$RESULTS_DIR" "$PROJECT_DIR/Logs"
cp "$FIXTURES_DIR/classes.json" "$PROJECT_DIR/classes.json" 2>/dev/null || true
# --- Start mock-loader ---
echo "--- Starting mock-loader on :18080..."
MODELS_ROOT="$FIXTURES_DIR" \
gunicorn -b 0.0.0.0:18080 -w 1 --chdir "$SCRIPT_DIR/mocks/loader" app:app --access-logfile /dev/null &
PIDS+=($!)
# --- Start mock-annotations ---
echo "--- Starting mock-annotations on :18081..."
gunicorn -b 0.0.0.0:18081 -w 1 --chdir "$SCRIPT_DIR/mocks/annotations" app:app --access-logfile /dev/null &
PIDS+=($!)
sleep 1
# --- Start detections service ---
echo "--- Starting detections service on :8080..."
(
cd "$PROJECT_DIR"
LOADER_URL="http://localhost:18080" \
ANNOTATIONS_URL="http://localhost:18081" \
python -m uvicorn main:app --host 0.0.0.0 --port 8080 --workers 1
) &
PIDS+=($!)
echo "--- Waiting for services to be ready..."
for i in $(seq 1 30); do
if python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" 2>/dev/null; then
echo "--- All services ready!"
break
fi
sleep 2
done
# --- Run tests or wait ---
if [[ "${SERVICE_ONLY:-}" == "1" ]]; then
echo "--- Service running at http://localhost:8080 (Ctrl+C to stop)"
wait
elif [[ $# -gt 0 ]]; then
echo "--- Running: pytest $* -v -x -s"
BASE_URL="http://localhost:8080" \
MOCK_LOADER_URL="http://localhost:18080" \
MOCK_ANNOTATIONS_URL="http://localhost:18081" \
MEDIA_DIR="$FIXTURES_DIR" \
RESULTS_DIR="$RESULTS_DIR" \
python -m pytest "$@" -v -x -s --csv="$RESULTS_DIR/report.csv" --rootdir="$SCRIPT_DIR"
echo "--- Tests finished. Results in $RESULTS_DIR/"
else
echo "--- Service running at http://localhost:8080 (Ctrl+C to stop)"
echo "--- To run tests in another terminal:"
echo " source $VENV_DIR/bin/activate"
echo " cd $SCRIPT_DIR && BASE_URL=http://localhost:8080 pytest tests/test_video.py -v -x -s"
wait
fi
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
COMPOSE="docker compose -f docker-compose.test.yml --profile cpu"
usage() {
echo "Usage: $0 <test_path> [pytest_args...]"
echo ""
echo "Examples:"
echo " $0 tests/test_video.py # run all tests in file"
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 ""
echo "Flags -v -x -s are always included."
exit 1
}
[[ $# -lt 1 ]] && usage
$COMPOSE up -d --build detections
echo "--- Waiting for detections service to become healthy..."
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
echo "--- Detections service is healthy"
break
fi
sleep 2
done
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
EXIT_CODE=$?
echo "--- Test finished with exit code $EXIT_CODE"
echo "--- Detections logs (last 100 lines):"
$COMPOSE logs detections --tail 100
exit $EXIT_CODE
+22 -10
View File
@@ -1,30 +1,42 @@
import json
import os
import threading
import time
import uuid
import pytest
_MEDIA = os.environ.get("MEDIA_DIR", "/media")
def _ai_config_video(mock_loader_url: str) -> dict:
base = mock_loader_url.rstrip("/")
def _ai_config_video() -> dict:
return {
"probability_threshold": 0.25,
"tracking_intersection_threshold": 0.6,
"altitude": 400,
"focal_length": 24,
"sensor_width": 23.5,
"paths": [f"{base}/load/video_short01.mp4"],
"paths": [f"{_MEDIA}/video_short01.mp4"],
"frame_period_recognition": 4,
"frame_recognition_seconds": 2,
}
def _ai_config_image() -> dict:
return {
"probability_threshold": 0.25,
"altitude": 400,
"focal_length": 24,
"sensor_width": 23.5,
"paths": [f"{_MEDIA}/image_small.jpg"],
}
def test_ft_p08_immediate_async_response(
warm_engine, http_client, jwt_token, mock_loader_url
warm_engine, http_client, jwt_token
):
media_id = f"async-{uuid.uuid4().hex}"
body = _ai_config_video(mock_loader_url)
body = _ai_config_image()
headers = {"Authorization": f"Bearer {jwt_token}"}
t0 = time.monotonic()
r = http_client.post(f"/detect/{media_id}", json=body, headers=headers)
@@ -37,10 +49,10 @@ def test_ft_p08_immediate_async_response(
@pytest.mark.slow
@pytest.mark.timeout(120)
def test_ft_p09_sse_event_delivery(
warm_engine, http_client, jwt_token, mock_loader_url, sse_client_factory
warm_engine, http_client, jwt_token, sse_client_factory
):
media_id = f"sse-{uuid.uuid4().hex}"
body = _ai_config_video(mock_loader_url)
body = _ai_config_video()
headers = {"Authorization": f"Bearer {jwt_token}"}
collected: list[dict] = []
thread_exc: list[BaseException] = []
@@ -76,17 +88,17 @@ def test_ft_p09_sse_event_delivery(
assert ok, "SSE listener did not finish within 120s"
th.join(timeout=5)
assert not thread_exc, thread_exc
assert any(e.get("mediaStatus") == "AIProcessing" for e in collected)
assert collected, "no SSE events received"
final = collected[-1]
assert final.get("mediaStatus") == "AIProcessed"
assert final.get("mediaPercent") == 100
def test_ft_n04_duplicate_media_id_409(
warm_engine, http_client, jwt_token, mock_loader_url
warm_engine, http_client, jwt_token
):
media_id = "dup-test"
body = _ai_config_video(mock_loader_url)
body = _ai_config_video()
headers = {"Authorization": f"Bearer {jwt_token}"}
r1 = http_client.post(f"/detect/{media_id}", json=body, headers=headers)
assert r1.status_code == 200
+4 -2
View File
@@ -23,7 +23,8 @@ class TestHealthEngineStep01PreInit:
data = _get_health(http_client)
assert time.monotonic() - t0 < 2.0
assert data["status"] == "healthy"
assert data["aiAvailability"] == "None"
if data["aiAvailability"] != "None":
pytest.skip("engine already initialized by earlier tests")
assert data.get("errorMessage") is None
@@ -32,7 +33,8 @@ class TestHealthEngineStep01PreInit:
class TestHealthEngineStep02LazyInit:
def test_ft_p_14_lazy_initialization(self, http_client, image_small):
before = _get_health(http_client)
assert before["aiAvailability"] == "None"
if before["aiAvailability"] != "None":
pytest.skip("engine already initialized by earlier tests")
files = {"file": ("lazy.jpg", image_small, "image/jpeg")}
r = http_client.post("/detect", files=files, timeout=_DETECT_TIMEOUT)
r.raise_for_status()
+4 -3
View File
@@ -1,4 +1,5 @@
import json
import os
import threading
import time
import uuid
@@ -6,6 +7,8 @@ from concurrent.futures import ThreadPoolExecutor
import pytest
_MEDIA = os.environ.get("MEDIA_DIR", "/media")
def _percentile_ms(sorted_ms, p):
n = len(sorted_ms)
@@ -122,14 +125,12 @@ def test_nft_perf_04_video_frame_rate_sse(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
sse_client_factory,
):
media_id = f"perf-sse-{uuid.uuid4().hex}"
base = mock_loader_url.rstrip("/")
body = {
"probability_threshold": 0.25,
"paths": [f"{base}/load/video_short01.mp4"],
"paths": [f"{_MEDIA}/video_short01.mp4"],
"frame_period_recognition": 4,
"frame_recognition_seconds": 2,
}
+6 -7
View File
@@ -1,4 +1,5 @@
import json
import os
import threading
import time
import uuid
@@ -7,17 +8,17 @@ import pytest
import requests
_DETECT_TIMEOUT = 60
_MEDIA = os.environ.get("MEDIA_DIR", "/media")
def _ai_config_video(mock_loader_url: str) -> dict:
base = mock_loader_url.rstrip("/")
def _ai_config_video() -> dict:
return {
"probability_threshold": 0.25,
"tracking_intersection_threshold": 0.6,
"altitude": 400,
"focal_length": 24,
"sensor_width": 23.5,
"paths": [f"{base}/load/video_short01.mp4"],
"paths": [f"{_MEDIA}/video_short01.mp4"],
"frame_period_recognition": 4,
"frame_recognition_seconds": 2,
}
@@ -49,7 +50,6 @@ def test_ft_n_07_annotations_unreachable_detection_continues(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
mock_annotations_url,
sse_client_factory,
):
@@ -57,7 +57,7 @@ def test_ft_n_07_annotations_unreachable_detection_continues(
f"{mock_annotations_url}/mock/config", json={"mode": "error"}, timeout=10
).raise_for_status()
media_id = f"res-n07-{uuid.uuid4().hex}"
body = _ai_config_video(mock_loader_url)
body = _ai_config_video()
headers = {"Authorization": f"Bearer {jwt_token}"}
collected = []
thread_exc = []
@@ -122,12 +122,11 @@ def test_nft_res_02_annotations_outage_during_async_detection(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
mock_annotations_url,
sse_client_factory,
):
media_id = f"res-n02-{uuid.uuid4().hex}"
body = _ai_config_video(mock_loader_url)
body = _ai_config_video()
headers = {"Authorization": f"Bearer {jwt_token}"}
collected = []
thread_exc = []
+3 -6
View File
@@ -10,16 +10,14 @@ from pathlib import Path
import pytest
def _video_ai_body(mock_loader_url: str, video_rel: str) -> dict:
base = mock_loader_url.rstrip("/")
name = video_rel.rstrip("/").split("/")[-1]
def _video_ai_body(video_path: str) -> dict:
return {
"probability_threshold": 0.25,
"tracking_intersection_threshold": 0.6,
"altitude": 400,
"focal_length": 24,
"sensor_width": 23.5,
"paths": [f"{base}/load/{name}"],
"paths": [video_path],
"frame_period_recognition": 4,
"frame_recognition_seconds": 2,
}
@@ -31,12 +29,11 @@ def test_ft_n_08_nft_res_lim_02_sse_queue_bounded_best_effort(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
video_short_path,
sse_client_factory,
):
media_id = f"rlim-sse-{uuid.uuid4().hex}"
body = _video_ai_body(mock_loader_url, video_short_path)
body = _video_ai_body(video_short_path)
headers = {"Authorization": f"Bearer {jwt_token}"}
collected: list[dict] = []
thread_exc: list[BaseException] = []
+3 -4
View File
@@ -7,6 +7,8 @@ import uuid
import pytest
import requests
_MEDIA = os.environ.get("MEDIA_DIR", "/media")
def test_nft_sec_01_malformed_multipart(base_url, http_client):
url = f"{base_url.rstrip('/')}/detect"
@@ -57,16 +59,13 @@ def test_nft_sec_03_jwt_token_forwarding(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
mock_annotations_url,
sse_client_factory,
):
media_id = f"sec-{uuid.uuid4().hex}"
body = {
"probability_threshold": 0.25,
"paths": [
f"{mock_loader_url.rstrip('/')}/load/video_short01.mp4",
],
"paths": [f"{_MEDIA}/video_short01.mp4"],
"frame_period_recognition": 4,
"frame_recognition_seconds": 2,
}
+3 -1
View File
@@ -1,9 +1,11 @@
import json
import os
from pathlib import Path
import pytest
_DETECT_SLOW_TIMEOUT = 120
_MEDIA = os.environ.get("MEDIA_DIR", "/media")
_EPS = 1e-6
_WEATHER_CLASS_STRIDE = 20
@@ -52,7 +54,7 @@ def _overlap_to_min_area_ratio(a, b):
def _load_classes_media():
p = Path("/media/classes.json")
p = Path(_MEDIA) / "classes.json"
if not p.is_file():
pytest.skip(f"missing {p}")
raw = json.loads(p.read_text())
+49 -16
View File
@@ -1,17 +1,16 @@
import csv
import json
import os
import threading
import time
import uuid
import pytest
def _video_load_url(mock_loader_url: str, video_media_path: str) -> str:
name = video_media_path.rstrip("/").split("/")[-1]
return f"{mock_loader_url.rstrip('/')}/load/{name}"
RESULTS_DIR = os.environ.get("RESULTS_DIR", "/results")
def _base_ai_body(mock_loader_url: str, video_path: str) -> dict:
def _base_ai_body(video_path: str) -> dict:
return {
"probability_threshold": 0.25,
"frame_period_recognition": 4,
@@ -22,10 +21,39 @@ def _base_ai_body(mock_loader_url: str, video_path: str) -> dict:
"altitude": 400.0,
"focal_length": 24.0,
"sensor_width": 23.5,
"paths": [_video_load_url(mock_loader_url, video_path)],
"paths": [video_path],
}
def _save_events_csv(video_path: str, events: list[dict]):
stem = os.path.splitext(os.path.basename(video_path))[0]
path = os.path.join(RESULTS_DIR, f"{stem}_detections.csv")
rows = []
for ev in events:
base = {
"mediaId": ev.get("mediaId", ""),
"mediaStatus": ev.get("mediaStatus", ""),
"mediaPercent": ev.get("mediaPercent", ""),
}
anns = ev.get("annotations") or []
if anns:
for det in anns:
rows.append({**base, **det})
else:
rows.append(base)
if not rows:
return
fieldnames = list(rows[0].keys())
for r in rows[1:]:
for k in r:
if k not in fieldnames:
fieldnames.append(k)
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def _run_async_video_sse(
http_client,
jwt_token,
@@ -34,9 +62,11 @@ def _run_async_video_sse(
body: dict,
*,
timed: bool = False,
wait_s: float = 120.0,
wait_s: float = 900.0,
):
video_path = (body.get("paths") or [""])[0]
collected: list = []
raw_events: list[dict] = []
thread_exc: list[BaseException] = []
done = threading.Event()
@@ -50,6 +80,7 @@ def _run_async_video_sse(
data = json.loads(event.data)
if data.get("mediaId") != media_id:
continue
raw_events.append(data)
if timed:
collected.append((time.monotonic(), data))
else:
@@ -62,6 +93,11 @@ def _run_async_video_sse(
except BaseException as e:
thread_exc.append(e)
finally:
if video_path and raw_events:
try:
_save_events_csv(video_path, raw_events)
except Exception:
pass
done.set()
th = threading.Thread(target=_listen, daemon=True)
@@ -96,17 +132,16 @@ def _assert_detection_dto(d: dict) -> None:
@pytest.mark.slow
@pytest.mark.timeout(120)
@pytest.mark.timeout(900)
def test_ft_p_10_frame_sampling_ac1(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
video_short_path,
sse_client_factory,
):
media_id = f"video-{uuid.uuid4().hex}"
body = _base_ai_body(mock_loader_url, video_short_path)
body = _base_ai_body(video_short_path)
body["frame_period_recognition"] = 4
collected = _run_async_video_sse(
http_client,
@@ -123,17 +158,16 @@ def test_ft_p_10_frame_sampling_ac1(
@pytest.mark.slow
@pytest.mark.timeout(120)
@pytest.mark.timeout(900)
def test_ft_p_11_annotation_interval_ac2(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
video_short_path,
sse_client_factory,
):
media_id = f"video-{uuid.uuid4().hex}"
body = _base_ai_body(mock_loader_url, video_short_path)
body = _base_ai_body(video_short_path)
body["frame_recognition_seconds"] = 2
collected = _run_async_video_sse(
http_client,
@@ -158,17 +192,16 @@ def test_ft_p_11_annotation_interval_ac2(
@pytest.mark.slow
@pytest.mark.timeout(120)
@pytest.mark.timeout(900)
def test_ft_p_12_movement_tracking_ac3(
warm_engine,
http_client,
jwt_token,
mock_loader_url,
video_short_path,
sse_client_factory,
):
media_id = f"video-{uuid.uuid4().hex}"
body = _base_ai_body(mock_loader_url, video_short_path)
body = _base_ai_body(video_short_path)
body["tracking_distance_confidence"] = 0.1
body["tracking_probability_increase"] = 0.1
collected = _run_async_video_sse(