Files
geointel/backend/tests/test_analysis_job_queue.py
T
JensandClaude Opus 5 08188005bd correct the tiled inference chain and move runs off the request thread
Tile handling produced results that were wrong before any model quality
question arose:

- orthophoto tiles reached the model through PIL convert("RGB"), which
  truncates the high byte of a 16-bit product and treats a 4-band RGB+NIR
  tile's infrared channel as colour. Tiles are now read with rasterio, the
  visible bands are chosen explicitly, and values are percentile-stretched
  across all three bands together so hue is preserved;
- an object wider than the tile overlap was truncated by both tiles into two
  boxes that barely intersect, so IoU suppression kept both: two false
  positives and one missed footprint per seam building. Suppression now also
  compares overlap against the smaller box, and boxes cut by an interior tile
  edge are dropped in favour of the neighbouring tile's complete view;
- georeferencing fell back to an assumed EPSG:4326 when a manifest carried no
  CRS, producing geometry that renders plausibly in the wrong place. QA
  already refused such a tile; inference now fails closed too.

Segmentation QA scored candidates against every reference feature in the
dataset, so every building outside the inferred tiles counted as a false
negative. It now applies the same persisted tile coverage that detection QA
has always used, including the indexed ST_Intersects prefilter.

Duplicate suppression uses an STRtree instead of the O(n^2) scan, tiles are
predicted in batches of YOLO_BATCH_SIZE (a setting that existed but was never
read), and detection/segmentation runs can be queued through /run-async for a
polling background worker rather than holding an HTTP worker thread for
minutes of GPU work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:32:44 +02:00

177 lines
5.1 KiB
Python

"""Tiled GPU inference must not run inside an HTTP request.
A configured YOLO run walks up to ``YOLO_MAX_TILES`` tiles through the GPU.
Doing that in the request handler holds a worker thread for minutes, gives the
operator no progress, and times the client out before the result exists. The
run is queued as a Job instead and executed by a background worker, which is
the same pattern the AOI operations already use.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from app.core.errors import AppError
from app.models import AnalysisRun, Detection, Job
from app.services.analysis_job_worker import AnalysisJobWorker
from app.services.detection_service import DetectionService
class FakeQuery:
def __init__(self, rows):
self.rows = list(rows)
def filter(self, *criteria):
return self
def order_by(self, *_args):
return self
def limit(self, count):
self.rows = self.rows[:count]
return self
def all(self):
return list(self.rows)
class FakeSession:
def __init__(self, objects=None, query_rows=None):
self.objects = dict(objects or {})
self.query_rows = query_rows or {}
self.added = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
def query(self, model):
return FakeQuery(self.query_rows.get(model, []))
def add(self, item):
self.added.append(item)
if getattr(item, "id", None) is not None:
self.objects[(item.__class__, item.id)] = item
def commit(self):
return None
def rollback(self):
return None
def refresh(self, _item):
return None
def close(self):
return None
def _queued_job(**parameters) -> Job:
payload = {
"project_id": str(uuid4()),
"dataset_id": str(uuid4()),
"model_id": "yolo-configured",
"confidence_threshold": 0.4,
"class_filter": ["building"],
"tile_manifest_path": "/tiles/manifest.json",
"parameters_json": {},
}
payload.update(parameters)
return Job(
id=uuid4(),
job_type="detection.run",
status="queued",
project_id=uuid4(),
parameters_json=payload,
)
def test_queued_detection_job_is_dispatched_to_the_detection_service(monkeypatch) -> None:
job = _queued_job()
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
calls: list[dict] = []
def fake_run(**kwargs):
calls.append(kwargs)
return type(
"Result",
(),
{
"status": "success",
"detection_count": 3,
"analysis_run_id": uuid4(),
"job_id": kwargs["existing_job"].id,
"model_dump": lambda self, **_: {"status": "success", "detection_count": 3},
},
)()
monkeypatch.setattr(DetectionService, "run_detection", staticmethod(fake_run))
processed = AnalysisJobWorker.run_once(db=db)
assert processed == 1
assert calls[0]["model_id"] == "yolo-configured"
assert calls[0]["confidence_threshold"] == 0.4
assert calls[0]["tile_manifest_path"] == "/tiles/manifest.json"
assert calls[0]["existing_job"] is job
assert job.status == "success"
def test_a_failing_run_marks_the_job_failed_instead_of_leaving_it_running(monkeypatch) -> None:
job = _queued_job()
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
def exploding(**_kwargs):
raise AppError(code="DETECTION_TILE_NOT_FOUND", message="missing tile", status_code=422)
monkeypatch.setattr(DetectionService, "run_detection", staticmethod(exploding))
processed = AnalysisJobWorker.run_once(db=db)
assert processed == 1
assert job.status == "failed"
assert job.error_message == "missing tile"
assert job.result_json["error_code"] == "DETECTION_TILE_NOT_FOUND"
def test_an_unexpected_error_still_closes_the_job(monkeypatch) -> None:
job = _queued_job()
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
def exploding(**_kwargs):
raise RuntimeError("CUDA out of memory")
monkeypatch.setattr(DetectionService, "run_detection", staticmethod(exploding))
AnalysisJobWorker.run_once(db=db)
assert job.status == "failed"
assert job.result_json["error_code"] == "ANALYSIS_JOB_INTERNAL_ERROR"
def test_job_types_the_worker_does_not_own_are_left_alone() -> None:
job = _queued_job()
job.job_type = "raster.clip"
db = FakeSession(objects={(Job, job.id): job}, query_rows={Job: [job]})
assert AnalysisJobWorker.run_once(db=db) == 0
assert job.status == "queued"
def test_enqueue_validates_before_accepting_the_job() -> None:
"""A bad request is rejected up front, not minutes later in the worker."""
db = FakeSession()
with pytest.raises(AppError) as exc_info:
DetectionService.enqueue_detection(
db=db,
project_id=uuid4(),
dataset_id=uuid4(),
model_id="yolo-configured",
confidence_threshold=0.4,
)
assert exc_info.value.code == "PROJECT_NOT_FOUND"