document the analysis contracts and correct the job-queue claim

The README's architecture diagram and stack list promised Redis + RQ. There is
no such thing in this codebase: redis_url is a settings field nothing reads,
rq is not a dependency, and the only worker is an asyncio poller over a
Postgres job table. Describe what exists.

API_CONTRACTS and KNOWN_LIMITATIONS gain the rules a reader needs to interpret
a result: deterministic confidence-ranked matching, the precision/recall curve,
the three flood-hazard cell populations, the sub-cell selection fallback, the
whole-feature object count next to clipped area metrics, and the required
manifest CRS. The new limitations are stated as limits rather than buried:
raster analysis has no sub-cell precision, and the object count is not
fractional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:34:33 +02:00
co-authored by Claude Opus 5
parent 3e4e211fad
commit d29d572e8c
4 changed files with 153 additions and 14 deletions
+110 -7
View File
@@ -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,
+29 -3
View File
@@ -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.