diff --git a/.env.example b/.env.example index 85a0dc4d..64e99e5c 100644 --- a/.env.example +++ b/.env.example @@ -131,7 +131,13 @@ YOLO_IMAGE_SIZE=640 YOLO_MAX_TILES=100 YOLO_MAX_DETECTIONS=1000 YOLO_DUPLICATE_IOU_THRESHOLD=0.5 +# Tiles per model.predict call. 1 leaves most of a modern GPU idle on a run of +# a hundred tiles; raise it until VRAM is the limit. YOLO_BATCH_SIZE=1 +# Drop boxes truncated by an interior tile edge. Because tiles overlap, the +# neighbouring tile saw the same object whole, so the truncated half is a +# duplicate and a shape error at once. Boxes on the outer raster edge are kept. +YOLO_SUPPRESS_TILE_EDGE_DETECTIONS=true # Local segmentation models. GeoIntel never downloads model weights # automatically; point these to existing local files to enable inference. @@ -176,3 +182,7 @@ GEOINTEL_CORS_ORIGINS=https://geointel.itworx.tech,http://geointel.itworx.tech,h GEOINTEL_MAX_UPLOAD_MB=500 GEOINTEL_AOI_WORKER_ENABLED=false GEOINTEL_AOI_WORKER_POLL_SECONDS=2 +# Executes queued detection.run / segmentation.run jobs from POST +# /detection/run-async, so tiled GPU inference never blocks an HTTP request. +GEOINTEL_ANALYSIS_WORKER_ENABLED=false +GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS=2 diff --git a/README.md b/README.md index 7e38294e..061dffb7 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ verbergen. | Tijd | Immutable DatasetVersions en vergelijking van ruimtelijke snapshots | | QA/QC | Precision, recall, F1, IoU, false-positive/-negative evidence en reviews | | Export | Reproduceerbare GeoJSON- en artefactexports met provenance | -| Runtime | DockerMan-native Unraid-container met PostGIS, Redis, backend en frontend | +| Runtime | DockerMan-native Unraid-container met PostGIS, backend en frontend | ## Architectuur @@ -98,7 +98,7 @@ verbergen. flowchart LR UI["React + MapLibre workbench"] --> API["FastAPI contracts"] API --> GIS["GeoPandas / Shapely / Rasterio"] - API --> JOBS["Redis + RQ"] + API --> JOBS["Job-queue in PostgreSQL"] JOBS --> AI["PyTorch / YOLO / SAM"] GIS --> DB[("PostgreSQL + PostGIS")] AI --> DB @@ -118,12 +118,12 @@ PostGIS en de persistentieregels volgen - **Spatial:** GeoPandas, Shapely, Rasterio, PyProj en GDAL - **AI:** PyTorch, Ultralytics YOLO en SAM-compatibele segmentatie - **Data:** PostgreSQL/PostGIS, lokale artefactopslag en immutable versions -- **Jobs:** Redis en RQ +- **Jobs:** job-tabel in PostgreSQL met achtergrondworkers in het API-proces - **Deployment:** één DockerMan-native Unraid-container ## Lokaal starten -Vereisten: Python 3.11+, Node 20.19+ of 22.12+, PostgreSQL/PostGIS en Redis. +Vereisten: Python 3.11+, Node 20.19+ of 22.12+ en PostgreSQL/PostGIS. ```bash python -m pip install -e backend/.[dev] diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 4284d696..589efce2 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -617,13 +617,39 @@ is not a temporal observation and receives no fabricated `observed_at` value. ### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/flood-hazard/select` -Returns mapped positive-depth area in hectares, share of the selection, mean, -P90 and maximum modeled local depth and `modelled_max_depth_area_integral_m3`. -Every result identifies mechanism, climate context, probability class and -return period. The integral sums local modeled maximum depth times cell area; -it is explicitly not concurrent flood storage, permanent waterbody content, -current water level or bathymetry. These unsupported metrics remain listed in -the response. +Returns mapped positive-depth area in hectares, share of the *modelled* area, +mean, P90 and maximum modeled local depth and +`modelled_max_depth_area_integral_m3`. Every result identifies mechanism, +climate context, probability class and return period. The integral sums local +modeled maximum depth times cell area; it is explicitly not concurrent flood +storage, permanent waterbody content, current water level or bathymetry. These +unsupported metrics remain listed in the response. + +Three cell populations are reported separately, because conflating them turns +missing data into a claim of safety: + +- `selected_cell_count` — cells inside the drawn selection; +- `valid_cell_count` / `no_data_cell_count` — the split between cells the VMM + raster models and cells it does not; +- `inundated_cell_count` — modelled cells with a positive depth. + +`inundated_fraction` and `modelled_inundated_share_pct` are shares of the +modelled cells, not of the drawn selection. `inundated_fraction` is `null` when +nothing was modelled at all. `data_coverage_ratio`, `model_coverage_pct`, +`modelled_area_ha` and `selection_area_ha` make the difference between the +drawn area and the analysed area explicit, and `coverage_warning` states it in +words. A selection reaching past the modelled extent previously reported a +diluted risk share for the whole rectangle. + +**Selections finer than one source cell.** `geometry_mask` selects a cell when +its centre falls inside the geometry, so a rectangle smaller than a cell — or +one landing between four centres — selected nothing and the analysis returned +zeros indistinguishable from "nothing here". Every raster selection now falls +back to the cells the geometry touches and reports that in +`cell_selection_warning` (`coverage_warning` for flood hazard), because the +result then covers more ground than was drawn. This applies to terrain, +bathymetry, thematic raster and flood hazard, in both the single-dataset and +the partitioned paths. ### POST `/api/v1/projects/{project_id}/datasets/raster/flood-hazard/select` @@ -1056,6 +1082,21 @@ Return vector bounds and feature count. ### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select` +Selection metrics describe two different populations and now say so. +`intersection_area` and `intersection_length` clip each feature to the +selection, while the object count treats any feature touching the selection as +whole — which is what an operator expects from "objecten", but overstates the +count along every edge. The response therefore adds +`fully_covered_feature_count`, `partially_covered_feature_count` and +`selection_edge_warning`, and marks the count metric as an estimate whenever +the selection cuts features. Area and length metrics stay exact and do not +inherit that caveat. + +Partitioned selection (`/datasets/vector/partitions/select`) de-duplicates on +`source_feature_id` across municipal partitions for the returned geometry as +well as for the count. A feature on a shared boundary was previously counted +once but drawn once per partition. + Read-only spatial selection over persisted `vector_features`. Request: @@ -1619,6 +1660,44 @@ 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. +The manifest must carry explicit CRS metadata (`crs`, `source_crs` or +`dataset_crs`). A manifest without it fails with +`DETECTION_TILE_MANIFEST_INVALID` rather than being georeferenced against an +assumed EPSG:4326, which would place detections plausibly but wrongly. + +Tiles are read with rasterio: the visible RGB bands are selected explicitly and +percentile-stretched to 8-bit, so 16-bit and 4-band (RGB + NIR) orthophotos +reach the model as the kind of image it was trained on. Tiles are predicted in +batches of `YOLO_BATCH_SIZE`. + +Post-processing removes two artefacts of tiled inference: + +- boxes truncated by an *interior* tile edge are dropped, because the + overlapping neighbouring tile observed the same object completely + (`YOLO_SUPPRESS_TILE_EDGE_DETECTIONS`, default on). Boxes against the outer + raster edge are kept; +- duplicates are suppressed on IoU *and* on intersection-over-smaller-area, so + an object wider than the tile overlap does not survive as two partial boxes. + +`result_json` reports `raw_detection_count`, `suppressed_detection_count`, +`tile_edge_truncated_count`, `duplicate_iou_threshold` and +`containment_suppression_threshold`. + +### POST `/api/v1/detection/run-async` + +Same request body as `POST /api/v1/detection/run`, but queues the run instead of +executing it inside the request, and returns a `JobRead`. Tiled GPU inference +over up to `YOLO_MAX_TILES` tiles takes minutes; performing it in the request +holds a worker thread and times the client out. Cheap validation (project, +dataset, dataset type) still happens synchronously, so an invalid request is +rejected immediately rather than by a job that fails minutes later. + +Queued jobs are executed by the background analysis worker +(`GEOINTEL_ANALYSIS_WORKER_ENABLED`, poll interval +`GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS`), which claims a job before dispatching +it so the same run is never started twice. Poll `GET /api/v1/jobs/{id}` for +progress. `POST /api/v1/segmentation/run-async` behaves identically. + Unavailable model response: ```json @@ -1759,6 +1838,30 @@ candidate geometry versus the persisted reference footprint. Envelope results are explicitly `diagnostic_only` and are persisted in `quality_checks.findings_json`; they never replace or inflate canonical metrics. +`box_to_footprint_diagnostics.candidate_geometry_mode` reports whether the +candidates are `axis_aligned_boxes` or `footprint_polygons`. For a box detector +the strict footprint IoU has a ceiling below 1 on rotated or non-rectangular +buildings, and the response says so in `warnings` and in `interpretation`. + +Matching is deterministic. Candidates are ranked by confidence, highest first, +with feature identity as tiebreaker, before the greedy IoU assignment. Database +row order is not usable for this: every detection in a run shares one +transaction timestamp, so ordering by `created_at` left the assignment — and +therefore the score and the false-positive evidence shown to a reviewer — +undefined between identical runs. + +The response also returns `precision_recall_curve`: precision, recall and F1 at +every confidence value present in the run, plus `average_precision`, `best_f1` +and `best_f1_threshold`. A single F1 describes one operating point and cannot +compare two models whose calibration differs; the curve can. `average_precision`, +`best_f1` and `best_f1_threshold` are persisted as `metrics` rows alongside the +existing ones. + +Segmentation QA (`POST /api/v1/segmentation/runs/{analysis_run_id}/qa/reference`) +applies the same tile-coverage clipping and returns the same `coverage` block. +Without it, every reference feature outside the inferred tiles counted as a +false negative and recall was understated by an arbitrary amount. + Configured-YOLO QA fails closed with `DETECTION_QA_COVERAGE_UNAVAILABLE` when manifest provenance is absent, `DETECTION_QA_COVERAGE_MISMATCH` when it belongs to another raster, `DETECTION_QA_COVERAGE_INVALID` when bounds/CRS are invalid, diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index a51097fb..5eb41565 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -43,6 +43,16 @@ runtime source of truth. underlying named Area itself is wholly valid. - Cross-region and land/sea selections stay split by legal/source zone. Semantically incompatible metrics are not merged. +- Raster analysis is cell-based. A selection smaller than one source cell is + answered over the cells it touches, so the analysed area is larger than the + drawn area; the response says so rather than returning zeros. Sub-cell + precision is not available from a raster source. +- The object count in a vector selection counts whole features that touch the + selection, while area and length metrics clip to it. The response reports how + many features the edge cuts; it does not attempt a fractional object count. +- Flood-hazard percentages are shares of the modelled area. Where the VMM model + does not cover the selection, that is reported as missing coverage and never + as an absence of risk. ## Historical analysis @@ -68,12 +78,28 @@ runtime source of truth. `YOLO_SEG_MODEL_PATH`/`SAM_MODEL_PATH` to existing local weights and enables them explicitly. GeoIntel never downloads segmentation weights automatically; fixture segmentation remains explicit-only. +- Detection QA reports both a strict footprint IoU and an envelope diagnostic. + For an axis-aligned box detector the strict figure has a ceiling below 1 on + rotated or non-rectangular buildings; the response states which geometry mode + applies rather than silently attributing that gap to detection quality. +- Segmentation inference still predicts one tile per call. Batching is + implemented for detection only, because the SAM adapter is prompt-driven and + does not share the detector's batch semantics. +- Tile-edge truncated boxes are dropped in favour of the overlapping + neighbour's complete view. On a manifest generated with `overlap=0` there is + no such neighbour, so an object on a seam is lost rather than duplicated. + Generate tiles with an overlap larger than the biggest expected object. ## Operations -- Long AI/GIS work still uses the existing synchronous job abstraction rather - than a distributed durable queue. Interrupted synchronous work is marked - failed on restart and must be retried explicitly. +- Detection and segmentation runs can be queued through + `POST /detection/run-async` and executed by the in-process analysis worker, so + tiled GPU inference no longer blocks an HTTP request. This is a job table in + PostgreSQL polled by the API process, not a distributed durable queue: there + is no cross-host distribution and no automatic retry. The synchronous + `POST /detection/run` remains available for small runs. Interrupted work is + marked failed on restart and must be retried explicitly. +- Other long GIS work still uses the synchronous job abstraction. - GeoIntel remains a controlled single-operator product. The optional guest demo adds bounded presentation access only; multi-user authorization and tenant isolation remain outside the product scope.