Add local model asset catalog
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 20:59:03 +02:00
parent 9e20cc82ae
commit 6e2a8cbdc4
33 changed files with 660 additions and 7 deletions
+1
View File
@@ -6,6 +6,7 @@ STORAGE_ROOT=./storage
MAX_UPLOAD_MB=500 MAX_UPLOAD_MB=500
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202 CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
YOLO_ENABLED=false YOLO_ENABLED=false
YOLO_MODELS_DIR=/app/models
YOLO_MODEL_PATH= YOLO_MODEL_PATH=
YOLO_MODEL_ID=yolo-configured YOLO_MODEL_ID=yolo-configured
YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
+9
View File
@@ -7,6 +7,15 @@
# Changelog # Changelog
## Sprint 118 Local model and reference catalog clarity (2026-07-06)
- Added a read-only local model asset catalog endpoint at `GET /api/v1/detection/model-assets`.
- Added `YOLO_MODELS_DIR` so Docker/Unraid runtimes can expose mounted model files as selectable assets without downloading weights.
- Detection runs and YOLO preflight can now accept `model_asset_id` for `yolo-configured`, with backend-side resolution to a cataloged local file.
- Detection Lab now shows a local model asset picker with active-file, size and checksum context.
- Provider Capabilities now explicitly labels GRB/OSM/manual/fixture as reference-data source capabilities, not AI model choices.
- Added regression coverage for the backend model asset catalog and frontend model asset wiring.
## Sprint 117 Safe local YOLO model activation (2026-07-06) ## Sprint 117 Safe local YOLO model activation (2026-07-06)
- Added `scripts/configure_yolo_model.py` to configure an existing local YOLO model into the Unraid/Tower `.env` file without downloading weights, loading a model or running inference. - Added `scripts/configure_yolo_model.py` to configure an existing local YOLO model into the Unraid/Tower `.env` file without downloading weights, loading a model or running inference.
+17
View File
@@ -125,6 +125,7 @@ bash scripts/live_migration_smoke.sh
- detection service boundary for creating jobs, analysis runs and dependency-aware unavailable responses. - detection service boundary for creating jobs, analysis runs and dependency-aware unavailable responses.
- Added detection endpoints: - Added detection endpoints:
- `GET /api/v1/detection/models` - `GET /api/v1/detection/models`
- `GET /api/v1/detection/model-assets`
- `POST /api/v1/detection/run` - `POST /api/v1/detection/run`
- `GET /api/v1/detection/runs/{analysis_run_id}` - `GET /api/v1/detection/runs/{analysis_run_id}`
- `GET /api/v1/detection/runs/{analysis_run_id}/detections` - `GET /api/v1/detection/runs/{analysis_run_id}/detections`
@@ -239,6 +240,7 @@ Configured YOLO requires:
```bash ```bash
YOLO_ENABLED=true YOLO_ENABLED=true
YOLO_MODELS_DIR=/absolute/path/to/models
YOLO_MODEL_PATH=/absolute/path/to/local-model.pt YOLO_MODEL_PATH=/absolute/path/to/local-model.pt
``` ```
@@ -260,6 +262,7 @@ directory, mounted as `/app/models` by default:
```bash ```bash
GEOINTEL_MODELS_PATH=/mnt/user/appdata/geointel/models GEOINTEL_MODELS_PATH=/mnt/user/appdata/geointel/models
YOLO_ENABLED=true YOLO_ENABLED=true
YOLO_MODELS_DIR=/app/models
YOLO_MODEL_PATH=/app/models/local-model.pt YOLO_MODEL_PATH=/app/models/local-model.pt
``` ```
@@ -275,6 +278,19 @@ python scripts/configure_yolo_model.py \
The smoke loads only the supplied local model file, does not run inference and The smoke loads only the supplied local model file, does not run inference and
does not download weights. does not download weights.
The backend also exposes a read-only model asset catalog for the mounted model
directory:
```bash
curl http://localhost:1202/api/v1/detection/model-assets
```
The catalog lists local `.pt`, `.onnx` and `.engine` files with size, SHA-256
and active-model status. Detection runs may submit `model_asset_id` with
`model_id="yolo-configured"` to use a cataloged local model for that run. The
backend resolves the ID to a file inside `YOLO_MODELS_DIR`; browser clients do
not send arbitrary model paths.
Configured YOLO inference uses raster tile artifacts from the existing tile Configured YOLO inference uses raster tile artifacts from the existing tile
manifest flow. Single-band or otherwise non-RGB tile images are converted to a manifest flow. Single-band or otherwise non-RGB tile images are converted to a
temporary RGB prediction image before inference; georeferencing still comes temporary RGB prediction image before inference; georeferencing still comes
@@ -286,6 +302,7 @@ Optional tuning:
YOLO_MODEL_ID=yolo-configured YOLO_MODEL_ID=yolo-configured
YOLO_MODEL_DISPLAY_NAME="Configured YOLO detector" YOLO_MODEL_DISPLAY_NAME="Configured YOLO detector"
YOLO_MODEL_VERSION=local-v1 YOLO_MODEL_VERSION=local-v1
YOLO_MODELS_DIR=/app/models
YOLO_CONFIG_DIR=/app/storage/ultralytics YOLO_CONFIG_DIR=/app/storage/ultralytics
YOLO_DEVICE=cpu YOLO_DEVICE=cpu
YOLO_IMAGE_SIZE=640 YOLO_IMAGE_SIZE=640
+13 -1
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from app.db.session import get_db from app.db.session import get_db
from app.schemas import DetectionQaRequest, DetectionRunRequest from app.schemas import DetectionQaRequest, DetectionRunRequest
from app.services.detection_service import DetectionService from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService from app.services.model_registry_service import ModelRegistryService
from app.services.yolo_preflight_service import YoloPreflightService from app.services.yolo_preflight_service import YoloPreflightService
from app.utils.response import envelope from app.utils.response import envelope
@@ -20,12 +21,22 @@ def list_detection_models() -> dict:
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]}) return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
@router.get("/model-assets", response_model=dict)
def list_detection_model_assets() -> dict:
return envelope(ModelAssetCatalogService.list_assets().model_dump())
@router.get("/yolo/preflight", response_model=dict) @router.get("/yolo/preflight", response_model=dict)
def get_yolo_preflight(tile_manifest_path: str | None = None, check_model_load: bool = False) -> dict: def get_yolo_preflight(
tile_manifest_path: str | None = None,
check_model_load: bool = False,
model_asset_id: str | None = None,
) -> dict:
return envelope( return envelope(
YoloPreflightService.run( YoloPreflightService.run(
tile_manifest_path=tile_manifest_path, tile_manifest_path=tile_manifest_path,
check_model_load=check_model_load, check_model_load=check_model_load,
model_asset_id=model_asset_id,
) )
) )
@@ -37,6 +48,7 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -
project_id=payload.project_id, project_id=payload.project_id,
dataset_id=payload.dataset_id, dataset_id=payload.dataset_id,
model_id=payload.model_id, model_id=payload.model_id,
model_asset_id=payload.model_asset_id,
confidence_threshold=payload.confidence_threshold, confidence_threshold=payload.confidence_threshold,
class_filter=payload.class_filter, class_filter=payload.class_filter,
tile_manifest_path=payload.tile_manifest_path, tile_manifest_path=payload.tile_manifest_path,
+1
View File
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL") log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL")
database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS") database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS")
yolo_enabled: bool = Field(default=False, validation_alias="YOLO_ENABLED") yolo_enabled: bool = Field(default=False, validation_alias="YOLO_ENABLED")
yolo_models_dir: str = Field(default="/app/models", validation_alias="YOLO_MODELS_DIR")
yolo_model_path: str | None = Field(default=None, validation_alias="YOLO_MODEL_PATH") yolo_model_path: str | None = Field(default=None, validation_alias="YOLO_MODEL_PATH")
yolo_model_id: str = Field(default="yolo-configured", validation_alias="YOLO_MODEL_ID") yolo_model_id: str = Field(default="yolo-configured", validation_alias="YOLO_MODEL_ID")
yolo_model_display_name: str = Field(default="Configured YOLO detector", validation_alias="YOLO_MODEL_DISPLAY_NAME") yolo_model_display_name: str = Field(default="Configured YOLO detector", validation_alias="YOLO_MODEL_DISPLAY_NAME")
+4
View File
@@ -15,6 +15,8 @@ from .detection import (
DetectionRunRead, DetectionRunRead,
DetectionRunRequest, DetectionRunRequest,
DetectionRunResponse, DetectionRunResponse,
ModelAssetListResponse,
ModelAssetRead,
) )
from .segmentation import ( from .segmentation import (
SegmentationListResponse, SegmentationListResponse,
@@ -105,6 +107,8 @@ __all__ = [
"DetectionRunRead", "DetectionRunRead",
"DetectionRunRequest", "DetectionRunRequest",
"DetectionRunResponse", "DetectionRunResponse",
"ModelAssetListResponse",
"ModelAssetRead",
"SegmentationListResponse", "SegmentationListResponse",
"SegmentationModelCapability", "SegmentationModelCapability",
"SegmentationModelsResponse", "SegmentationModelsResponse",
+23
View File
@@ -22,10 +22,33 @@ class DetectionModelsResponse(BaseModel):
models: list[DetectionModelCapability] models: list[DetectionModelCapability]
class ModelAssetRead(BaseModel):
model_asset_id: str
filename: str
display_name: str
model_path: str
suffix: str
framework: str
task_type: str
size_bytes: int
sha256: str
active: bool
status: str
limitation_message: str
will_download_models: bool = False
class ModelAssetListResponse(BaseModel):
items: list[ModelAssetRead]
total: int
model_directory: str
class DetectionRunRequest(BaseModel): class DetectionRunRequest(BaseModel):
project_id: UUID project_id: UUID
dataset_id: UUID dataset_id: UUID
model_id: str model_id: str
model_asset_id: str | None = None
confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0) confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
class_filter: list[str] | None = None class_filter: list[str] | None = None
tile_manifest_path: str | None = None tile_manifest_path: str | None = None
+10
View File
@@ -15,6 +15,7 @@ from app.core.errors import AppError
from app.models import AnalysisRun, Dataset, Detection, Job, Project, VectorFeature from app.models import AnalysisRun, Dataset, Detection, Job, Project, VectorFeature
from app.schemas.detection import DetectionListResponse, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunResponse from app.schemas.detection import DetectionListResponse, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunResponse
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService from app.services.model_registry_service import ModelRegistryService
from app.services.qa_service import QaService from app.services.qa_service import QaService
from app.services.quality_service import QualityService from app.services.quality_service import QualityService
@@ -33,6 +34,7 @@ class DetectionService:
dataset_id: uuid.UUID, dataset_id: uuid.UUID,
model_id: str, model_id: str,
confidence_threshold: float, confidence_threshold: float,
model_asset_id: str | None = None,
class_filter: list[str] | None = None, class_filter: list[str] | None = None,
tile_manifest_path: str | None = None, tile_manifest_path: str | None = None,
parameters_json: dict[str, Any] | None = None, parameters_json: dict[str, Any] | None = None,
@@ -55,6 +57,11 @@ class DetectionService:
status_code=400, status_code=400,
) )
selected_model_asset = None
if model_id == resolved_settings.yolo_model_id and model_asset_id:
selected_model_asset = ModelAssetCatalogService.resolve_asset(model_asset_id, settings=resolved_settings)
resolved_settings = ModelAssetCatalogService.settings_for_asset(resolved_settings, selected_model_asset)
model = ModelRegistryService.get_model_capability( model = ModelRegistryService.get_model_capability(
model_id, model_id,
settings=resolved_settings, settings=resolved_settings,
@@ -77,6 +84,9 @@ class DetectionService:
run_parameters = { run_parameters = {
"model_id": model.model_id, "model_id": model.model_id,
"model_asset_id": selected_model_asset.model_asset_id if selected_model_asset else None,
"model_asset_path": selected_model_asset.model_path if selected_model_asset else None,
"model_asset_sha256": selected_model_asset.sha256 if selected_model_asset else None,
"confidence_threshold": confidence_threshold, "confidence_threshold": confidence_threshold,
"class_filter": class_filter or [], "class_filter": class_filter or [],
"tile_manifest_path": tile_manifest_path, "tile_manifest_path": tile_manifest_path,
@@ -0,0 +1,101 @@
from __future__ import annotations
import hashlib
import re
from pathlib import Path
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.schemas.detection import ModelAssetListResponse, ModelAssetRead
class ModelAssetCatalogService:
SUPPORTED_SUFFIXES = {
".pt": "ultralytics/pytorch",
".onnx": "onnx",
".engine": "tensorrt",
}
@staticmethod
def list_assets(settings: Settings | None = None) -> ModelAssetListResponse:
resolved_settings = settings or get_settings()
model_directory = ModelAssetCatalogService._model_directory(resolved_settings)
active_model_path = ModelAssetCatalogService._resolved_file_path(resolved_settings.yolo_model_path)
if not model_directory.exists() or not model_directory.is_dir():
return ModelAssetListResponse(items=[], total=0, model_directory=str(model_directory))
items = [
ModelAssetCatalogService._asset_from_file(path, active_model_path=active_model_path)
for path in sorted(model_directory.iterdir(), key=lambda item: item.name.lower())
if path.is_file() and path.suffix.lower() in ModelAssetCatalogService.SUPPORTED_SUFFIXES
]
return ModelAssetListResponse(items=items, total=len(items), model_directory=str(model_directory))
@staticmethod
def resolve_asset(model_asset_id: str, settings: Settings | None = None) -> ModelAssetRead:
normalized = model_asset_id.strip()
for asset in ModelAssetCatalogService.list_assets(settings=settings).items:
if asset.model_asset_id == normalized:
return asset
raise AppError(
code="DETECTION_MODEL_ASSET_NOT_FOUND",
message="Selected local model asset was not found in the configured model directory",
details={"model_asset_id": normalized},
status_code=404,
)
@staticmethod
def settings_for_asset(settings: Settings, asset: ModelAssetRead) -> Settings:
return settings.model_copy(update={"yolo_model_path": asset.model_path})
@staticmethod
def _model_directory(settings: Settings) -> Path:
configured_directory = Path(settings.yolo_models_dir).expanduser()
if configured_directory.exists() and configured_directory.is_dir():
return configured_directory.resolve()
active_model_path = ModelAssetCatalogService._resolved_file_path(settings.yolo_model_path)
if active_model_path and active_model_path.parent.exists() and active_model_path.parent.is_dir():
return active_model_path.parent.resolve()
return configured_directory.resolve()
@staticmethod
def _asset_from_file(path: Path, *, active_model_path: Path | None) -> ModelAssetRead:
resolved_path = path.resolve()
return ModelAssetRead(
model_asset_id=ModelAssetCatalogService._asset_id(path),
filename=path.name,
display_name=path.stem,
model_path=str(resolved_path),
suffix=path.suffix.lower(),
framework=ModelAssetCatalogService.SUPPORTED_SUFFIXES[path.suffix.lower()],
task_type="object_detection",
size_bytes=path.stat().st_size,
sha256=ModelAssetCatalogService._sha256(path),
active=active_model_path == resolved_path,
status="available",
limitation_message="Local runtime model asset. GeoIntel will not download or mutate model weights.",
will_download_models=False,
)
@staticmethod
def _asset_id(path: Path) -> str:
raw = f"{path.stem}-{path.suffix.lower().lstrip('.')}"
normalized = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-")
return normalized or "model-asset"
@staticmethod
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@staticmethod
def _resolved_file_path(raw_path: str | None) -> Path | None:
if not raw_path:
return None
path = Path(raw_path).expanduser()
if not path.exists() or not path.is_file():
return None
return path.resolve()
@@ -8,6 +8,7 @@ from typing import Any, Type
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.services.detection_service import DetectionService from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.yolo_adapter import YoloDetectionAdapter from app.services.yolo_adapter import YoloDetectionAdapter
@@ -20,10 +21,16 @@ class YoloPreflightService:
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter, yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
assume_dependencies: bool = False, assume_dependencies: bool = False,
check_model_load: bool = False, check_model_load: bool = False,
model_asset_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
resolved_settings = settings or get_settings() resolved_settings = settings or get_settings()
selected_asset = None
if model_asset_id:
selected_asset = ModelAssetCatalogService.resolve_asset(model_asset_id, settings=resolved_settings)
resolved_settings = ModelAssetCatalogService.settings_for_asset(resolved_settings, selected_asset)
result: dict[str, Any] = { result: dict[str, Any] = {
"model_id": resolved_settings.yolo_model_id, "model_id": resolved_settings.yolo_model_id,
"model_asset_id": selected_asset.model_asset_id if selected_asset else None,
"model_path": resolved_settings.yolo_model_path, "model_path": resolved_settings.yolo_model_path,
"tile_manifest_path": tile_manifest_path, "tile_manifest_path": tile_manifest_path,
"status": "not_configured", "status": "not_configured",
@@ -80,6 +80,7 @@ def test_env_example_uses_runtime_env_names_read_by_backend_and_frontend() -> No
assert "GEOINTEL_INSTALL_AI=false" in env_example assert "GEOINTEL_INSTALL_AI=false" in env_example
assert "YOLO_ENABLED=false" in env_example assert "YOLO_ENABLED=false" in env_example
assert "YOLO_MODELS_DIR=/app/models" in env_example
assert "YOLO_MODEL_PATH=" in env_example assert "YOLO_MODEL_PATH=" in env_example
assert "YOLO_CONFIG_DIR=./storage/ultralytics" in env_example assert "YOLO_CONFIG_DIR=./storage/ultralytics" in env_example
assert "YOLO_MAX_TILES=100" in env_example assert "YOLO_MAX_TILES=100" in env_example
@@ -231,6 +232,8 @@ def test_unraid_deploy_passes_ai_build_arg_and_yolo_runtime_env() -> None:
assert 'YOLO_ENABLED="${YOLO_ENABLED:-false}"' in run_script assert 'YOLO_ENABLED="${YOLO_ENABLED:-false}"' in run_script
assert '-e YOLO_ENABLED="$YOLO_ENABLED"' in run_script assert '-e YOLO_ENABLED="$YOLO_ENABLED"' in run_script
assert 'YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}"' in run_script
assert '-e YOLO_MODELS_DIR="$YOLO_MODELS_DIR"' in run_script
assert '-e YOLO_MODEL_PATH="$YOLO_MODEL_PATH"' in run_script assert '-e YOLO_MODEL_PATH="$YOLO_MODEL_PATH"' in run_script
assert '-e YOLO_MAX_TILES="$YOLO_MAX_TILES"' in run_script assert '-e YOLO_MAX_TILES="$YOLO_MAX_TILES"' in run_script
assert "-v \"${GEOINTEL_MODELS_PATH}:/app/models\"" in run_script assert "-v \"${GEOINTEL_MODELS_PATH}:/app/models\"" in run_script
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings
from app.core.errors import AppError
from app.main import app
from app.models import AnalysisRun, Dataset, Detection, Job, Project
from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService
class FakeSession:
def __init__(self, objects=None) -> None:
self.objects = objects or {}
self.added = []
self.commits = 0
self.refreshes = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
def add(self, item) -> None:
self.added.append(item)
if getattr(item, "id", None) is not None:
self.objects[(item.__class__, item.id)] = item
def commit(self) -> None:
self.commits += 1
def refresh(self, item) -> None:
self.refreshes.append(item)
class MockYoloAdapter:
def __init__(self, settings: Settings) -> None:
self.settings = settings
@staticmethod
def dependencies_available() -> bool:
return True
def load_model(self, model_path: Path):
return {"model_path": str(model_path)}
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
assert model["model_path"].endswith("building-detector.pt")
return [
{
"class_name": "building",
"confidence": 0.9,
"bbox": [10.0, 20.0, 30.0, 40.0],
"properties": {"adapter": "mock"},
}
]
def _project_and_raster_dataset():
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Geel")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="source.tif",
dataset_type="raster",
source="user_upload",
storage_path="storage/uploads/source.tif",
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id
def _manifest(tmp_path: Path) -> Path:
tile_path = tmp_path / "tile_0000.tif"
tile_path.write_bytes(b"tile")
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(
json.dumps(
{
"tile_set_id": "tiles-fixture",
"count": 1,
"tiles": [
{
"path": str(tile_path),
"pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"index": 0,
}
],
}
),
encoding="utf-8",
)
return manifest_path
def test_model_asset_catalog_lists_supported_local_model_files(tmp_path: Path) -> None:
model_file = tmp_path / "building-detector.pt"
model_file.write_bytes(b"local model")
ignored_file = tmp_path / "notes.txt"
ignored_file.write_text("ignore me", encoding="utf-8")
settings = Settings(yolo_models_dir=str(tmp_path), yolo_model_path=str(model_file), yolo_enabled=True)
response = ModelAssetCatalogService.list_assets(settings=settings)
assert response.total == 1
asset = response.items[0]
assert asset.model_asset_id == "building-detector-pt"
assert asset.filename == "building-detector.pt"
assert asset.display_name == "building-detector"
assert asset.model_path == str(model_file)
assert asset.size_bytes == len(b"local model")
assert len(asset.sha256) == 64
assert asset.active is True
assert asset.status == "available"
assert asset.will_download_models is False
def test_model_asset_catalog_resolves_known_asset(tmp_path: Path) -> None:
model_file = tmp_path / "building-detector.pt"
model_file.write_bytes(b"local model")
settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True)
asset = ModelAssetCatalogService.resolve_asset("building-detector-pt", settings=settings)
assert asset.filename == "building-detector.pt"
assert asset.model_path == str(model_file)
def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None:
settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True)
with pytest.raises(AppError) as exc_info:
ModelAssetCatalogService.resolve_asset("missing-model", settings=settings)
assert exc_info.value.code == "DETECTION_MODEL_ASSET_NOT_FOUND"
assert exc_info.value.status_code == 404
def test_model_assets_api_returns_canonical_envelope(monkeypatch, tmp_path: Path) -> None:
model_file = tmp_path / "building-detector.pt"
model_file.write_bytes(b"local model")
monkeypatch.setenv("YOLO_MODELS_DIR", str(tmp_path))
monkeypatch.setenv("YOLO_MODEL_PATH", str(model_file))
response = TestClient(app).get("/api/v1/detection/model-assets")
assert response.status_code == 200
payload = response.json()
assert set(payload) == {"data"}
assert payload["data"]["total"] == 1
assert payload["data"]["items"][0]["model_asset_id"] == "building-detector-pt"
assert payload["data"]["items"][0]["active"] is True
assert payload["data"]["items"][0]["will_download_models"] is False
def test_detection_run_persists_selected_model_asset_parameters(tmp_path: Path) -> None:
model_file = tmp_path / "building-detector.pt"
model_file.write_bytes(b"local model")
db, project_id, dataset_id = _project_and_raster_dataset()
settings = Settings(
yolo_enabled=True,
yolo_model_path=str(tmp_path / "default.pt"),
yolo_models_dir=str(tmp_path),
yolo_max_tiles=4,
)
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-configured",
model_asset_id="building-detector-pt",
confidence_threshold=0.5,
tile_manifest_path=str(_manifest(tmp_path)),
settings=settings,
yolo_adapter_class=MockYoloAdapter,
)
jobs = [item for item in db.added if isinstance(item, Job)]
runs = [item for item in db.added if isinstance(item, AnalysisRun)]
detections = [item for item in db.added if isinstance(item, Detection)]
assert result.status == "success"
assert result.detection_count == 1
assert jobs[0].parameters_json["model_asset_id"] == "building-detector-pt"
assert jobs[0].parameters_json["model_asset_path"] == str(model_file)
assert len(jobs[0].parameters_json["model_asset_sha256"]) == 64
assert runs[0].parameters_json["model_asset_id"] == "building-detector-pt"
assert detections[0].model_name == "yolo-configured"
@@ -23,3 +23,29 @@ def test_detection_lab_surfaces_yolo_runtime_preflight() -> None:
assert "/api/v1/detection/yolo/preflight" in api assert "/api/v1/detection/yolo/preflight" in api
assert "interface YoloPreflightResponse" in types assert "interface YoloPreflightResponse" in types
assert "yoloPreflight={yoloPreflight}" in app assert "yoloPreflight={yoloPreflight}" in app
def test_detection_lab_surfaces_local_model_asset_selection() -> 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")
provider_panel = (ROOT / "frontend" / "src" / "components" / "providers" / "ProviderPanel.tsx").read_text(
encoding="utf-8"
)
assert "interface ModelAssetRead" in types
assert "model_asset_id?: string | null" in types
assert "listModelAssets" in api
assert "/api/v1/detection/model-assets" in api
assert "modelAssets" in hook
assert "selectedModelAssetId" in hook
assert "model_asset_id: selectedModelAssetId || null" in hook
assert "Local model assets" in lab
assert "onSelectModelAsset" in lab
assert "modelAssets={modelAssets}" in app
assert "Official reference sources" in provider_panel
assert "not AI model choices" in provider_panel
@@ -66,6 +66,7 @@ def test_configure_yolo_model_dry_run_selects_single_model(tmp_path: Path) -> No
assert payload["selected_container_model_path"] == "/app/models/nested/detector.pt" assert payload["selected_container_model_path"] == "/app/models/nested/detector.pt"
assert payload["env_updates"]["GEOINTEL_INSTALL_AI"] == "true" assert payload["env_updates"]["GEOINTEL_INSTALL_AI"] == "true"
assert payload["env_updates"]["YOLO_ENABLED"] == "true" assert payload["env_updates"]["YOLO_ENABLED"] == "true"
assert payload["env_updates"]["YOLO_MODELS_DIR"] == "/app/models"
assert payload["env_updates"]["YOLO_MODEL_PATH"] == "/app/models/nested/detector.pt" assert payload["env_updates"]["YOLO_MODEL_PATH"] == "/app/models/nested/detector.pt"
assert payload["will_download_models"] is False assert payload["will_download_models"] is False
assert not (tmp_path / ".env").exists() assert not (tmp_path / ".env").exists()
@@ -94,4 +95,5 @@ def test_configure_yolo_model_apply_updates_existing_env_file(tmp_path: Path) ->
assert "GEOINTEL_FRONTEND_PORT=1202" in contents assert "GEOINTEL_FRONTEND_PORT=1202" in contents
assert "GEOINTEL_INSTALL_AI=true" in contents assert "GEOINTEL_INSTALL_AI=true" in contents
assert "YOLO_ENABLED=true" in contents assert "YOLO_ENABLED=true" in contents
assert "YOLO_MODELS_DIR=/app/models" in contents
assert "YOLO_MODEL_PATH=/app/models/detector.engine" in contents assert "YOLO_MODEL_PATH=/app/models/detector.engine" in contents
+2 -1
View File
@@ -85,7 +85,8 @@ set it through `.env`, the Unraid template or `docker run -e`.
AI dependencies are opt-in. Leave `GEOINTEL_INSTALL_AI=false` for the default AI dependencies are opt-in. Leave `GEOINTEL_INSTALL_AI=false` for the default
GIS-only image. Set `GEOINTEL_INSTALL_AI=true`, mount models through GIS-only image. Set `GEOINTEL_INSTALL_AI=true`, mount models through
`GEOINTEL_MODELS_PATH` and configure `YOLO_ENABLED=true` plus `GEOINTEL_MODELS_PATH` and configure `YOLO_ENABLED=true` plus
`YOLO_MODEL_PATH=/app/models/<model>.pt` only when you have a local model file. `YOLO_MODELS_DIR=/app/models` and `YOLO_MODEL_PATH=/app/models/<model>.pt` only
when you have a local model file.
The AI-enabled image installs PyTorch/Ultralytics plus the native OpenCV runtime The AI-enabled image installs PyTorch/Ultralytics plus the native OpenCV runtime
libraries needed for Ultralytics imports; it still never downloads model weights. libraries needed for Ultralytics imports; it still never downloads model weights.
`YOLO_CONFIG_DIR` defaults to `/app/storage/ultralytics`, a writable persistent `YOLO_CONFIG_DIR` defaults to `/app/storage/ultralytics`, a writable persistent
+1
View File
@@ -9,6 +9,7 @@ export STORAGE_ROOT="${STORAGE_ROOT:-${GEOINTEL_STORAGE_ROOT:-/app/storage}}"
export DATABASE_URL="${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}}" export DATABASE_URL="${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}}"
export CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-${CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}}" export CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-${CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}}"
export MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-${MAX_UPLOAD_MB:-500}}" export MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-${MAX_UPLOAD_MB:-500}}"
export YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}"
export YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-$STORAGE_ROOT/ultralytics}" export YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-$STORAGE_ROOT/ultralytics}"
mkdir -p "$PGDATA" "$STORAGE_ROOT" "$YOLO_CONFIG_DIR" /run/nginx /var/log/nginx mkdir -p "$PGDATA" "$STORAGE_ROOT" "$YOLO_CONFIG_DIR" /run/nginx /var/log/nginx
+1
View File
@@ -27,6 +27,7 @@ GEOINTEL_MAX_UPLOAD_MB=500
# Optional configured-YOLO runtime. Keep disabled unless a local model is mounted. # Optional configured-YOLO runtime. Keep disabled unless a local model is mounted.
GEOINTEL_INSTALL_AI=false GEOINTEL_INSTALL_AI=false
YOLO_ENABLED=false YOLO_ENABLED=false
YOLO_MODELS_DIR=/app/models
YOLO_MODEL_PATH= YOLO_MODEL_PATH=
YOLO_MODEL_ID=yolo-configured YOLO_MODEL_ID=yolo-configured
YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
+2
View File
@@ -21,6 +21,7 @@ GEOINTEL_POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-geointel}"
GEOINTEL_CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-http://localhost:${GEOINTEL_FRONTEND_PORT},http://127.0.0.1:${GEOINTEL_FRONTEND_PORT},http://192.168.10.150:${GEOINTEL_FRONTEND_PORT}}" GEOINTEL_CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-http://localhost:${GEOINTEL_FRONTEND_PORT},http://127.0.0.1:${GEOINTEL_FRONTEND_PORT},http://192.168.10.150:${GEOINTEL_FRONTEND_PORT}}"
GEOINTEL_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}" GEOINTEL_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}"
YOLO_ENABLED="${YOLO_ENABLED:-false}" YOLO_ENABLED="${YOLO_ENABLED:-false}"
YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}"
YOLO_MODEL_PATH="${YOLO_MODEL_PATH:-}" YOLO_MODEL_PATH="${YOLO_MODEL_PATH:-}"
YOLO_MODEL_ID="${YOLO_MODEL_ID:-yolo-configured}" YOLO_MODEL_ID="${YOLO_MODEL_ID:-yolo-configured}"
YOLO_MODEL_DISPLAY_NAME="${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector}" YOLO_MODEL_DISPLAY_NAME="${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector}"
@@ -80,6 +81,7 @@ docker run -d \
-e GEOINTEL_CORS_ORIGINS="$GEOINTEL_CORS_ORIGINS" \ -e GEOINTEL_CORS_ORIGINS="$GEOINTEL_CORS_ORIGINS" \
-e GEOINTEL_MAX_UPLOAD_MB="$GEOINTEL_MAX_UPLOAD_MB" \ -e GEOINTEL_MAX_UPLOAD_MB="$GEOINTEL_MAX_UPLOAD_MB" \
-e YOLO_ENABLED="$YOLO_ENABLED" \ -e YOLO_ENABLED="$YOLO_ENABLED" \
-e YOLO_MODELS_DIR="$YOLO_MODELS_DIR" \
-e YOLO_MODEL_PATH="$YOLO_MODEL_PATH" \ -e YOLO_MODEL_PATH="$YOLO_MODEL_PATH" \
-e YOLO_MODEL_ID="$YOLO_MODEL_ID" \ -e YOLO_MODEL_ID="$YOLO_MODEL_ID" \
-e YOLO_MODEL_DISPLAY_NAME="$YOLO_MODEL_DISPLAY_NAME" \ -e YOLO_MODEL_DISPLAY_NAME="$YOLO_MODEL_DISPLAY_NAME" \
+1
View File
@@ -19,6 +19,7 @@ services:
GEOINTEL_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202} GEOINTEL_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
YOLO_ENABLED: ${YOLO_ENABLED:-false} YOLO_ENABLED: ${YOLO_ENABLED:-false}
YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models}
YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-} YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-}
YOLO_MODEL_ID: ${YOLO_MODEL_ID:-yolo-configured} YOLO_MODEL_ID: ${YOLO_MODEL_ID:-yolo-configured}
YOLO_MODEL_DISPLAY_NAME: ${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector} YOLO_MODEL_DISPLAY_NAME: ${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector}
+1
View File
@@ -24,6 +24,7 @@ services:
CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202} CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
YOLO_ENABLED: ${YOLO_ENABLED:-false} YOLO_ENABLED: ${YOLO_ENABLED:-false}
YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models}
YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-} YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-}
YOLO_MODEL_ID: ${YOLO_MODEL_ID:-yolo-configured} YOLO_MODEL_ID: ${YOLO_MODEL_ID:-yolo-configured}
YOLO_MODEL_DISPLAY_NAME: ${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector} YOLO_MODEL_DISPLAY_NAME: ${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector}
+14
View File
@@ -100,6 +100,7 @@ Environment variables:
- `GEOINTEL_INSTALL_AI` - `GEOINTEL_INSTALL_AI`
- `YOLO_ENABLED` - `YOLO_ENABLED`
- `YOLO_MODELS_DIR`
- `YOLO_MODEL_PATH` - `YOLO_MODEL_PATH`
- `YOLO_MODEL_ID` - `YOLO_MODEL_ID`
- `YOLO_MODEL_DISPLAY_NAME` - `YOLO_MODEL_DISPLAY_NAME`
@@ -109,6 +110,19 @@ Environment variables:
- `YOLO_MAX_TILES` - `YOLO_MAX_TILES`
- `YOLO_BATCH_SIZE` - `YOLO_BATCH_SIZE`
### Local model asset catalog
GeoIntel can list local runtime model files mounted into the backend model
directory through `GET /api/v1/detection/model-assets`. The catalog is
filesystem-backed and read-only: it reports existing `.pt`, `.onnx` and
`.engine` files, size, checksum and whether the file matches `YOLO_MODEL_PATH`.
Detection runs still use `model_id="yolo-configured"` for the configured YOLO
execution path. A selected `model_asset_id` can be supplied to use one specific
cataloged file for that run. The backend resolves the ID to a local path and
persists the selected asset metadata in Job/AnalysisRun parameters. GeoIntel
does not download weights or accept arbitrary model paths from the browser.
### Sprint 8C detection visualization and QA status ### Sprint 8C detection visualization and QA status
Sprint 8C makes persisted detections reviewable: Sprint 8C makes persisted detections reviewable:
+48
View File
@@ -636,12 +636,51 @@ Returns object-detection model capability descriptors.
} }
``` ```
### GET `/api/v1/detection/model-assets`
Returns local runtime model files discovered in the configured model directory.
This is a read-only catalog. GeoIntel never downloads, creates, mutates or
deletes model weights from this endpoint.
The backend scans `YOLO_MODELS_DIR` (default `/app/models`) and reports
supported local model files such as `.pt`, `.onnx` and `.engine`. The active
model is the file matching `YOLO_MODEL_PATH`.
Response data:
```json
{
"items": [
{
"model_asset_id": "building-detector-pt",
"filename": "building-detector.pt",
"display_name": "building-detector",
"model_path": "/app/models/building-detector.pt",
"suffix": ".pt",
"framework": "ultralytics/pytorch",
"task_type": "object_detection",
"size_bytes": 123456,
"sha256": "sha256hex",
"active": true,
"status": "available",
"limitation_message": "Local runtime model asset. GeoIntel will not download or mutate model weights.",
"will_download_models": false
}
],
"total": 1,
"model_directory": "/app/models"
}
```
### GET `/api/v1/detection/yolo/preflight` ### GET `/api/v1/detection/yolo/preflight`
Returns a canonical envelope with read-only configured-YOLO runtime preflight Returns a canonical envelope with read-only configured-YOLO runtime preflight
state. Optional query parameters: state. Optional query parameters:
- `tile_manifest_path`: existing raster tile manifest path to validate. - `tile_manifest_path`: existing raster tile manifest path to validate.
- `model_asset_id`: optional local model asset ID from
`GET /api/v1/detection/model-assets`; when supplied, preflight validates that
asset path instead of the default `YOLO_MODEL_PATH`.
- `check_model_load`: default `false`; when `true`, explicitly loads only the - `check_model_load`: default `false`; when `true`, explicitly loads only the
configured local model file for compatibility smoke. It never downloads configured local model file for compatibility smoke. It never downloads
weights and never runs inference. weights and never runs inference.
@@ -651,6 +690,7 @@ Response data:
```json ```json
{ {
"model_id": "yolo-configured", "model_id": "yolo-configured",
"model_asset_id": null,
"model_path": null, "model_path": null,
"tile_manifest_path": null, "tile_manifest_path": null,
"status": "not_configured", "status": "not_configured",
@@ -693,6 +733,7 @@ Request:
"project_id": "uuid", "project_id": "uuid",
"dataset_id": "uuid", "dataset_id": "uuid",
"model_id": "yolo-placeholder", "model_id": "yolo-placeholder",
"model_asset_id": null,
"confidence_threshold": 0.5, "confidence_threshold": 0.5,
"class_filter": ["building"], "class_filter": ["building"],
"tile_manifest_path": null, "tile_manifest_path": null,
@@ -707,6 +748,12 @@ Sprint 8B configured YOLO mode uses `model_id: "yolo-configured"`. It requires:
- backend optional AI dependencies installed with `geointel-backend[ai]` - backend optional AI dependencies installed with `geointel-backend[ai]`
- `tile_manifest_path` pointing to an existing raster tile manifest generated by the raster tile operation - `tile_manifest_path` pointing to an existing raster tile manifest generated by the raster tile operation
`model_asset_id` may be supplied with `model_id: "yolo-configured"` to select a
specific local model file from the read-only model asset catalog. The backend
resolves the ID to a file inside the configured model directory and persists the
asset ID, path and SHA-256 in the job and analysis-run parameters for
reproducibility. Clients must not submit arbitrary model paths.
GeoIntel does not download model weights automatically. Configured YOLO runs read existing tile files from the manifest, convert YOLO pixel-space boxes to EPSG:4326 detection polygons and persist detections as first-class records. GeoIntel does not download model weights automatically. Configured YOLO runs read existing tile files from the manifest, convert YOLO pixel-space boxes to EPSG:4326 detection polygons and persist detections as first-class records.
Unavailable model response: Unavailable model response:
@@ -729,6 +776,7 @@ Validation errors:
- `INVALID_DATASET_TYPE` when the dataset is not raster. - `INVALID_DATASET_TYPE` when the dataset is not raster.
- `DETECTION_MODEL_NOT_FOUND` when the model id is unknown. - `DETECTION_MODEL_NOT_FOUND` when the model id is unknown.
- `DETECTION_MODEL_ASSET_NOT_FOUND` when `model_asset_id` is not present in the configured model directory.
- `FIXTURE_MODE_REQUIRED` when `manual-fixture-detector` is requested without `parameters_json.fixture_mode=true`. - `FIXTURE_MODE_REQUIRED` when `manual-fixture-detector` is requested without `parameters_json.fixture_mode=true`.
- `DETECTION_TILE_MANIFEST_REQUIRED` when `yolo-configured` is requested without `tile_manifest_path`. - `DETECTION_TILE_MANIFEST_REQUIRED` when `yolo-configured` is requested without `tile_manifest_path`.
- `DETECTION_TILE_MANIFEST_NOT_FOUND` when the provided manifest path does not exist. - `DETECTION_TILE_MANIFEST_NOT_FOUND` when the provided manifest path does not exist.
+36
View File
@@ -1,3 +1,39 @@
## Sprint 118 Local model and reference catalog clarity (2026-07-06)
Changed:
- Added a read-only backend model asset catalog through `GET /api/v1/detection/model-assets`.
- Added `YOLO_MODELS_DIR` to backend settings, Compose, Unraid env examples and all-in-one runtime startup so `/app/models` is the explicit model catalog directory.
- Extended configured YOLO preflight and detection runs with optional `model_asset_id`, resolved server-side against the model asset catalog.
- Detection jobs and analysis runs now persist selected model asset ID, path and SHA-256 in parameters for reproducibility.
- Detection Lab now loads local model assets, selects the active model by default and lets operators choose a cataloged local model file for `yolo-configured`.
- Provider Capabilities now distinguishes GRB/OSM/manual/fixture reference-data sources from AI model choices.
- Updated `docs/API_CONTRACTS.md`, `docs/AI_PIPELINES.md`, `backend/README.md`, `frontend/README.md`, `deploy/unraid/README.md`, `scripts/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
- Added design/plan documents under `docs/superpowers/`.
Validation:
- RED: `python -m pytest backend/tests/test_model_asset_catalog.py -q` failed before implementation because `app.services.model_asset_catalog_service` did not exist.
- `python -m pytest backend/tests/test_model_asset_catalog.py -q` passed: 5 tests.
- RED: `python -m pytest backend/tests/test_sprint118_yolo_preflight_ui.py -q` failed before frontend wiring because the model asset types/API/hook/UI were absent.
- `python -m pytest backend/tests/test_sprint118_yolo_preflight_ui.py -q` passed: 2 tests.
- RED: runtime config tests failed before `YOLO_MODELS_DIR` was added to env examples, Unraid runtime and `scripts/configure_yolo_model.py`.
- `python -m pytest backend/tests/test_docker_runtime_config.py::test_env_example_uses_runtime_env_names_read_by_backend_and_frontend backend/tests/test_docker_runtime_config.py::test_unraid_deploy_passes_ai_build_arg_and_yolo_runtime_env backend/tests/test_sprint119_yolo_model_configuration.py -q` passed: 6 tests.
- `python -m compileall backend/app` passed.
- `cd backend && python -m pytest -q` passed: 383 tests.
- `cd frontend && npm run typecheck` passed.
- `cd frontend && npm run build` passed.
- `python scripts/audit_api_contracts.py` passed: 81 implemented routes match docs; 2 explicit non-envelope endpoints tracked.
- `bash scripts/run_readiness_check.sh` passed: 383 backend tests plus frontend typecheck/build, API contract audit, Alembic head and shell syntax checks.
- `cd backend && python -m alembic upgrade head --sql` passed.
- `bash -n scripts/live_migration_smoke.sh; bash -n deploy/unraid/run-dockerman-container.sh; bash -n deploy/unraid/all-in-one-start.sh; bash -n scripts/deploy_tower.sh` passed.
- Local `docker compose config` could not run because Docker is not installed in this Windows Codex environment; Tower deploy validation remains required.
Limitations:
- The catalog is intentionally filesystem-backed and read-only. It does not download, validate semantic class metadata, train models or manage model lifecycle records in the database.
- GRB/OSM remain provider capabilities only; no live external fetching was added.
Next recommended pass:
- Redeploy Tower, verify `/api/v1/detection/model-assets`, confirm Detection Lab shows the local model picker, then continue with real raster/model workflow validation on non-synthetic imagery.
## Sprint 117 Reusable GIS run and AI runtime opt-in (2026-07-05) ## Sprint 117 Reusable GIS run and AI runtime opt-in (2026-07-05)
Changed: Changed:
+1
View File
@@ -81,6 +81,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Add reusable latest-result mode for repeated Map QA/QC runs without duplicate derived artifacts. - [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] 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] Surface configured-YOLO runtime preflight status through the API and Detection Lab UI.
- [x] Add read-only local model asset catalog and Detection Lab model-file selection.
- [x] Add one-click full GIS workflow action for query, derived dataset, QA/QC and export handoff. - [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 QA/QC workspace result hierarchy and filter density polish.
- [x] Add Change Detection panel hierarchy and analysis workspace density polish. - [x] Add Change Detection panel hierarchy and analysis workspace density polish.
+2
View File
@@ -107,6 +107,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst
## Sprint 7B additions ## Sprint 7B additions
- Added a lightweight Provider Capabilities panel. - Added a lightweight Provider Capabilities panel.
- The panel lists GRB, OSM, manual and fixture provider status, configured state, authority level, supported layers, supported geometry types, query modes and limitation messages. - The panel lists GRB, OSM, manual and fixture provider status, configured state, authority level, supported layers, supported geometry types, query modes and limitation messages.
- Provider Capabilities now labels GRB/OSM/manual/fixture as reference data source capabilities, not AI model choices.
- GRB and OSM are shown as `not_configured`; the UI does not expose a live import/download action for them. - GRB and OSM are shown as `not_configured`; the UI does not expose a live import/download action for them.
- Existing dataset, reference and QA/QC UI remains unchanged. - Existing dataset, reference and QA/QC UI remains unchanged.
@@ -120,6 +121,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst
## Sprint 8B additions ## Sprint 8B additions
- Detection Lab now exposes the `yolo-configured` capability reported by the backend. - 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. - When `yolo-configured` is selected, users can provide an existing raster tile manifest path.
- Detection Lab lists local model assets from `GET /api/v1/detection/model-assets` so operators can choose an existing mounted model file instead of editing only one hidden `YOLO_MODEL_PATH` slot.
- 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`. - 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. - The UI still does not download models or create fake detections; backend status and error codes remain the source of truth.
+8
View File
@@ -222,10 +222,13 @@ function App(): JSX.Element {
}) })
const { const {
detectionModels, detectionModels,
modelAssets,
loadingDetectionModels, loadingDetectionModels,
detectionModelError, detectionModelError,
modelAssetError,
selectedDetectionDatasetId, selectedDetectionDatasetId,
selectedDetectionModelId, selectedDetectionModelId,
selectedModelAssetId,
detectionTileManifestPath, detectionTileManifestPath,
detectionConfidenceThreshold, detectionConfidenceThreshold,
runningDetection, runningDetection,
@@ -254,6 +257,7 @@ function App(): JSX.Element {
resetDetectionForProject, resetDetectionForProject,
setSelectedDetectionDatasetId, setSelectedDetectionDatasetId,
setSelectedDetectionModelId, setSelectedDetectionModelId,
setSelectedModelAssetId,
setDetectionTileManifestPath, setDetectionTileManifestPath,
setDetectionConfidenceThreshold, setDetectionConfidenceThreshold,
setSelectedDetectionRunId, setSelectedDetectionRunId,
@@ -930,10 +934,13 @@ function App(): JSX.Element {
<div className="workspace-grid workspace-grid-ai"> <div className="workspace-grid workspace-grid-ai">
<DetectionLab <DetectionLab
detectionModels={detectionModels} detectionModels={detectionModels}
modelAssets={modelAssets}
loadingDetectionModels={loadingDetectionModels} loadingDetectionModels={loadingDetectionModels}
detectionModelError={detectionModelError} detectionModelError={detectionModelError}
modelAssetError={modelAssetError}
selectedDetectionDatasetId={selectedDetectionDatasetId} selectedDetectionDatasetId={selectedDetectionDatasetId}
selectedDetectionModelId={selectedDetectionModelId} selectedDetectionModelId={selectedDetectionModelId}
selectedModelAssetId={selectedModelAssetId}
detectionTileManifestPath={detectionTileManifestPath} detectionTileManifestPath={detectionTileManifestPath}
detectionConfidenceThreshold={detectionConfidenceThreshold} detectionConfidenceThreshold={detectionConfidenceThreshold}
runningDetection={runningDetection} runningDetection={runningDetection}
@@ -959,6 +966,7 @@ function App(): JSX.Element {
onRefreshYoloPreflight={() => loadYoloPreflight()} onRefreshYoloPreflight={() => loadYoloPreflight()}
onSelectDataset={setSelectedDetectionDatasetId} onSelectDataset={setSelectedDetectionDatasetId}
onSelectModel={setSelectedDetectionModelId} onSelectModel={setSelectedDetectionModelId}
onSelectModelAsset={setSelectedModelAssetId}
onSetConfidenceThreshold={setDetectionConfidenceThreshold} onSetConfidenceThreshold={setDetectionConfidenceThreshold}
onSetTileManifestPath={setDetectionTileManifestPath} onSetTileManifestPath={setDetectionTileManifestPath}
onRunDetection={runDetection} onRunDetection={runDetection}
@@ -5,15 +5,19 @@ import type {
DetectionRead, DetectionRead,
DetectionRunRead, DetectionRunRead,
DetectionRunResponse, DetectionRunResponse,
ModelAssetRead,
YoloPreflightResponse, YoloPreflightResponse,
} from '../../types' } from '../../types'
interface DetectionLabProps { interface DetectionLabProps {
detectionModels: DetectionModelCapability[] detectionModels: DetectionModelCapability[]
modelAssets: ModelAssetRead[]
loadingDetectionModels: boolean loadingDetectionModels: boolean
detectionModelError: string | null detectionModelError: string | null
modelAssetError: string | null
selectedDetectionDatasetId: string selectedDetectionDatasetId: string
selectedDetectionModelId: string selectedDetectionModelId: string
selectedModelAssetId: string
detectionTileManifestPath: string detectionTileManifestPath: string
detectionConfidenceThreshold: number detectionConfidenceThreshold: number
runningDetection: boolean runningDetection: boolean
@@ -39,6 +43,7 @@ interface DetectionLabProps {
onRefreshYoloPreflight: () => void onRefreshYoloPreflight: () => void
onSelectDataset: (datasetId: string) => void onSelectDataset: (datasetId: string) => void
onSelectModel: (modelId: string) => void onSelectModel: (modelId: string) => void
onSelectModelAsset: (modelAssetId: string) => void
onSetConfidenceThreshold: (value: number) => void onSetConfidenceThreshold: (value: number) => void
onSetTileManifestPath: (value: string) => void onSetTileManifestPath: (value: string) => void
onRunDetection: () => void onRunDetection: () => void
@@ -53,10 +58,13 @@ interface DetectionLabProps {
export function DetectionLab({ export function DetectionLab({
detectionModels, detectionModels,
modelAssets,
loadingDetectionModels, loadingDetectionModels,
detectionModelError, detectionModelError,
modelAssetError,
selectedDetectionDatasetId, selectedDetectionDatasetId,
selectedDetectionModelId, selectedDetectionModelId,
selectedModelAssetId,
detectionTileManifestPath, detectionTileManifestPath,
detectionConfidenceThreshold, detectionConfidenceThreshold,
runningDetection, runningDetection,
@@ -82,6 +90,7 @@ export function DetectionLab({
onRefreshYoloPreflight, onRefreshYoloPreflight,
onSelectDataset, onSelectDataset,
onSelectModel, onSelectModel,
onSelectModelAsset,
onSetConfidenceThreshold, onSetConfidenceThreshold,
onSetTileManifestPath, onSetTileManifestPath,
onRunDetection, onRunDetection,
@@ -94,6 +103,7 @@ export function DetectionLab({
onRunQa, onRunQa,
}: DetectionLabProps): JSX.Element { }: DetectionLabProps): JSX.Element {
const selectedDetectionModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) ?? null const selectedDetectionModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) ?? null
const selectedModelAsset = modelAssets.find((asset) => asset.model_asset_id === selectedModelAssetId) ?? null
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured' const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionHasDataset = selectedDetectionDatasetId.length > 0 const detectionHasDataset = selectedDetectionDatasetId.length > 0
const detectionHasModel = selectedDetectionModel !== null const detectionHasModel = selectedDetectionModel !== null
@@ -149,6 +159,12 @@ export function DetectionLab({
<p>{detectionModelError}</p> <p>{detectionModelError}</p>
</div> </div>
) : null} ) : null}
{modelAssetError ? (
<div className="result-state result-state-error">
<strong>Local model assets unavailable.</strong>
<p>{modelAssetError}</p>
</div>
) : null}
{detectionModels.length === 0 && !loadingDetectionModels ? ( {detectionModels.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty"> <div className="result-state result-state-empty">
<strong>No detection models reported by backend.</strong> <strong>No detection models reported by backend.</strong>
@@ -173,6 +189,47 @@ export function DetectionLab({
</ul> </ul>
</div> </div>
{selectedDetectionModelId === 'yolo-configured' ? (
<div className="ai-lab-model-surface" aria-label="Local model asset selection">
<div className="ai-lab-section-header">
<div>
<h3>Local model assets</h3>
<p>Select an existing model file mounted into the backend runtime. GeoIntel does not download model weights.</p>
</div>
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
{selectedModelAsset ? 'asset selected' : 'using configured path'}
</span>
</div>
<label>
Local model file
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
<option value="">Use configured YOLO_MODEL_PATH</option>
{modelAssets.map((asset) => (
<option key={asset.model_asset_id} value={asset.model_asset_id}>
{asset.display_name} {asset.active ? '(active)' : ''}
</option>
))}
</select>
</label>
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>No local model assets found.</strong>
<p>Mount model files into the backend model directory or continue with the configured YOLO_MODEL_PATH.</p>
</div>
) : null}
{selectedModelAsset ? (
<div className="result-summary-card">
<p>File: {selectedModelAsset.filename}</p>
<p>Status: {selectedModelAsset.status}</p>
<p>Size: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
<p>Path: {selectedModelAsset.model_path}</p>
<p>{selectedModelAsset.limitation_message}</p>
</div>
) : null}
</div>
) : null}
<div className="ai-lab-model-surface" aria-label="YOLO runtime preflight"> <div className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
<div className="ai-lab-section-header"> <div className="ai-lab-section-header">
<div> <div>
@@ -238,6 +295,7 @@ export function DetectionLab({
<span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span> <span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span>
<span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span> <span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span>
<span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span> <span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span>
<span>model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}</span>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -486,3 +544,13 @@ export function DetectionLab({
</section> </section>
) )
} }
function formatModelAssetSize(sizeBytes: number): string {
if (sizeBytes >= 1024 * 1024) {
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
}
if (sizeBytes >= 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`
}
return `${sizeBytes} B`
}
@@ -48,6 +48,12 @@ export function ProviderPanel({
</div> </div>
<div className="system-provider-capability-surface" aria-label="Provider capability registry"> <div className="system-provider-capability-surface" aria-label="Provider capability registry">
<div className="provider-detail-stack">
<strong>Official reference sources</strong>
<div>
GRB and OSM are reference-data provider capabilities, not AI model choices. Manual upload is the configured path for real reference datasets today; fixtures remain test/demo only.
</div>
</div>
<ul className="system-provider-list"> <ul className="system-provider-list">
{providers.map((provider) => ( {providers.map((provider) => (
<li className="system-provider-card" key={provider.provider_name}> <li className="system-provider-card" key={provider.provider_name}>
@@ -7,6 +7,7 @@ import type {
DetectionRead, DetectionRead,
DetectionRunRead, DetectionRunRead,
DetectionRunResponse, DetectionRunResponse,
ModelAssetRead,
QualityCheckRead, QualityCheckRead,
YoloPreflightResponse, YoloPreflightResponse,
} from '../types' } from '../types'
@@ -28,10 +29,13 @@ export function useDetectionWorkflow({
loadQualityChecks, loadQualityChecks,
}: DetectionWorkflowOptions) { }: DetectionWorkflowOptions) {
const [detectionModels, setDetectionModels] = useState<DetectionModelCapability[]>([]) const [detectionModels, setDetectionModels] = useState<DetectionModelCapability[]>([])
const [modelAssets, setModelAssets] = useState<ModelAssetRead[]>([])
const [loadingDetectionModels, setLoadingDetectionModels] = useState(false) const [loadingDetectionModels, setLoadingDetectionModels] = useState(false)
const [detectionModelError, setDetectionModelError] = useState<string | null>(null) const [detectionModelError, setDetectionModelError] = useState<string | null>(null)
const [modelAssetError, setModelAssetError] = useState<string | null>(null)
const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('') const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('')
const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-placeholder') const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-placeholder')
const [selectedModelAssetId, setSelectedModelAssetId] = useState('')
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('') const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.5) const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.5)
const [runningDetection, setRunningDetection] = useState(false) const [runningDetection, setRunningDetection] = useState(false)
@@ -55,6 +59,7 @@ export function useDetectionWorkflow({
const loadDetectionModels = async () => { const loadDetectionModels = async () => {
setLoadingDetectionModels(true) setLoadingDetectionModels(true)
setDetectionModelError(null) setDetectionModelError(null)
setModelAssetError(null)
try { try {
const response = await detectionApi.listModels() const response = await detectionApi.listModels()
setDetectionModels(response.models) setDetectionModels(response.models)
@@ -63,6 +68,17 @@ export function useDetectionWorkflow({
} }
} catch (error) { } catch (error) {
setDetectionModelError(formatError(error, 'Failed to load detection models')) setDetectionModelError(formatError(error, 'Failed to load detection models'))
}
try {
const assetResponse = await detectionApi.listModelAssets()
setModelAssets(assetResponse.items)
const activeAsset = assetResponse.items.find((asset) => asset.active) ?? assetResponse.items[0] ?? null
if (!assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId)) {
setSelectedModelAssetId(activeAsset?.model_asset_id ?? '')
}
} catch (error) {
setModelAssets([])
setModelAssetError(formatError(error, 'Failed to load local model assets'))
} finally { } finally {
setLoadingDetectionModels(false) setLoadingDetectionModels(false)
} }
@@ -74,6 +90,7 @@ export function useDetectionWorkflow({
try { try {
const response = await detectionApi.getYoloPreflight({ const response = await detectionApi.getYoloPreflight({
tile_manifest_path: tileManifestPath.trim() || null, tile_manifest_path: tileManifestPath.trim() || null,
model_asset_id: selectedModelAssetId || null,
}) })
setYoloPreflight(response) setYoloPreflight(response)
} catch (error) { } catch (error) {
@@ -143,6 +160,7 @@ export function useDetectionWorkflow({
project_id: selectedProjectId, project_id: selectedProjectId,
dataset_id: datasetId, dataset_id: datasetId,
model_id: selectedDetectionModelId, model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
confidence_threshold: detectionConfidenceThreshold, confidence_threshold: detectionConfidenceThreshold,
tile_manifest_path: detectionTileManifestPath.trim() || null, tile_manifest_path: detectionTileManifestPath.trim() || null,
parameters_json: {}, parameters_json: {},
@@ -198,10 +216,13 @@ export function useDetectionWorkflow({
return { return {
detectionModels, detectionModels,
modelAssets,
loadingDetectionModels, loadingDetectionModels,
detectionModelError, detectionModelError,
modelAssetError,
selectedDetectionDatasetId, selectedDetectionDatasetId,
selectedDetectionModelId, selectedDetectionModelId,
selectedModelAssetId,
detectionTileManifestPath, detectionTileManifestPath,
detectionConfidenceThreshold, detectionConfidenceThreshold,
runningDetection, runningDetection,
@@ -230,6 +251,7 @@ export function useDetectionWorkflow({
resetDetectionForProject, resetDetectionForProject,
setSelectedDetectionDatasetId, setSelectedDetectionDatasetId,
setSelectedDetectionModelId, setSelectedDetectionModelId,
setSelectedModelAssetId,
setDetectionTileManifestPath, setDetectionTileManifestPath,
setDetectionConfidenceThreshold, setDetectionConfidenceThreshold,
setSelectedDetectionRunId, setSelectedDetectionRunId,
+3 -1
View File
@@ -8,6 +8,7 @@ import type {
DetectionRunRead, DetectionRunRead,
DetectionRunRequest, DetectionRunRequest,
DetectionRunResponse, DetectionRunResponse,
ModelAssetListResponse,
YoloPreflightResponse, YoloPreflightResponse,
} from '../../types' } from '../../types'
@@ -24,7 +25,8 @@ function queryString(params: Record<string, string | number | boolean | null | u
export const detectionApi = { export const detectionApi = {
listModels: (): Promise<DetectionModelsResponse> => apiGet<DetectionModelsResponse>('/api/v1/detection/models'), listModels: (): Promise<DetectionModelsResponse> => apiGet<DetectionModelsResponse>('/api/v1/detection/models'),
getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null } = {}): Promise<YoloPreflightResponse> => listModelAssets: (): Promise<ModelAssetListResponse> => apiGet<ModelAssetListResponse>('/api/v1/detection/model-assets'),
getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null; model_asset_id?: string | null } = {}): Promise<YoloPreflightResponse> =>
apiGet<YoloPreflightResponse>(`/api/v1/detection/yolo/preflight${queryString(params)}`), apiGet<YoloPreflightResponse>(`/api/v1/detection/yolo/preflight${queryString(params)}`),
run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> => run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> =>
apiPost<DetectionRunResponse>('/api/v1/detection/run', payload), apiPost<DetectionRunResponse>('/api/v1/detection/run', payload),
+24
View File
@@ -404,6 +404,28 @@ export interface DetectionModelsResponse {
models: DetectionModelCapability[] models: DetectionModelCapability[]
} }
export interface ModelAssetRead {
model_asset_id: string
filename: string
display_name: string
model_path: string
suffix: string
framework: string
task_type: string
size_bytes: number
sha256: string
active: boolean
status: string
limitation_message: string
will_download_models: boolean
}
export interface ModelAssetListResponse {
items: ModelAssetRead[]
total: number
model_directory: string
}
export interface YoloPreflightChecks { export interface YoloPreflightChecks {
enabled: boolean enabled: boolean
dependencies_available?: boolean | null dependencies_available?: boolean | null
@@ -428,6 +450,7 @@ export interface YoloPreflightRuntime {
export interface YoloPreflightResponse { export interface YoloPreflightResponse {
model_id: string model_id: string
model_asset_id?: string | null
model_path?: string | null model_path?: string | null
tile_manifest_path?: string | null tile_manifest_path?: string | null
status: string status: string
@@ -445,6 +468,7 @@ export interface DetectionRunRequest {
project_id: string project_id: string
dataset_id: string dataset_id: string
model_id: string model_id: string
model_asset_id?: string | null
confidence_threshold: number confidence_threshold: number
class_filter?: string[] | null class_filter?: string[] | null
tile_manifest_path?: string | null tile_manifest_path?: string | null
+4 -3
View File
@@ -145,7 +145,8 @@ GEOINTEL_INSTALL_AI=true
For Unraid/all-in-one deployments, place model files under For Unraid/all-in-one deployments, place model files under
`GEOINTEL_MODELS_PATH` so they appear in the container under `/app/models`, then `GEOINTEL_MODELS_PATH` so they appear in the container under `/app/models`, then
set `YOLO_ENABLED=true` and `YOLO_MODEL_PATH=/app/models/<model>.pt`. set `YOLO_ENABLED=true`, `YOLO_MODELS_DIR=/app/models` and
`YOLO_MODEL_PATH=/app/models/<model>.pt`.
Configure the Unraid/Tower env file from an existing local model without Configure the Unraid/Tower env file from an existing local model without
downloading weights or running inference: downloading weights or running inference:
@@ -168,8 +169,8 @@ python scripts/configure_yolo_model.py \
The configurator refuses to proceed when no model exists or when multiple model The configurator refuses to proceed when no model exists or when multiple model
files are present without `--model-file`. It writes only files are present without `--model-file`. It writes only
`GEOINTEL_INSTALL_AI=true`, `YOLO_ENABLED=true` and the mounted `GEOINTEL_INSTALL_AI=true`, `YOLO_ENABLED=true`, `YOLO_MODELS_DIR=/app/models`
`YOLO_MODEL_PATH`. and the mounted `YOLO_MODEL_PATH`.
Clean old offline demo export artifacts without touching uploaded source data: Clean old offline demo export artifacts without touching uploaded source data:
+2 -1
View File
@@ -9,7 +9,7 @@ from typing import Iterable
SUPPORTED_MODEL_SUFFIXES = {".pt", ".onnx", ".engine"} SUPPORTED_MODEL_SUFFIXES = {".pt", ".onnx", ".engine"}
ENV_KEYS = ("GEOINTEL_INSTALL_AI", "YOLO_ENABLED", "YOLO_MODEL_PATH") ENV_KEYS = ("GEOINTEL_INSTALL_AI", "YOLO_ENABLED", "YOLO_MODELS_DIR", "YOLO_MODEL_PATH")
def _candidate_paths(models_dir: Path) -> list[Path]: def _candidate_paths(models_dir: Path) -> list[Path]:
@@ -149,6 +149,7 @@ def configure(args: argparse.Namespace) -> tuple[int, dict[str, object]]:
updates = { updates = {
"GEOINTEL_INSTALL_AI": "true", "GEOINTEL_INSTALL_AI": "true",
"YOLO_ENABLED": "true", "YOLO_ENABLED": "true",
"YOLO_MODELS_DIR": args.container_model_dir.rstrip("/"),
"YOLO_MODEL_PATH": selected_container_path, "YOLO_MODEL_PATH": selected_container_path,
} }
payload.update( payload.update(