diff --git a/.env.example b/.env.example index c3b6f264..afef7944 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ STORAGE_ROOT=./storage MAX_UPLOAD_MB=500 CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202 YOLO_ENABLED=false +YOLO_MODELS_DIR=/app/models YOLO_MODEL_PATH= YOLO_MODEL_ID=yolo-configured YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2b7e37..402f3d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ # 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) - 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. diff --git a/backend/README.md b/backend/README.md index acd1137a..73c81e88 100644 --- a/backend/README.md +++ b/backend/README.md @@ -125,6 +125,7 @@ bash scripts/live_migration_smoke.sh - detection service boundary for creating jobs, analysis runs and dependency-aware unavailable responses. - Added detection endpoints: - `GET /api/v1/detection/models` + - `GET /api/v1/detection/model-assets` - `POST /api/v1/detection/run` - `GET /api/v1/detection/runs/{analysis_run_id}` - `GET /api/v1/detection/runs/{analysis_run_id}/detections` @@ -239,6 +240,7 @@ Configured YOLO requires: ```bash YOLO_ENABLED=true +YOLO_MODELS_DIR=/absolute/path/to/models YOLO_MODEL_PATH=/absolute/path/to/local-model.pt ``` @@ -260,6 +262,7 @@ directory, mounted as `/app/models` by default: ```bash GEOINTEL_MODELS_PATH=/mnt/user/appdata/geointel/models YOLO_ENABLED=true +YOLO_MODELS_DIR=/app/models 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 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 manifest flow. Single-band or otherwise non-RGB tile images are converted to a temporary RGB prediction image before inference; georeferencing still comes @@ -286,6 +302,7 @@ Optional tuning: YOLO_MODEL_ID=yolo-configured YOLO_MODEL_DISPLAY_NAME="Configured YOLO detector" YOLO_MODEL_VERSION=local-v1 +YOLO_MODELS_DIR=/app/models YOLO_CONFIG_DIR=/app/storage/ultralytics YOLO_DEVICE=cpu YOLO_IMAGE_SIZE=640 diff --git a/backend/app/api/routes/detection.py b/backend/app/api/routes/detection.py index 7de11d8f..29ba8d62 100644 --- a/backend/app/api/routes/detection.py +++ b/backend/app/api/routes/detection.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.schemas import DetectionQaRequest, DetectionRunRequest 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.yolo_preflight_service import YoloPreflightService 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()]}) +@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) -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( YoloPreflightService.run( tile_manifest_path=tile_manifest_path, 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, dataset_id=payload.dataset_id, model_id=payload.model_id, + model_asset_id=payload.model_asset_id, confidence_threshold=payload.confidence_threshold, class_filter=payload.class_filter, tile_manifest_path=payload.tile_manifest_path, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e9a0a6e7..19ca3429 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): 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") 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_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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 1865d357..3e4f324f 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -15,6 +15,8 @@ from .detection import ( DetectionRunRead, DetectionRunRequest, DetectionRunResponse, + ModelAssetListResponse, + ModelAssetRead, ) from .segmentation import ( SegmentationListResponse, @@ -105,6 +107,8 @@ __all__ = [ "DetectionRunRead", "DetectionRunRequest", "DetectionRunResponse", + "ModelAssetListResponse", + "ModelAssetRead", "SegmentationListResponse", "SegmentationModelCapability", "SegmentationModelsResponse", diff --git a/backend/app/schemas/detection.py b/backend/app/schemas/detection.py index 736c5540..3c278ac4 100644 --- a/backend/app/schemas/detection.py +++ b/backend/app/schemas/detection.py @@ -22,10 +22,33 @@ class DetectionModelsResponse(BaseModel): 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): project_id: UUID dataset_id: UUID model_id: str + model_asset_id: str | None = None confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0) class_filter: list[str] | None = None tile_manifest_path: str | None = None diff --git a/backend/app/services/detection_service.py b/backend/app/services/detection_service.py index 0cde32fe..64067fc8 100644 --- a/backend/app/services/detection_service.py +++ b/backend/app/services/detection_service.py @@ -15,6 +15,7 @@ from app.core.errors import AppError from app.models import AnalysisRun, Dataset, Detection, Job, Project, VectorFeature from app.schemas.detection import DetectionListResponse, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunResponse 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.qa_service import QaService from app.services.quality_service import QualityService @@ -33,6 +34,7 @@ class DetectionService: dataset_id: uuid.UUID, model_id: str, confidence_threshold: float, + model_asset_id: str | None = None, class_filter: list[str] | None = None, tile_manifest_path: str | None = None, parameters_json: dict[str, Any] | None = None, @@ -55,6 +57,11 @@ class DetectionService: 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_id, settings=resolved_settings, @@ -77,6 +84,9 @@ class DetectionService: run_parameters = { "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, "class_filter": class_filter or [], "tile_manifest_path": tile_manifest_path, diff --git a/backend/app/services/model_asset_catalog_service.py b/backend/app/services/model_asset_catalog_service.py new file mode 100644 index 00000000..9d75eb49 --- /dev/null +++ b/backend/app/services/model_asset_catalog_service.py @@ -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() diff --git a/backend/app/services/yolo_preflight_service.py b/backend/app/services/yolo_preflight_service.py index 45815cfe..8745ad24 100644 --- a/backend/app/services/yolo_preflight_service.py +++ b/backend/app/services/yolo_preflight_service.py @@ -8,6 +8,7 @@ from typing import Any, Type from app.core.config import Settings, get_settings from app.core.errors import AppError from app.services.detection_service import DetectionService +from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.yolo_adapter import YoloDetectionAdapter @@ -20,10 +21,16 @@ class YoloPreflightService: yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter, assume_dependencies: bool = False, check_model_load: bool = False, + model_asset_id: str | None = None, ) -> dict[str, Any]: 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] = { "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, "tile_manifest_path": tile_manifest_path, "status": "not_configured", diff --git a/backend/tests/test_docker_runtime_config.py b/backend/tests/test_docker_runtime_config.py index 7c252c6c..2a20e6c8 100644 --- a/backend/tests/test_docker_runtime_config.py +++ b/backend/tests/test_docker_runtime_config.py @@ -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 "YOLO_ENABLED=false" in env_example + assert "YOLO_MODELS_DIR=/app/models" in env_example assert "YOLO_MODEL_PATH=" in env_example assert "YOLO_CONFIG_DIR=./storage/ultralytics" 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 '-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_MAX_TILES="$YOLO_MAX_TILES"' in run_script assert "-v \"${GEOINTEL_MODELS_PATH}:/app/models\"" in run_script diff --git a/backend/tests/test_model_asset_catalog.py b/backend/tests/test_model_asset_catalog.py new file mode 100644 index 00000000..d92b5a9a --- /dev/null +++ b/backend/tests/test_model_asset_catalog.py @@ -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" diff --git a/backend/tests/test_sprint118_yolo_preflight_ui.py b/backend/tests/test_sprint118_yolo_preflight_ui.py index 21251575..6b374698 100644 --- a/backend/tests/test_sprint118_yolo_preflight_ui.py +++ b/backend/tests/test_sprint118_yolo_preflight_ui.py @@ -23,3 +23,29 @@ def test_detection_lab_surfaces_yolo_runtime_preflight() -> None: assert "/api/v1/detection/yolo/preflight" in api assert "interface YoloPreflightResponse" in types 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 diff --git a/backend/tests/test_sprint119_yolo_model_configuration.py b/backend/tests/test_sprint119_yolo_model_configuration.py index ec1e9c2a..f7d7d52f 100644 --- a/backend/tests/test_sprint119_yolo_model_configuration.py +++ b/backend/tests/test_sprint119_yolo_model_configuration.py @@ -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["env_updates"]["GEOINTEL_INSTALL_AI"] == "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["will_download_models"] is False 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_INSTALL_AI=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 diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index 59d38e3a..d48fa719 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -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 GIS-only image. Set `GEOINTEL_INSTALL_AI=true`, mount models through `GEOINTEL_MODELS_PATH` and configure `YOLO_ENABLED=true` plus -`YOLO_MODEL_PATH=/app/models/.pt` only when you have a local model file. +`YOLO_MODELS_DIR=/app/models` and `YOLO_MODEL_PATH=/app/models/.pt` only +when you have a local model file. The AI-enabled image installs PyTorch/Ultralytics plus the native OpenCV runtime libraries needed for Ultralytics imports; it still never downloads model weights. `YOLO_CONFIG_DIR` defaults to `/app/storage/ultralytics`, a writable persistent diff --git a/deploy/unraid/all-in-one-start.sh b/deploy/unraid/all-in-one-start.sh index 090f9300..3ca5918a 100644 --- a/deploy/unraid/all-in-one-start.sh +++ b/deploy/unraid/all-in-one-start.sh @@ -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 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 YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}" export YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-$STORAGE_ROOT/ultralytics}" mkdir -p "$PGDATA" "$STORAGE_ROOT" "$YOLO_CONFIG_DIR" /run/nginx /var/log/nginx diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index d6c12774..9b38964a 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -27,6 +27,7 @@ GEOINTEL_MAX_UPLOAD_MB=500 # Optional configured-YOLO runtime. Keep disabled unless a local model is mounted. GEOINTEL_INSTALL_AI=false YOLO_ENABLED=false +YOLO_MODELS_DIR=/app/models YOLO_MODEL_PATH= YOLO_MODEL_ID=yolo-configured YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index ac3ff243..d5a0d30f 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -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_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}" YOLO_ENABLED="${YOLO_ENABLED:-false}" +YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}" YOLO_MODEL_PATH="${YOLO_MODEL_PATH:-}" YOLO_MODEL_ID="${YOLO_MODEL_ID:-yolo-configured}" 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_MAX_UPLOAD_MB="$GEOINTEL_MAX_UPLOAD_MB" \ -e YOLO_ENABLED="$YOLO_ENABLED" \ + -e YOLO_MODELS_DIR="$YOLO_MODELS_DIR" \ -e YOLO_MODEL_PATH="$YOLO_MODEL_PATH" \ -e YOLO_MODEL_ID="$YOLO_MODEL_ID" \ -e YOLO_MODEL_DISPLAY_NAME="$YOLO_MODEL_DISPLAY_NAME" \ diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index cfa42ad9..c45dee2d 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -19,6 +19,7 @@ services: GEOINTEL_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202} GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} YOLO_ENABLED: ${YOLO_ENABLED:-false} + YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models} YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-} YOLO_MODEL_ID: ${YOLO_MODEL_ID:-yolo-configured} YOLO_MODEL_DISPLAY_NAME: ${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector} diff --git a/docker-compose.yml b/docker-compose.yml index 7d457113..768ed8e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202} MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} YOLO_ENABLED: ${YOLO_ENABLED:-false} + YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models} YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-} YOLO_MODEL_ID: ${YOLO_MODEL_ID:-yolo-configured} YOLO_MODEL_DISPLAY_NAME: ${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector} diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index 42c291a3..f5555fd3 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -100,6 +100,7 @@ Environment variables: - `GEOINTEL_INSTALL_AI` - `YOLO_ENABLED` +- `YOLO_MODELS_DIR` - `YOLO_MODEL_PATH` - `YOLO_MODEL_ID` - `YOLO_MODEL_DISPLAY_NAME` @@ -109,6 +110,19 @@ Environment variables: - `YOLO_MAX_TILES` - `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 makes persisted detections reviewable: diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 1c09f54c..d4b1c603 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -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` 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. +- `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 configured local model file for compatibility smoke. It never downloads weights and never runs inference. @@ -651,6 +690,7 @@ Response data: ```json { "model_id": "yolo-configured", + "model_asset_id": null, "model_path": null, "tile_manifest_path": null, "status": "not_configured", @@ -693,6 +733,7 @@ Request: "project_id": "uuid", "dataset_id": "uuid", "model_id": "yolo-placeholder", + "model_asset_id": null, "confidence_threshold": 0.5, "class_filter": ["building"], "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]` - `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. Unavailable model response: @@ -729,6 +776,7 @@ Validation errors: - `INVALID_DATASET_TYPE` when the dataset is not raster. - `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`. - `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. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 6cd99737..ab21f6a1 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 6029e533..43502668 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 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 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 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 fc447b9c..bc0c4400 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -107,6 +107,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst ## Sprint 7B additions - 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. +- 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. - 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 - 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 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`. - The UI still does not download models or create fake detections; backend status and error codes remain the source of truth. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2487bb6a..45fde704 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -222,10 +222,13 @@ function App(): JSX.Element { }) const { detectionModels, + modelAssets, loadingDetectionModels, detectionModelError, + modelAssetError, selectedDetectionDatasetId, selectedDetectionModelId, + selectedModelAssetId, detectionTileManifestPath, detectionConfidenceThreshold, runningDetection, @@ -254,6 +257,7 @@ function App(): JSX.Element { resetDetectionForProject, setSelectedDetectionDatasetId, setSelectedDetectionModelId, + setSelectedModelAssetId, setDetectionTileManifestPath, setDetectionConfidenceThreshold, setSelectedDetectionRunId, @@ -930,10 +934,13 @@ function App(): JSX.Element {
loadYoloPreflight()} onSelectDataset={setSelectedDetectionDatasetId} onSelectModel={setSelectedDetectionModelId} + onSelectModelAsset={setSelectedModelAssetId} onSetConfidenceThreshold={setDetectionConfidenceThreshold} onSetTileManifestPath={setDetectionTileManifestPath} onRunDetection={runDetection} diff --git a/frontend/src/components/detection/DetectionLab.tsx b/frontend/src/components/detection/DetectionLab.tsx index 85600f59..06a2cb2c 100644 --- a/frontend/src/components/detection/DetectionLab.tsx +++ b/frontend/src/components/detection/DetectionLab.tsx @@ -5,15 +5,19 @@ import type { DetectionRead, DetectionRunRead, DetectionRunResponse, + ModelAssetRead, YoloPreflightResponse, } from '../../types' interface DetectionLabProps { detectionModels: DetectionModelCapability[] + modelAssets: ModelAssetRead[] loadingDetectionModels: boolean detectionModelError: string | null + modelAssetError: string | null selectedDetectionDatasetId: string selectedDetectionModelId: string + selectedModelAssetId: string detectionTileManifestPath: string detectionConfidenceThreshold: number runningDetection: boolean @@ -39,6 +43,7 @@ interface DetectionLabProps { onRefreshYoloPreflight: () => void onSelectDataset: (datasetId: string) => void onSelectModel: (modelId: string) => void + onSelectModelAsset: (modelAssetId: string) => void onSetConfidenceThreshold: (value: number) => void onSetTileManifestPath: (value: string) => void onRunDetection: () => void @@ -53,10 +58,13 @@ interface DetectionLabProps { export function DetectionLab({ detectionModels, + modelAssets, loadingDetectionModels, detectionModelError, + modelAssetError, selectedDetectionDatasetId, selectedDetectionModelId, + selectedModelAssetId, detectionTileManifestPath, detectionConfidenceThreshold, runningDetection, @@ -82,6 +90,7 @@ export function DetectionLab({ onRefreshYoloPreflight, onSelectDataset, onSelectModel, + onSelectModelAsset, onSetConfidenceThreshold, onSetTileManifestPath, onRunDetection, @@ -94,6 +103,7 @@ export function DetectionLab({ onRunQa, }: DetectionLabProps): JSX.Element { 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 detectionHasDataset = selectedDetectionDatasetId.length > 0 const detectionHasModel = selectedDetectionModel !== null @@ -149,6 +159,12 @@ export function DetectionLab({

{detectionModelError}

) : null} + {modelAssetError ? ( +
+ Local model assets unavailable. +

{modelAssetError}

+
+ ) : null} {detectionModels.length === 0 && !loadingDetectionModels ? (
No detection models reported by backend. @@ -173,6 +189,47 @@ export function DetectionLab({
+ {selectedDetectionModelId === 'yolo-configured' ? ( +
+
+
+

Local model assets

+

Select an existing model file mounted into the backend runtime. GeoIntel does not download model weights.

+
+ + {selectedModelAsset ? 'asset selected' : 'using configured path'} + +
+ + {modelAssets.length === 0 && !loadingDetectionModels ? ( +
+ No local model assets found. +

Mount model files into the backend model directory or continue with the configured YOLO_MODEL_PATH.

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

File: {selectedModelAsset.filename}

+

Status: {selectedModelAsset.status}

+

Size: {formatModelAssetSize(selectedModelAsset.size_bytes)}

+

SHA-256: {selectedModelAsset.sha256.slice(0, 12)}

+

Path: {selectedModelAsset.model_path}

+

{selectedModelAsset.limitation_message}

+
+ ) : null} +
+ ) : null} +
@@ -238,6 +295,7 @@ export function DetectionLab({ 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'} + model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}
) : null} @@ -486,3 +544,13 @@ export function DetectionLab({ ) } + +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` +} diff --git a/frontend/src/components/providers/ProviderPanel.tsx b/frontend/src/components/providers/ProviderPanel.tsx index bf684117..2bb69d76 100644 --- a/frontend/src/components/providers/ProviderPanel.tsx +++ b/frontend/src/components/providers/ProviderPanel.tsx @@ -48,6 +48,12 @@ export function ProviderPanel({
+
+ Official reference sources +
+ 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. +
+
    {providers.map((provider) => (
  • diff --git a/frontend/src/hooks/useDetectionWorkflow.ts b/frontend/src/hooks/useDetectionWorkflow.ts index a7808ea9..986a7909 100644 --- a/frontend/src/hooks/useDetectionWorkflow.ts +++ b/frontend/src/hooks/useDetectionWorkflow.ts @@ -7,6 +7,7 @@ import type { DetectionRead, DetectionRunRead, DetectionRunResponse, + ModelAssetRead, QualityCheckRead, YoloPreflightResponse, } from '../types' @@ -28,10 +29,13 @@ export function useDetectionWorkflow({ loadQualityChecks, }: DetectionWorkflowOptions) { const [detectionModels, setDetectionModels] = useState([]) + const [modelAssets, setModelAssets] = useState([]) const [loadingDetectionModels, setLoadingDetectionModels] = useState(false) const [detectionModelError, setDetectionModelError] = useState(null) + const [modelAssetError, setModelAssetError] = useState(null) const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('') const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-placeholder') + const [selectedModelAssetId, setSelectedModelAssetId] = useState('') const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('') const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.5) const [runningDetection, setRunningDetection] = useState(false) @@ -55,6 +59,7 @@ export function useDetectionWorkflow({ const loadDetectionModels = async () => { setLoadingDetectionModels(true) setDetectionModelError(null) + setModelAssetError(null) try { const response = await detectionApi.listModels() setDetectionModels(response.models) @@ -63,6 +68,17 @@ export function useDetectionWorkflow({ } } catch (error) { 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 { setLoadingDetectionModels(false) } @@ -74,6 +90,7 @@ export function useDetectionWorkflow({ try { const response = await detectionApi.getYoloPreflight({ tile_manifest_path: tileManifestPath.trim() || null, + model_asset_id: selectedModelAssetId || null, }) setYoloPreflight(response) } catch (error) { @@ -143,6 +160,7 @@ export function useDetectionWorkflow({ project_id: selectedProjectId, dataset_id: datasetId, model_id: selectedDetectionModelId, + model_asset_id: selectedModelAssetId || null, confidence_threshold: detectionConfidenceThreshold, tile_manifest_path: detectionTileManifestPath.trim() || null, parameters_json: {}, @@ -198,10 +216,13 @@ export function useDetectionWorkflow({ return { detectionModels, + modelAssets, loadingDetectionModels, detectionModelError, + modelAssetError, selectedDetectionDatasetId, selectedDetectionModelId, + selectedModelAssetId, detectionTileManifestPath, detectionConfidenceThreshold, runningDetection, @@ -230,6 +251,7 @@ export function useDetectionWorkflow({ resetDetectionForProject, setSelectedDetectionDatasetId, setSelectedDetectionModelId, + setSelectedModelAssetId, setDetectionTileManifestPath, setDetectionConfidenceThreshold, setSelectedDetectionRunId, diff --git a/frontend/src/services/api/detection.ts b/frontend/src/services/api/detection.ts index 3c892ead..45babac0 100644 --- a/frontend/src/services/api/detection.ts +++ b/frontend/src/services/api/detection.ts @@ -8,6 +8,7 @@ import type { DetectionRunRead, DetectionRunRequest, DetectionRunResponse, + ModelAssetListResponse, YoloPreflightResponse, } from '../../types' @@ -24,7 +25,8 @@ function queryString(params: Record => apiGet('/api/v1/detection/models'), - getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null } = {}): Promise => + listModelAssets: (): Promise => apiGet('/api/v1/detection/model-assets'), + getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null; model_asset_id?: string | null } = {}): Promise => apiGet(`/api/v1/detection/yolo/preflight${queryString(params)}`), run: (payload: DetectionRunRequest): Promise => apiPost('/api/v1/detection/run', payload), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 60ef0a09..b6d09f27 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -404,6 +404,28 @@ export interface DetectionModelsResponse { 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 { enabled: boolean dependencies_available?: boolean | null @@ -428,6 +450,7 @@ export interface YoloPreflightRuntime { export interface YoloPreflightResponse { model_id: string + model_asset_id?: string | null model_path?: string | null tile_manifest_path?: string | null status: string @@ -445,6 +468,7 @@ export interface DetectionRunRequest { project_id: string dataset_id: string model_id: string + model_asset_id?: string | null confidence_threshold: number class_filter?: string[] | null tile_manifest_path?: string | null diff --git a/scripts/README.md b/scripts/README.md index ef46f385..b884ed99 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -145,7 +145,8 @@ GEOINTEL_INSTALL_AI=true For Unraid/all-in-one deployments, place model files under `GEOINTEL_MODELS_PATH` so they appear in the container under `/app/models`, then -set `YOLO_ENABLED=true` and `YOLO_MODEL_PATH=/app/models/.pt`. +set `YOLO_ENABLED=true`, `YOLO_MODELS_DIR=/app/models` and +`YOLO_MODEL_PATH=/app/models/.pt`. Configure the Unraid/Tower env file from an existing local model without 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 files are present without `--model-file`. It writes only -`GEOINTEL_INSTALL_AI=true`, `YOLO_ENABLED=true` and the mounted -`YOLO_MODEL_PATH`. +`GEOINTEL_INSTALL_AI=true`, `YOLO_ENABLED=true`, `YOLO_MODELS_DIR=/app/models` +and the mounted `YOLO_MODEL_PATH`. Clean old offline demo export artifacts without touching uploaded source data: diff --git a/scripts/configure_yolo_model.py b/scripts/configure_yolo_model.py index 09844e07..8fe5dae4 100644 --- a/scripts/configure_yolo_model.py +++ b/scripts/configure_yolo_model.py @@ -9,7 +9,7 @@ from typing import Iterable 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]: @@ -149,6 +149,7 @@ def configure(args: argparse.Namespace) -> tuple[int, dict[str, object]]: updates = { "GEOINTEL_INSTALL_AI": "true", "YOLO_ENABLED": "true", + "YOLO_MODELS_DIR": args.container_model_dir.rstrip("/"), "YOLO_MODEL_PATH": selected_container_path, } payload.update(