Surface YOLO preflight in Detection Lab
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-06 10:52:26 +02:00
parent 58608383cd
commit 7aa9382c9e
14 changed files with 275 additions and 1 deletions
+1
View File
@@ -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)
+6
View File
@@ -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
+11
View File
@@ -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(
@@ -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
@@ -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
+46
View File
@@ -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`.
+12
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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
+8
View File
@@ -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}
@@ -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({
</ul>
</div>
<div className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
<div className="ai-lab-section-header">
<div>
<h3>YOLO runtime preflight</h3>
<p>Read-only runtime status. This does not load a model, run inference or download weights.</p>
</div>
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
Refresh preflight
</button>
</div>
<div className="ai-lab-state-stack">
{loadingYoloPreflight ? (
<div className="result-state result-state-loading">
<strong>Loading YOLO preflight.</strong>
<p>Checking backend runtime configuration and optional dependency visibility.</p>
</div>
) : null}
{yoloPreflightError ? (
<div className="result-state result-state-error">
<strong>YOLO preflight unavailable.</strong>
<p>{yoloPreflightError}</p>
</div>
) : null}
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
<div className="result-state result-state-empty">
<strong>No YOLO preflight loaded.</strong>
<p>Refresh preflight to inspect the live backend AI runtime before running configured YOLO.</p>
</div>
) : null}
</div>
{yoloPreflight ? (
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
<div className="ai-lab-section-header">
<div>
<h3>Status: {yoloPreflight.status}</h3>
<p>{yoloPreflight.message}</p>
</div>
<span className={yoloPreflight.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{yoloPreflight.checks.dependencies_available ? 'dependencies visible' : 'not ready'}
</span>
</div>
<div className="lab-readiness-grid">
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>YOLO enabled</span>
<strong>{yoloPreflight.checks.enabled ? 'true' : 'false'}</strong>
</div>
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Dependencies</span>
<strong>{yoloPreflight.checks.dependencies_available === true ? 'available' : yoloPreflight.checks.dependencies_available === false ? 'unavailable' : 'not checked'}</strong>
</div>
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Local model file</span>
<strong>{yoloPreflight.checks.model_file_exists === true ? 'found' : yoloPreflight.checks.model_path_set ? 'missing' : 'not configured'}</strong>
</div>
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>CUDA</span>
<strong>{yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'}</strong>
</div>
</div>
<div className="entity-meta">
<span>torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'}</span>
<span>ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'}</span>
<span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span>
<span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span>
<span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span>
</div>
</div>
) : null}
</div>
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Detection run controls">
<h3>Run detection</h3>
@@ -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<DetectionQaResult | null>(null)
const [detectionQaError, setDetectionQaError] = useState<string | null>(null)
const [runningDetectionQa, setRunningDetectionQa] = useState(false)
const [yoloPreflight, setYoloPreflight] = useState<YoloPreflightResponse | null>(null)
const [loadingYoloPreflight, setLoadingYoloPreflight] = useState(false)
const [yoloPreflightError, setYoloPreflightError] = useState<string | null>(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,
+4 -1
View File
@@ -8,9 +8,10 @@ import type {
DetectionRunRead,
DetectionRunRequest,
DetectionRunResponse,
YoloPreflightResponse,
} from '../../types'
function queryString(params: Record<string, string | number | null | undefined>): string {
function queryString(params: Record<string, string | number | boolean | null | undefined>): 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<string, string | number | null | undefined>)
export const detectionApi = {
listModels: (): Promise<DetectionModelsResponse> => apiGet<DetectionModelsResponse>('/api/v1/detection/models'),
getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null } = {}): Promise<YoloPreflightResponse> =>
apiGet<YoloPreflightResponse>(`/api/v1/detection/yolo/preflight${queryString(params)}`),
run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> =>
apiPost<DetectionRunResponse>('/api/v1/detection/run', payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<DetectionRunListResponse> =>
+37
View File
@@ -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