From 25f098bde019c1dfd33975521e5823f74338fd24 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 10:14:07 +0200 Subject: [PATCH] Raise configured YOLO max detections --- .env.example | 1 + CHANGELOG.md | 8 +++++ backend/README.md | 5 ++++ backend/app/core/config.py | 1 + backend/app/services/yolo_adapter.py | 1 + backend/tests/test_docker_runtime_config.py | 2 ++ .../tests/test_sprint8b_yolo_foundation.py | 17 ++++++++++- deploy/unraid/geointel.env.example | 1 + deploy/unraid/run-dockerman-container.sh | 2 ++ docker-compose.unraid.yml | 1 + docker-compose.yml | 1 + docs/AI_PIPELINES.md | 8 +++++ docs/API_CONTRACTS.md | 3 ++ docs/CODEX_EXECUTION_LOG.md | 29 +++++++++++++++++++ docs/ENVIRONMENT_SPEC.md | 1 + docs/TODO.md | 2 ++ 16 files changed, 82 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index afef7944..c9d262ff 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ YOLO_CONFIG_DIR=./storage/ultralytics YOLO_DEVICE=cpu YOLO_IMAGE_SIZE=640 YOLO_MAX_TILES=100 +YOLO_MAX_DETECTIONS=1000 YOLO_BATCH_SIZE=1 ENABLE_GRB_WFS=false GRB_WFS_URL= diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd3fe5b..a2983804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 148 YOLO max-detection cap hardening (2026-07-09) + +- Added `YOLO_MAX_DETECTIONS` with default `1000` and forward it to Ultralytics as `max_det`. +- Wired the setting through `.env.example`, Docker Compose, Unraid env examples and the Dockerman run script. +- Documented why dense building AOIs should not inherit the Ultralytics default cap of 300 detections before persisted QA/QC. +- Added regression coverage for adapter forwarding and Docker/Unraid runtime exposure. +- No model was activated, no detections were faked, and no API route or migration changed. + ## Sprint 147 AOI512 YOLOv8s scale-match candidate gate (2026-07-09) - Built and audited an AOI-scale YOLO dataset at `512px` tile size to test whether the previous `160px` training scale was the main quality blocker. diff --git a/backend/README.md b/backend/README.md index b384974c..92388409 100644 --- a/backend/README.md +++ b/backend/README.md @@ -359,6 +359,7 @@ YOLO_CONFIG_DIR=/app/storage/ultralytics YOLO_DEVICE=cpu YOLO_IMAGE_SIZE=640 YOLO_MAX_TILES=100 +YOLO_MAX_DETECTIONS=1000 YOLO_BATCH_SIZE=1 ``` @@ -384,6 +385,10 @@ python scripts/yolo_preflight.py --model-path /absolute/path/to/local-model.pt - The preflight checks configuration, dependency availability, local model file existence, tile manifest validity, tile count and referenced tile paths. JSON output also includes runtime diagnostics for the model directory, `YOLO_CONFIG_DIR`, installed `torch`/`ultralytics` versions and CUDA availability when dependency checks pass. It does not load a YOLO model, run inference or download weights. +`YOLO_MAX_DETECTIONS` is forwarded to Ultralytics as `max_det`. The default is +`1000` because dense building AOIs can exceed the upstream default cap of 300 +detections before QA/QC can measure recall honestly. + The same read-only status is available through the API and Detection Lab UI: ```bash diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 19ca3429..425b3454 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -31,6 +31,7 @@ class Settings(BaseSettings): yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE") yolo_image_size: int = Field(default=640, validation_alias="YOLO_IMAGE_SIZE") yolo_max_tiles: int = Field(default=100, validation_alias="YOLO_MAX_TILES") + yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS") yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE") cors_origins: list[str] | str = Field( default=["http://localhost:5173", "http://127.0.0.1:5173"], diff --git a/backend/app/services/yolo_adapter.py b/backend/app/services/yolo_adapter.py index 362d8a28..14c4401f 100644 --- a/backend/app/services/yolo_adapter.py +++ b/backend/app/services/yolo_adapter.py @@ -72,6 +72,7 @@ class YoloDetectionAdapter: conf=float(confidence_threshold), imgsz=int(self.settings.yolo_image_size), device=self.settings.yolo_device, + max_det=int(self.settings.yolo_max_detections), verbose=False, ) except AppError: diff --git a/backend/tests/test_docker_runtime_config.py b/backend/tests/test_docker_runtime_config.py index 2a5d2234..a79c0721 100644 --- a/backend/tests/test_docker_runtime_config.py +++ b/backend/tests/test_docker_runtime_config.py @@ -93,6 +93,7 @@ def test_env_example_uses_runtime_env_names_read_by_backend_and_frontend() -> No assert "YOLO_MODEL_PATH=" in env_example assert "YOLO_CONFIG_DIR=./storage/ultralytics" in env_example assert "YOLO_MAX_TILES=100" in env_example + assert "YOLO_MAX_DETECTIONS=1000" in env_example assert "ENABLE_YOLO" not in env_example assert "ENABLE_SAM" not in env_example assert "VITE_API_BASE_URL=" in env_example @@ -245,4 +246,5 @@ def test_unraid_deploy_passes_ai_build_arg_and_yolo_runtime_env() -> None: 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 '-e YOLO_MAX_DETECTIONS="$YOLO_MAX_DETECTIONS"' in run_script assert "-v \"${GEOINTEL_MODELS_PATH}:/app/models\"" in run_script diff --git a/backend/tests/test_sprint8b_yolo_foundation.py b/backend/tests/test_sprint8b_yolo_foundation.py index 8dd71732..344fb3a5 100644 --- a/backend/tests/test_sprint8b_yolo_foundation.py +++ b/backend/tests/test_sprint8b_yolo_foundation.py @@ -93,7 +93,7 @@ class RecordingPredictModel: def __init__(self) -> None: self.seen_sources: list[dict] = [] - def predict(self, *, source, conf, imgsz, device, verbose): + def predict(self, *, source, conf, imgsz, device, verbose, max_det): from PIL import Image with Image.open(source) as image: @@ -106,6 +106,7 @@ class RecordingPredictModel: "imgsz": imgsz, "device": device, "verbose": verbose, + "max_det": max_det, } ) return [] @@ -399,6 +400,20 @@ def test_yolo_adapter_converts_single_band_tiles_to_rgb_before_prediction(tmp_pa assert model.seen_sources[0]["imgsz"] == 64 assert model.seen_sources[0]["device"] == "cpu" assert model.seen_sources[0]["verbose"] is False + assert model.seen_sources[0]["max_det"] == 1000 + + +def test_yolo_adapter_uses_configured_max_detections(tmp_path: Path) -> None: + Image = pytest.importorskip("PIL.Image") + tile_path = tmp_path / "rgb_tile.png" + Image.new("RGB", (16, 16), (10, 20, 30)).save(tile_path) + model = RecordingPredictModel() + settings = _settings(tmp_path, yolo_max_detections=1500) + + detections = YoloDetectionAdapter(settings).predict_tile(model, tile_path, confidence_threshold=0.25) + + assert detections == [] + assert model.seen_sources[0]["max_det"] == 1500 def test_yolo_adapter_wraps_prediction_runtime_errors(tmp_path: Path) -> None: diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index 9b38964a..5855b8d1 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -36,4 +36,5 @@ YOLO_CONFIG_DIR=/app/storage/ultralytics YOLO_DEVICE=cpu YOLO_IMAGE_SIZE=640 YOLO_MAX_TILES=100 +YOLO_MAX_DETECTIONS=1000 YOLO_BATCH_SIZE=1 diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index d5a0d30f..a5db4323 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -30,6 +30,7 @@ YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-/app/storage/ultralytics}" YOLO_DEVICE="${YOLO_DEVICE:-cpu}" YOLO_IMAGE_SIZE="${YOLO_IMAGE_SIZE:-640}" YOLO_MAX_TILES="${YOLO_MAX_TILES:-100}" +YOLO_MAX_DETECTIONS="${YOLO_MAX_DETECTIONS:-1000}" YOLO_BATCH_SIZE="${YOLO_BATCH_SIZE:-1}" install_dockerman_metadata() { @@ -90,6 +91,7 @@ docker run -d \ -e YOLO_DEVICE="$YOLO_DEVICE" \ -e YOLO_IMAGE_SIZE="$YOLO_IMAGE_SIZE" \ -e YOLO_MAX_TILES="$YOLO_MAX_TILES" \ + -e YOLO_MAX_DETECTIONS="$YOLO_MAX_DETECTIONS" \ -e YOLO_BATCH_SIZE="$YOLO_BATCH_SIZE" \ -v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data" \ -v "${GEOINTEL_STORAGE_PATH}:/app/storage" \ diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index c45dee2d..368fb299 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -28,6 +28,7 @@ services: YOLO_DEVICE: ${YOLO_DEVICE:-cpu} YOLO_IMAGE_SIZE: ${YOLO_IMAGE_SIZE:-640} YOLO_MAX_TILES: ${YOLO_MAX_TILES:-100} + YOLO_MAX_DETECTIONS: ${YOLO_MAX_DETECTIONS:-1000} YOLO_BATCH_SIZE: ${YOLO_BATCH_SIZE:-1} ports: - "${GEOINTEL_FRONTEND_PORT:-1202}:80" diff --git a/docker-compose.yml b/docker-compose.yml index 768ed8e0..a2877c3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: YOLO_DEVICE: ${YOLO_DEVICE:-cpu} YOLO_IMAGE_SIZE: ${YOLO_IMAGE_SIZE:-640} YOLO_MAX_TILES: ${YOLO_MAX_TILES:-100} + YOLO_MAX_DETECTIONS: ${YOLO_MAX_DETECTIONS:-1000} YOLO_BATCH_SIZE: ${YOLO_BATCH_SIZE:-1} ports: - "${GEOINTEL_BACKEND_PORT:-8000}:8000" diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index 421ddb8e..43a8abe2 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -109,8 +109,16 @@ Environment variables: - `YOLO_DEVICE` - `YOLO_IMAGE_SIZE` - `YOLO_MAX_TILES` +- `YOLO_MAX_DETECTIONS` - `YOLO_BATCH_SIZE` +`YOLO_MAX_DETECTIONS` is forwarded to Ultralytics as `max_det` for each +prediction call. GeoIntel defaults it to `1000` because building-rich AOIs can +contain far more than the Ultralytics default of 300 candidate boxes; keeping +the upstream default would cap recall before QA/QC begins. Operators may lower +the value for small rasters or raise it for dense urban tiles after reviewing +runtime and false-positive behavior. + ### Local model asset catalog GeoIntel can list local runtime model files mounted into the backend model diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index d4b1c603..5f1575e2 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -782,6 +782,9 @@ Validation errors: - `DETECTION_TILE_MANIFEST_NOT_FOUND` when the provided manifest path does not exist. - `DETECTION_TILE_MANIFEST_INVALID` when the manifest cannot be parsed or lacks tile metadata. - `DETECTION_TILE_LIMIT_EXCEEDED` when the manifest exceeds `YOLO_MAX_TILES`. +- Configured YOLO inference forwards `YOLO_MAX_DETECTIONS` to Ultralytics + `max_det` and defaults to `1000` so dense building AOIs are not silently + limited by the upstream default of 300 detections before persisted QA/QC. - `DETECTION_DEPENDENCY_UNAVAILABLE` when YOLO dependencies are not installed. - `DETECTION_MODEL_LOAD_FAILED` when the local model file exists but cannot be loaded. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 42bb5ae3..f084ff63 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,32 @@ +## Sprint 148 YOLO max-detection cap hardening (2026-07-09) + +Changed: +- Added backend setting `YOLO_MAX_DETECTIONS` / `Settings.yolo_max_detections`. +- `YoloDetectionAdapter` now forwards the value to Ultralytics as `max_det`. +- Default is `1000` instead of relying on Ultralytics' upstream default of 300. +- Added Docker/Unraid/runtime wiring: + - `.env.example` + - `docker-compose.yml` + - `docker-compose.unraid.yml` + - `deploy/unraid/geointel.env.example` + - `deploy/unraid/run-dockerman-container.sh` +- Updated backend/API/AI environment documentation. + +Why: +- Real Kempen building AOIs often contain more than 300 reference buildings. +- The previous configured-YOLO path could saturate at 300 detections before QA/QC, capping recall independently of model quality. +- This does not activate a model and does not fake detections; it removes an inference runtime cap so persisted QA/QC can measure candidate models honestly. + +Tested: +- Red step: targeted YOLO adapter tests failed because `max_det` was not passed to the model. +- `python -m pytest backend\tests\test_sprint8b_yolo_foundation.py::test_yolo_adapter_converts_single_band_tiles_to_rgb_before_prediction backend\tests\test_sprint8b_yolo_foundation.py::test_yolo_adapter_uses_configured_max_detections -q` (`2 passed`) +- Red step: Docker runtime config tests failed before `.env.example` and Unraid runner exposed `YOLO_MAX_DETECTIONS`. +- `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_sprint8b_yolo_foundation.py -q` (`16 passed`) + +Open: +- Rebuild/redeploy the Tower all-in-one image before rerunning live calibration so the container uses `YOLO_MAX_DETECTIONS=1000`. +- After deploy, rerun at least one high-density AOI calibration to confirm detection counts are no longer capped at 300. + ## Sprint 147 AOI512 YOLOv8s scale-match candidate gate (2026-07-09) Changed: diff --git a/docs/ENVIRONMENT_SPEC.md b/docs/ENVIRONMENT_SPEC.md index 262a904b..b0ec07f2 100644 --- a/docs/ENVIRONMENT_SPEC.md +++ b/docs/ENVIRONMENT_SPEC.md @@ -21,6 +21,7 @@ YOLO_ENABLED=false YOLO_MODEL_PATH= YOLO_MODEL_VERSION= YOLO_MAX_TILES=100 +YOLO_MAX_DETECTIONS=1000 ENABLE_GRB_WFS=false GRB_WFS_URL= OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter diff --git a/docs/TODO.md b/docs/TODO.md index c6e5d0cb..7da01f9f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -110,9 +110,11 @@ This file now starts with the current implementation status. Older preparation/b - [x] Benchmark an external remote-sensing YOLOv8l building candidate as an explicit local model asset. - [x] Train and gate the `uniquehardneg160e50` YOLOv8s candidate through 7 positive AOIs and 9 hard-negative/background samples. - [x] Train and gate an AOI-scale `aoi512e80` YOLOv8s candidate to test the 160px training-scale hypothesis. +- [x] Raise configured-YOLO `max_det` through `YOLO_MAX_DETECTIONS` so dense AOIs are not capped at 300 detections before QA/QC. - [ ] Find or train a materially stronger aerial/Kempen building model candidate; `geointel-building-yolov8n-expanded160e50-pt` is the best current dense-AOI candidate but still too weak and too noisy for a V1 default. - [ ] Train a higher-capacity local aerial-building detector with stronger positive recall while preserving the hard-negative false-positive gate. - [ ] Add more diverse positive AOIs and revisit geometry-to-box label strategy before the next default-model training attempt. +- [ ] Rerun live dense-AOI calibration after redeploy with `YOLO_MAX_DETECTIONS=1000` to measure uncapped recall and false-positive pressure. ## Sprint 8 status