From 7aa9382c9e14273d11f7241e7e0686e1e2d81612 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 10:52:26 +0200 Subject: [PATCH] Surface YOLO preflight in Detection Lab --- CHANGELOG.md | 1 + backend/README.md | 6 ++ backend/app/api/routes/detection.py | 11 +++ .../tests/test_sprint118_yolo_preflight_ui.py | 25 ++++++ backend/tests/test_sprint13_yolo_preflight.py | 21 +++++ docs/API_CONTRACTS.md | 46 +++++++++++ docs/CODEX_EXECUTION_LOG.md | 12 +++ docs/TODO.md | 1 + frontend/README.md | 1 + frontend/src/App.tsx | 8 ++ .../src/components/detection/DetectionLab.tsx | 79 +++++++++++++++++++ frontend/src/hooks/useDetectionWorkflow.ts | 23 ++++++ frontend/src/services/api/detection.ts | 5 +- frontend/src/types.ts | 37 +++++++++ 14 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_sprint118_yolo_preflight_ui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dafdcd93..7d52a20c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Hardened the AI Docker runtime with OpenCV native libraries required by Ultralytics and made YOLO dependency detection use real imports instead of optimistic module discovery. - Added a writable `YOLO_CONFIG_DIR` default under application storage so Ultralytics does not fall back to root user config paths in Docker/Unraid. - Added YOLO preflight runtime diagnostics for dependency assumption state, model directory, `YOLO_CONFIG_DIR`, installed `torch`/`ultralytics` versions and CUDA availability without running inference or downloading weights. +- Added a canonical `GET /api/v1/detection/yolo/preflight` endpoint and Detection Lab panel so operators can inspect live YOLO runtime readiness from the web UI. - Added static regression coverage for the road basemap, attribution, basemap policy notice, database layer selector and persisted operational GIS workflow wiring. ## Sprint 115 QA/QC and Exports usability layout pass (2026-07-04) diff --git a/backend/README.md b/backend/README.md index bfb799e2..8b9e4dd0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -301,6 +301,12 @@ python scripts/yolo_preflight.py --model-path /absolute/path/to/local-model.pt - The preflight checks configuration, dependency availability, local model file existence, tile manifest validity, tile count and referenced tile paths. JSON output also includes runtime diagnostics for the model directory, `YOLO_CONFIG_DIR`, installed `torch`/`ultralytics` versions and CUDA availability when dependency checks pass. It does not load a YOLO model, run inference or download weights. +The same read-only status is available through the API and Detection Lab UI: + +```bash +curl http://localhost:1202/api/v1/detection/yolo/preflight +``` + ### Run backend ```bash diff --git a/backend/app/api/routes/detection.py b/backend/app/api/routes/detection.py index 8aadac1b..7de11d8f 100644 --- a/backend/app/api/routes/detection.py +++ b/backend/app/api/routes/detection.py @@ -9,6 +9,7 @@ from app.db.session import get_db from app.schemas import DetectionQaRequest, DetectionRunRequest from app.services.detection_service import DetectionService from app.services.model_registry_service import ModelRegistryService +from app.services.yolo_preflight_service import YoloPreflightService from app.utils.response import envelope router = APIRouter(prefix="/detection", tags=["detection"]) @@ -19,6 +20,16 @@ def list_detection_models() -> dict: return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]}) +@router.get("/yolo/preflight", response_model=dict) +def get_yolo_preflight(tile_manifest_path: str | None = None, check_model_load: bool = False) -> dict: + return envelope( + YoloPreflightService.run( + tile_manifest_path=tile_manifest_path, + check_model_load=check_model_load, + ) + ) + + @router.post("/run", response_model=dict) def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict: result = DetectionService.run_detection( diff --git a/backend/tests/test_sprint118_yolo_preflight_ui.py b/backend/tests/test_sprint118_yolo_preflight_ui.py new file mode 100644 index 00000000..21251575 --- /dev/null +++ b/backend/tests/test_sprint118_yolo_preflight_ui.py @@ -0,0 +1,25 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_detection_lab_surfaces_yolo_runtime_preflight() -> None: + lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text( + encoding="utf-8" + ) + hook = (ROOT / "frontend" / "src" / "hooks" / "useDetectionWorkflow.ts").read_text(encoding="utf-8") + api = (ROOT / "frontend" / "src" / "services" / "api" / "detection.ts").read_text(encoding="utf-8") + types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8") + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "YOLO runtime preflight" in lab + assert "torch_version" in lab + assert "ultralytics_version" in lab + assert "cuda_available" in lab + assert "onRefreshYoloPreflight" in lab + assert "loadYoloPreflight" in hook + assert "getYoloPreflight" in api + assert "/api/v1/detection/yolo/preflight" in api + assert "interface YoloPreflightResponse" in types + assert "yoloPreflight={yoloPreflight}" in app diff --git a/backend/tests/test_sprint13_yolo_preflight.py b/backend/tests/test_sprint13_yolo_preflight.py index 0716f2f9..149190c6 100644 --- a/backend/tests/test_sprint13_yolo_preflight.py +++ b/backend/tests/test_sprint13_yolo_preflight.py @@ -5,7 +5,10 @@ import subprocess import sys from pathlib import Path +from fastapi.testclient import TestClient + from app.core.config import Settings +from app.main import app from app.services.yolo_preflight_service import YoloPreflightService @@ -213,3 +216,21 @@ def test_yolo_preflight_script_rejects_assumed_dependencies_for_model_load(tmp_p assert result.returncode != 0 assert "--check-model-load cannot be combined with --assume-dependencies" in result.stderr + + +def test_yolo_preflight_api_returns_canonical_envelope(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("YOLO_ENABLED", "false") + monkeypatch.setenv("YOLO_MODEL_PATH", str(tmp_path / "missing.pt")) + monkeypatch.setenv("YOLO_CONFIG_DIR", str(tmp_path / "ultralytics")) + + response = TestClient(app).get("/api/v1/detection/yolo/preflight") + + assert response.status_code == 200 + payload = response.json() + assert set(payload) == {"data"} + assert payload["data"]["status"] == "not_configured" + assert payload["data"]["checks"]["enabled"] is False + assert payload["data"]["runtime"]["model_directory"] == str(tmp_path) + assert payload["data"]["runtime"]["yolo_config_dir"] == str(tmp_path / "ultralytics") + assert payload["data"]["will_download_models"] is False + assert payload["data"]["will_run_inference"] is False diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index cdf7259d..1c09f54c 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -636,6 +636,52 @@ Returns object-detection model capability descriptors. } ``` +### GET `/api/v1/detection/yolo/preflight` + +Returns a canonical envelope with read-only configured-YOLO runtime preflight +state. Optional query parameters: + +- `tile_manifest_path`: existing raster tile manifest path to validate. +- `check_model_load`: default `false`; when `true`, explicitly loads only the + configured local model file for compatibility smoke. It never downloads + weights and never runs inference. + +Response data: + +```json +{ + "model_id": "yolo-configured", + "model_path": null, + "tile_manifest_path": null, + "status": "not_configured", + "message": "YOLO_MODEL_PATH is not set. GeoIntel will not download model weights automatically.", + "checks": { + "enabled": true, + "dependencies_available": true, + "model_path_set": false, + "model_file_exists": null, + "model_load_requested": false, + "model_load_ok": null, + "manifest_path_set": null, + "manifest_valid": null, + "tile_paths_exist": null, + "tile_limit_ok": null + }, + "runtime": { + "dependencies_assumed": false, + "model_directory": null, + "yolo_config_dir": "/app/storage/ultralytics", + "torch_version": "2.12.1", + "ultralytics_version": "8.4.88", + "cuda_available": false + }, + "tile_count": 0, + "max_tiles": 100, + "will_download_models": false, + "will_run_inference": false +} +``` + ### POST `/api/v1/detection/run` Creates a detection job and detection analysis run. If the requested model is unavailable, the job and analysis run are marked `failed` with `DETECTION_MODEL_UNAVAILABLE` or `DETECTION_DEPENDENCY_UNAVAILABLE`. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 341c8e4c..5fdf4bad 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -8,6 +8,7 @@ Changed: - Hardened the AI image path after Tower validation showed `torch` imported but `ultralytics` failed on a missing OpenCV native library. The Dockerfiles now include the required OpenCV runtime shared libraries and YOLO dependency detection performs real imports instead of `find_spec` checks. - Added a writable `YOLO_CONFIG_DIR` default under application storage after Tower validation showed Ultralytics otherwise falls back to `/tmp` because root config is not writable in the container. - Added YOLO preflight runtime diagnostics so operators can see dependency assumption state, model directory, `YOLO_CONFIG_DIR`, installed `torch`/`ultralytics` package versions and CUDA availability without loading a model, running inference or downloading weights. +- Added a read-only `GET /api/v1/detection/yolo/preflight` endpoint and Detection Lab YOLO runtime preflight panel so browser operators can inspect live AI runtime readiness without loading a model, running inference or downloading weights. - Updated `.env.example`, `backend/README.md`, `frontend/README.md`, `scripts/README.md`, `docs/AI_PIPELINES.md`, `docs/TODO.md` and `CHANGELOG.md`. - Added regression coverage in `backend/tests/test_sprint116_operational_gis_map_workflow.py`, `backend/tests/test_sprint8b_yolo_foundation.py` and `backend/tests/test_docker_runtime_config.py`. @@ -54,6 +55,17 @@ Validation: - Deploy-time live migration smoke passed on Tower; PostGIS reported `3.6 USE_GEOS=1 USE_PROJ=1 USE_STATS=1` and Alembic head was `202606120900`. - Deploy-time browser runtime verification passed for `http://192.168.10.150:1202`, API proxy and icon. - Tower container check passed: remote checkout is `7a29e78`, `geointel` is healthy on `0.0.0.0:1202->80/tcp`, and `scripts/yolo_preflight.py --enabled --json` reports `dependencies_available=true`, `torch_version=2.12.1`, `ultralytics_version=8.4.88`, `cuda_available=false`, `yolo_config_dir=/app/storage/ultralytics`, `status=not_configured`, `will_download_models=false` and `will_run_inference=false`. +- RED: `python -m pytest backend\tests\test_sprint13_yolo_preflight.py::test_yolo_preflight_api_returns_canonical_envelope backend\tests\test_sprint118_yolo_preflight_ui.py -q` failed before implementation because `/api/v1/detection/yolo/preflight` returned 404 and the Detection Lab did not surface a YOLO runtime preflight panel. +- `python -m pytest backend\tests\test_sprint13_yolo_preflight.py::test_yolo_preflight_api_returns_canonical_envelope backend\tests\test_sprint118_yolo_preflight_ui.py -q` passed: 2 tests. +- `cd frontend && npm run typecheck` passed after adding the preflight API client and Detection Lab panel. +- `python -m pytest backend\tests\test_sprint13_yolo_preflight.py backend\tests\test_sprint118_yolo_preflight_ui.py backend\tests\test_sprint48_api_contract_audit.py -q` passed: 13 tests. +- `python -m compileall backend/app` passed. +- `cd backend && python -m pytest -q` passed: 371 tests. +- `cd frontend && npm run build` passed. +- `bash scripts/run_readiness_check.sh` passed: 371 backend tests plus frontend typecheck/build and API contract audit for 80 documented routes. +- `cd backend && python -m alembic heads` passed: `202606120900 (head)`. +- `cd backend && python -m alembic upgrade head --sql` passed. +- `bash -n scripts/live_migration_smoke.sh` and `bash -n scripts/deploy_tower.sh` passed. Limitations: - `GEOINTEL_INSTALL_AI=true` installs optional PyTorch/Ultralytics dependencies but still requires a user-provided local model file; GeoIntel does not download weights. diff --git a/docs/TODO.md b/docs/TODO.md index 8e886689..ba24c733 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -80,6 +80,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Add basemap policy notice and guided GIS query-to-QA/export workflow in the Map workspace. - [x] Add reusable latest-result mode for repeated Map QA/QC runs without duplicate derived artifacts. - [x] Add opt-in Docker/Unraid AI build/runtime path for local PyTorch/Ultralytics YOLO operation. +- [x] Surface configured-YOLO runtime preflight status through the API and Detection Lab UI. - [x] Add one-click full GIS workflow action for query, derived dataset, QA/QC and export handoff. - [x] Add QA/QC workspace result hierarchy and filter density polish. - [x] Add Change Detection panel hierarchy and analysis workspace density polish. diff --git a/frontend/README.md b/frontend/README.md index a78c6bb9..fc447b9c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -120,6 +120,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst ## Sprint 8B additions - Detection Lab now exposes the `yolo-configured` capability reported by the backend. - When `yolo-configured` is selected, users can provide an existing raster tile manifest path. +- Detection Lab includes a read-only YOLO runtime preflight panel with backend status, dependency visibility, local model configuration, `torch`/`ultralytics` versions, CUDA state and `YOLO_CONFIG_DIR`. - The UI still does not download models or create fake detections; backend status and error codes remain the source of truth. ## Sprint 8C additions diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d47b7fd7..2487bb6a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -242,7 +242,11 @@ function App(): JSX.Element { detectionQaResult, detectionQaError, runningDetectionQa, + yoloPreflight, + loadingYoloPreflight, + yoloPreflightError, loadDetectionModels, + loadYoloPreflight, loadDetectionRuns, loadDetectionResults, runDetection, @@ -945,10 +949,14 @@ function App(): JSX.Element { detectionQaResult={detectionQaResult} detectionQaError={detectionQaError} runningDetectionQa={runningDetectionQa} + yoloPreflight={yoloPreflight} + loadingYoloPreflight={loadingYoloPreflight} + yoloPreflightError={yoloPreflightError} selectedProjectId={selectedProjectId} rasterDatasets={rasterDatasets} referenceDatasets={referenceDatasets} onLoadModels={loadDetectionModels} + onRefreshYoloPreflight={() => loadYoloPreflight()} onSelectDataset={setSelectedDetectionDatasetId} onSelectModel={setSelectedDetectionModelId} onSetConfidenceThreshold={setDetectionConfidenceThreshold} diff --git a/frontend/src/components/detection/DetectionLab.tsx b/frontend/src/components/detection/DetectionLab.tsx index cdf7ba56..85600f59 100644 --- a/frontend/src/components/detection/DetectionLab.tsx +++ b/frontend/src/components/detection/DetectionLab.tsx @@ -5,6 +5,7 @@ import type { DetectionRead, DetectionRunRead, DetectionRunResponse, + YoloPreflightResponse, } from '../../types' interface DetectionLabProps { @@ -28,10 +29,14 @@ interface DetectionLabProps { detectionQaResult: DetectionQaResult | null detectionQaError: string | null runningDetectionQa: boolean + yoloPreflight: YoloPreflightResponse | null + loadingYoloPreflight: boolean + yoloPreflightError: string | null selectedProjectId: string | null rasterDatasets: DatasetCreateResponse[] referenceDatasets: DatasetCreateResponse[] onLoadModels: () => void + onRefreshYoloPreflight: () => void onSelectDataset: (datasetId: string) => void onSelectModel: (modelId: string) => void onSetConfidenceThreshold: (value: number) => void @@ -67,10 +72,14 @@ export function DetectionLab({ detectionQaResult, detectionQaError, runningDetectionQa, + yoloPreflight, + loadingYoloPreflight, + yoloPreflightError, selectedProjectId, rasterDatasets, referenceDatasets, onLoadModels, + onRefreshYoloPreflight, onSelectDataset, onSelectModel, onSetConfidenceThreshold, @@ -164,6 +173,76 @@ export function DetectionLab({ +
+
+
+

