Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 89 additions & 32 deletions api/hastefuncapi/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1367,16 +1367,21 @@ async def GetLayerModelsDetails(req: func.HttpRequest) -> func.HttpResponse:
return func.HttpResponse("Error loading models.", status_code=500)


# Embedding-model artifacts the Interactive Labeler fetches by HTTP byte
# range, mapped to the Model field that holds each blob URL.
# Model artifacts the browser fetches by HTTP byte range, mapped to the
# Model field that holds each blob URL.
_MODEL_ARTIFACT_URL_FIELDS = {
"pmtiles": "pmtilesUrl",
"sidecar": "featuresSidecarUrl",
"geojson": "embeddingsGeoJSONUrl",
"gpkg": "gpkgUrl",
}
# Artifacts owned by the ImageLayer rather than a model. Footprint
# geometry is shared by every model trained on the layer, so it lives
# here and is looked up by imageLayerId.
_LAYER_ARTIFACT_URL_FIELDS = {
"footprint_pmtiles": "footprintPmtilesUrl",
}
_MODEL_ARTIFACT_CONTENT_TYPES = {
"pmtiles": "application/octet-stream",
"footprint_pmtiles": "application/vnd.pmtiles",
"sidecar": "application/octet-stream",
"geojson": "application/geo+json",
"gpkg": "application/geopackage+sqlite3",
Expand All @@ -1400,47 +1405,99 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse:
blob I/O server-side over the Azure backbone, honoring ``Range`` so
pmtiles.js can do partial reads.

Supported ``kind`` values: ``pmtiles``, ``sidecar`` and ``geojson``
(fetched/parsed in-browser), plus ``gpkg`` — the per-building
predictions GeoPackage saved by ``PutBuildingPredictions``, served as
a downloadable attachment. Example:
Supported ``kind`` values: ``sidecar`` and ``geojson``
(fetched/parsed in-browser), ``gpkg`` — the per-building predictions
GeoPackage saved by ``PutBuildingPredictions``, served as a
downloadable attachment — and ``footprint_pmtiles``, the image
layer's shared building-footprint vector tiles. Example:
``GET /api/GetModelArtifact?projectId=<pid>&modelId=<mid>&kind=gpkg``.

``footprint_pmtiles`` is layer-scoped: geometry is shared by every
model trained on the layer, so it resolves against ``imageLayerId``
(taken from the query string, else from the model's own layer).
"""
try:
project_id = _require_guid_param(req, "projectId")
model_id = _require_short_int_id_param(req, "modelId")
except ValueError as e:
return _bad_request(str(e))

kind = (req.params.get("kind") or "").lower()
url_field = _MODEL_ARTIFACT_URL_FIELDS.get(kind)
if url_field is None:
layer_url_field = _LAYER_ARTIFACT_URL_FIELDS.get(kind)
if url_field is None and layer_url_field is None:
return _bad_request(
f"kind must be one of {sorted(_MODEL_ARTIFACT_URL_FIELDS)}"
)
"kind must be one of "
f"{sorted({**_MODEL_ARTIFACT_URL_FIELDS, **_LAYER_ARTIFACT_URL_FIELDS})}"
)

# A layer-scoped artifact belongs to the image layer, so an
# imageLayerId alone is enough to reach it: a standard-workflow layer
# may have no models at all. modelId stays accepted, and is still
# required for every model-scoped kind.
explicit_layer_id = req.params.get("imageLayerId")
layer_only_request = bool(
layer_url_field is not None
and explicit_layer_id
and not req.params.get("modelId")
)
model_id = None
if not layer_only_request:
try:
model_id = _require_short_int_id_param(req, "modelId")
except ValueError as e:
return _bad_request(str(e))

try:
model = await asyncio.to_thread(
MetadataProcessor(
data_type=config.get_metadata_types().MODEL.value,
partition_key=project_id,
).load,
model_id,
)
except FileNotFoundError:
return func.HttpResponse("Model not found.", status_code=404)
except Exception as e:
logger.error(
f"GetModelArtifact model load failed: {e}\n"
f"{traceback.format_exc()}"
)
return func.HttpResponse("Error loading model.", status_code=500)
document = None
if model_id is not None:
try:
document = await asyncio.to_thread(
MetadataProcessor(
data_type=config.get_metadata_types().MODEL.value,
partition_key=project_id,
).load,
model_id,
)
except FileNotFoundError:
return func.HttpResponse("Model not found.", status_code=404)
except Exception as e:
logger.error(
f"GetModelArtifact model load failed: {e}\n"
f"{traceback.format_exc()}"
)
return func.HttpResponse("Error loading model.", status_code=500)

if layer_url_field is not None:
# Layer-scoped artifact: take an explicit imageLayerId when the
# caller passes one, otherwise the model's own layer.
url_field = layer_url_field
model_document = document or {}
image_layer_id = explicit_layer_id or model_document.get(
"imageLayerId"
)
if not image_layer_id or not _GUID_RE.match(str(image_layer_id)):
return _bad_request("Invalid or missing parameter: imageLayerId")
try:
document = await asyncio.to_thread(
MetadataProcessor(
data_type=config.get_metadata_types().IMAGELAYER.value,
partition_key=project_id,
).load,
image_layer_id,
)
except FileNotFoundError:
return func.HttpResponse("Image layer not found.", status_code=404)
except Exception as e:
logger.error(
f"GetModelArtifact image layer load failed: {e}\n"
f"{traceback.format_exc()}"
)
return func.HttpResponse(
"Error loading image layer.", status_code=500
)

blob_url = (model or {}).get(url_field) or ""
blob_url = (document or {}).get(url_field) or ""
if not blob_url:
return func.HttpResponse(
"Artifact not available for this model.", status_code=404
)
return func.HttpResponse("Artifact not available.", status_code=404)

try:
offset, length, is_range = parse_byte_range(req.headers.get("Range"))
Expand Down
99 changes: 98 additions & 1 deletion api/hastefuncqueues/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from hastegeo.core.models.training import ExperimentConfig
from hastegeo.core.processors.artifacts import ArtifactProcessor
from hastegeo.core.processors.embedding import EmbeddingPostprocessor
from hastegeo.core.processors.footprint_tiles import FootprintTilesPreprocessor
from hastegeo.core.processors.imagery import ImageryPostProcessor
from hastegeo.core.processors.inference import (
InferencePostprocessor,
Expand Down Expand Up @@ -601,6 +602,100 @@ async def GetRunEmbeddingQueueMessage(msg: func.QueueMessage) -> None:
)


@app.function_name(name="GetPrepareFootprintTilesQueueTrigger")
@app.queue_trigger(
arg_name="msg",
queue_name=config.get_queue_config()["footprint_tiles_queue_name"],
connection="AzureWebJobsStorage",
)
async def GetPrepareFootprintTilesQueueMessage(
msg: func.QueueMessage,
) -> None:
"""Build an image layer's shared building-footprint vector tiles.

Message schema (identifiers only)::

{"projectId", "imageLayerId", "sourceFootprintsUrl", "force"}

Imagery prep enqueues this as soon as a layer's footprints are cached,
so every map that draws those buildings finds the archive already
built. The authoritative state is read from metadata, so a fresh
request and the preprocessor's own poll messages take the same path.
The work runs as a task in the training docker image because
tippecanoe ships only there.
"""
logger.info(
"GetPrepareFootprintTilesQueueTrigger function processed a message: "
f'{msg.get_body().decode("utf-8")}'
)
try:
payload = json.loads(msg.get_body().decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Queue message must be a JSON object")
project_id = payload.get("projectId")
image_layer_id = payload.get("imageLayerId")
if not project_id or not image_layer_id:
raise ValueError(
"Queue message requires projectId and imageLayerId, got: "
f"{sorted(payload.keys())}"
)

try:
layer_record = await asyncio.to_thread(
MetadataProcessor(
data_type=config.get_metadata_types().IMAGELAYER.value,
partition_key=project_id,
).load,
image_layer_id,
)
except FileNotFoundError:
layer_record = None

if not layer_record:
logger.info(
f"Image layer {image_layer_id} not found, likely deleted, "
"skipping footprint tile preparation."
)
return

image_layer = ImageLayer(**layer_record)
if not image_layer.buildingFootprintsUrl:
logger.info(
f"Image layer {image_layer_id} has no cached building "
"footprints; nothing to tile."
)
return

output = await asyncio.to_thread(
FootprintTilesPreprocessor(image_layer).process
Comment on lines +669 to +670
)

await asyncio.to_thread(
MetadataProcessor(
data_type=config.get_metadata_types().IMAGELAYER.value,
partition_key=project_id,
).save,
image_layer_id,
output.dict(),
)
except ValidationError as e:
logger.error(
f"GetPrepareFootprintTilesQueueTrigger: Validation error: {e}\n"
f"{traceback.format_exc()}"
)
except ValueError as e:
logger.error(
"GetPrepareFootprintTilesQueueTrigger: Invalid queue message: "
f"{e}\n{traceback.format_exc()}"
)
except Exception as e:
logger.error(
"GetPrepareFootprintTilesQueueTrigger: Error processing queue "
f"message: {e}\n{traceback.format_exc()}",
stack_info=True,
)


@app.function_name(name="GetRunInferenceQueueTrigger")
@app.queue_trigger(
arg_name="msg",
Expand Down Expand Up @@ -1013,7 +1108,9 @@ async def GetPublishDatasetQueueMessage(msg: func.QueueMessage) -> None:
message = PublishQueueMessage(
**json.loads(msg.get_body().decode("utf-8"))
)
await asyncio.to_thread(PublishingProcessor(config=config).run_step, message)
await asyncio.to_thread(
PublishingProcessor(config=config).run_step, message
)
except Exception as error:
logger.error(
"PublishDatasetQueueTrigger failed with %s",
Expand Down
1 change: 1 addition & 0 deletions docker/data-init/upload_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def create_queues():
"local-zip-queue",
"local-inference-queue",
"local-embedding-queue",
"local-footprint-tiles-queue",
"local-image-queue-poison",
"local-embedding-queue-poison"
]
Expand Down
2 changes: 2 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ services:
ZIP_QUEUE_NAME: "local-zip-queue"
INFERENCE_QUEUE_NAME: "local-inference-queue"
EMBEDDING_QUEUE_NAME: "local-embedding-queue"
FOOTPRINT_TILES_QUEUE_NAME: "local-footprint-tiles-queue"
PUBLISH_QUEUE_NAME: "local-publish-queue"
PUBLISHING_ENABLED: "true"
PC_PROVIDER_ENABLED: "false"
Expand Down Expand Up @@ -154,6 +155,7 @@ services:
ZIP_QUEUE_NAME: "local-zip-queue"
INFERENCE_QUEUE_NAME: "local-inference-queue"
EMBEDDING_QUEUE_NAME: "local-embedding-queue"
FOOTPRINT_TILES_QUEUE_NAME: "local-footprint-tiles-queue"
PUBLISH_QUEUE_NAME: "local-publish-queue"
PUBLISHING_ENABLED: "true"
PC_PROVIDER_ENABLED: "false"
Expand Down
10 changes: 7 additions & 3 deletions docker/training/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ RUN for i in 1 2 3 4 5; do \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# CLI shim for the building-embedding workflow (mirrors imageryprep's
# prepare-imagery shim). `python` resolves to the conda env at runtime via PATH.
# CLI shims for the workflows that run in this image (mirroring
# imageryprep's prepare-imagery shim). `python` resolves to the conda env
# at runtime via PATH. prepare-footprint-tiles lives here because
# tippecanoe ships only in this image.
RUN printf '#!/bin/bash\nexec python -m hastegeo.workflows.embed_buildings "$@"\n' > /usr/local/bin/embed-buildings \
&& chmod +x /usr/local/bin/embed-buildings
&& chmod +x /usr/local/bin/embed-buildings \
&& printf '#!/bin/bash\nexec python -m hastegeo.workflows.prepare_footprint_tiles "$@"\n' > /usr/local/bin/prepare-footprint-tiles \
&& chmod +x /usr/local/bin/prepare-footprint-tiles

# Prepare conda env directory owned by the target user, then extract the packed env
# as that user — non-root tar uses the running user's UID/GID, so no chown -R needed.
Expand Down
28 changes: 18 additions & 10 deletions hastelib/src/hastegeo/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ class ArtifactTypes(Enum):
- BUILDING_FOOTPRINTS: Cached Overture Maps building footprints, scoped
to the image layer's AOI. Generated during imageryprep so the
inference workflow can reuse the same set across multiple model runs.
- LAYER_FOOTPRINT_PMTILES: Geometry-only vector tiles of an image
layer's cached building footprints, carrying the row-index ``id``
and ``overture_id``. Built once per layer and shared by every
model trained on it, so nothing model-specific belongs in it.
- VALID_AREA_MASK: GeoJSON FeatureCollection of the valid-data polygon
derived from the post-event mosaic — i.e. the imagery's actual
AOI excluding nodata. Same polygon used to bbox-filter Overture;
Expand Down Expand Up @@ -137,6 +141,7 @@ class ArtifactTypes(Enum):
BUILDING_FOOTPRINTS = Template(
"building_footprints_${projectId}_${imageLayerId}"
)
LAYER_FOOTPRINT_PMTILES = Template("footprints_${imageLayerId}")
VALID_AREA_MASK = Template("valid_area_mask_${projectId}_${imageLayerId}")
INFERENCE_GPKG = Template("predicted_damage_${modelName}")
VISUALIZER = Template(
Expand All @@ -146,11 +151,12 @@ class ArtifactTypes(Enum):
TRAINING_ARTIFACTS_ZIP = Template("training_artifacts_${modelName}")
INFERENCE_ARTIFACTS_ZIP = Template("inference_artifacts_${modelName}")
# Building labeling workflow: per-building MOSAIKS / DINOv2 embeddings
# (footprints + f_* feature columns), the matching PMTiles vector tiles
# (geometry + id only), the binary HFTR sidecar (id -> feature vector),
# and the per-building predictions written by the interactive labeler.
# (footprints + f_* feature columns), the binary HFTR sidecar
# (id -> feature vector), and the per-building predictions written by
# the interactive labeler. The footprint vector tiles are NOT here:
# they belong to the image layer (LAYER_FOOTPRINT_PMTILES), shared by
# every model trained on it.
BUILDING_EMBEDDINGS = Template("building_embeddings_${modelName}")
BUILDING_PMTILES = Template("building_pmtiles_${modelName}")
BUILDING_FEATURES_SIDECAR = Template("building_features_${modelName}")
BUILDING_PREDICTIONS_GPKG = Template("building_predictions_${modelName}")

Expand Down Expand Up @@ -321,18 +327,19 @@ def get_queue_config():
"publish_queue_name": os.getenv(
"PUBLISH_QUEUE_NAME", "publish-queue"
),
# Layer footprint vector tiles. Runs in the training container
# because tippecanoe ships only in that image.
"footprint_tiles_queue_name": os.getenv(
"FOOTPRINT_TILES_QUEUE_NAME", "footprint-tiles-queue"
),
}

@staticmethod
def get_publishing_config():
"""Get publishing feature and provider configuration."""
return {
"publishing_enabled": _get_bool_env(
"PUBLISHING_ENABLED", True
),
"pc_provider_enabled": _get_bool_env(
"PC_PROVIDER_ENABLED", False
),
"publishing_enabled": _get_bool_env("PUBLISHING_ENABLED", True),
"pc_provider_enabled": _get_bool_env("PC_PROVIDER_ENABLED", False),
"max_total_bytes": _get_bounded_int_env(
"PUBLISH_MAX_TOTAL_BYTES", 5 * 1024**3, 1
),
Expand Down Expand Up @@ -405,6 +412,7 @@ class DataTypes(Enum):
EXPERIMENT_CONFIG = "experiment_config"
IMAGERY_CONFIG = "imageryprep_config"
EMBEDDING_CONFIG = "embedding_config"
FOOTPRINT_TILES_CONFIG = "footprint_tiles_config"
PROCESSED_IMAGERY = "processed_imagery_post_event_cog"
RAW_IMAGERY = "raw_imagery"
PREVIEW_RAW_IMAGERY = "preview_raw_imagery"
Expand Down
Loading
Loading