YOLO runtime preflight

+

Read-only runtime status. This does not load a model, run inference or download weights.

+
+ +
+
+ {loadingYoloPreflight ? ( +
+ Loading YOLO preflight. +

Checking backend runtime configuration and optional dependency visibility.

+
+ ) : null} + {yoloPreflightError ? ( +
+ YOLO preflight unavailable. +

{yoloPreflightError}

+
+ ) : null} + {!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? ( +
+ No YOLO preflight loaded. +

Refresh preflight to inspect the live backend AI runtime before running configured YOLO.

+
+ ) : null} +
+ {yoloPreflight ? ( +
+
+
+

Status: {yoloPreflight.status}

+

{yoloPreflight.message}

+
+ + {yoloPreflight.checks.dependencies_available ? 'dependencies visible' : 'not ready'} + +
+
+
+ YOLO enabled + {yoloPreflight.checks.enabled ? 'true' : 'false'} +
+
+ Dependencies + {yoloPreflight.checks.dependencies_available === true ? 'available' : yoloPreflight.checks.dependencies_available === false ? 'unavailable' : 'not checked'} +
+
+ Local model file + {yoloPreflight.checks.model_file_exists === true ? 'found' : yoloPreflight.checks.model_path_set ? 'missing' : 'not configured'} +
+
+ CUDA + {yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'} +
+
+
+ torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'} + ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'} + cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')} + YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'} + model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'} +
+
+ ) : null} +
+

Run detection

diff --git a/frontend/src/hooks/useDetectionWorkflow.ts b/frontend/src/hooks/useDetectionWorkflow.ts index c040db4b..a7808ea9 100644 --- a/frontend/src/hooks/useDetectionWorkflow.ts +++ b/frontend/src/hooks/useDetectionWorkflow.ts @@ -8,6 +8,7 @@ import type { DetectionRunRead, DetectionRunResponse, QualityCheckRead, + YoloPreflightResponse, } from '../types' import { formatError } from '../lib/formatError' @@ -47,6 +48,9 @@ export function useDetectionWorkflow({ const [detectionQaResult, setDetectionQaResult] = useState(null) const [detectionQaError, setDetectionQaError] = useState(null) const [runningDetectionQa, setRunningDetectionQa] = useState(false) + const [yoloPreflight, setYoloPreflight] = useState(null) + const [loadingYoloPreflight, setLoadingYoloPreflight] = useState(false) + const [yoloPreflightError, setYoloPreflightError] = useState(null) const loadDetectionModels = async () => { setLoadingDetectionModels(true) @@ -64,6 +68,21 @@ export function useDetectionWorkflow({ } } + const loadYoloPreflight = async (tileManifestPath = detectionTileManifestPath) => { + setLoadingYoloPreflight(true) + setYoloPreflightError(null) + try { + const response = await detectionApi.getYoloPreflight({ + tile_manifest_path: tileManifestPath.trim() || null, + }) + setYoloPreflight(response) + } catch (error) { + setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status')) + } finally { + setLoadingYoloPreflight(false) + } + } + const loadDetectionRuns = async (projectId = selectedProjectId) => { if (!projectId) { setDetectionRuns([]) @@ -199,7 +218,11 @@ export function useDetectionWorkflow({ detectionQaResult, detectionQaError, runningDetectionQa, + yoloPreflight, + loadingYoloPreflight, + yoloPreflightError, loadDetectionModels, + loadYoloPreflight, loadDetectionRuns, loadDetectionResults, runDetection, diff --git a/frontend/src/services/api/detection.ts b/frontend/src/services/api/detection.ts index d1eab0ba..3c892ead 100644 --- a/frontend/src/services/api/detection.ts +++ b/frontend/src/services/api/detection.ts @@ -8,9 +8,10 @@ import type { DetectionRunRead, DetectionRunRequest, DetectionRunResponse, + YoloPreflightResponse, } from '../../types' -function queryString(params: Record): string { +function queryString(params: Record): string { const searchParams = new URLSearchParams() Object.entries(params).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') { @@ -23,6 +24,8 @@ function queryString(params: Record) export const detectionApi = { listModels: (): Promise => apiGet('/api/v1/detection/models'), + getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null } = {}): Promise => + apiGet(`/api/v1/detection/yolo/preflight${queryString(params)}`), run: (payload: DetectionRunRequest): Promise => apiPost('/api/v1/detection/run', payload), listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise => diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ddff361e..60ef0a09 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -404,6 +404,43 @@ export interface DetectionModelsResponse { models: DetectionModelCapability[] } +export interface YoloPreflightChecks { + enabled: boolean + dependencies_available?: boolean | null + model_path_set?: boolean | null + model_file_exists?: boolean | null + model_load_requested: boolean + model_load_ok?: boolean | null + manifest_path_set?: boolean | null + manifest_valid?: boolean | null + tile_paths_exist?: boolean | null + tile_limit_ok?: boolean | null +} + +export interface YoloPreflightRuntime { + dependencies_assumed: boolean + model_directory?: string | null + yolo_config_dir?: string | null + torch_version?: string | null + ultralytics_version?: string | null + cuda_available?: boolean | null +} + +export interface YoloPreflightResponse { + model_id: string + model_path?: string | null + tile_manifest_path?: string | null + status: string + message: string + checks: YoloPreflightChecks + runtime: YoloPreflightRuntime + tile_count: number + max_tiles: number + will_download_models: boolean + will_run_inference: boolean + error_code?: string | null +} + export interface DetectionRunRequest { project_id: string dataset_id: string