# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-30-2026
"""
Headless workflow engine behind the rapidtools asset-analysis GUI.
This module reproduces the detect / extract / analyze workflow of the
examples without any GUI dependency, so it can be unit tested and scripted:
1. **Imagery** - a local orthomosaic, or a Bing / Google satellite
basemap stitched over a region (:meth:`AssetAnalysisWorkflow.download_basemap`).
2. **Detection** - discover assets in aerial imagery with Meta's SAM 3
(:meth:`AssetAnalysisWorkflow.detect`), or discover objects along a
Mapillary street survey from its segmentation metadata
(:meth:`AssetAnalysisWorkflow.discover_street`); buildings and roads
are regularized into clean GIS-ready polygons.
3. **Inference** - crop imagery around every asset (aerial crops, Google
Street View, Mapillary panoramas or Mapillary object crops) and run a
vision-language model (cloud APIs such as Gemini, Claude, OpenAI, Meta
Muse Spark and Qwen, or local checkpoints such as Gemma-4, Llama, Muse
Glimmer and Qwen) with a user-supplied prompt
(:meth:`AssetAnalysisWorkflow.analyze`).
The same model backends power the prompt assistant of the GUI
(:meth:`AssetAnalysisWorkflow.assist`), which drafts, refines and reviews
prompts through :mod:`rapidtools.gui.prompt_builder`.
"""
from __future__ import annotations
import gc
import logging
import math
import re
import sys
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, TypedDict
from rapidtools.core import OperationCancelled, PhysicalAssetCollection
from rapidtools.gui.prompt_builder import ASSIST_ACTIONS, PromptAssistant
logger = logging.getLogger(__name__)
# Backend metadata comes straight from the model registry, which is import-
# light (no PyTorch/Transformers) so the GUI starts quickly.
from rapidtools.models.catalogs import PROVIDERS, ModelInfo # noqa: E402
InferenceBackend = Literal[
'gemini',
'claude',
'openai',
'muse_spark',
'qwen',
'gemma4',
'llama',
'muse_glimmer',
'qwen_vl',
'hf',
]
def _backend_spec(info: ModelInfo) -> dict[str, Any]:
"""Translate a :class:`ModelInfo` into the dict the web page consumes."""
spec: dict[str, Any] = {
'label': info.label,
'kind': info.kind,
'description': info.description,
'default_model': info.analyzer_default_model or info.default_model,
'models': info.model_ids,
'attribute_prefix': info.attribute_prefix,
}
if info.is_api:
spec['key_label'] = info.key_label
spec['key_env'] = info.key_env[0] if info.key_env else ''
else:
spec['supports_4bit'] = info.supports_4bit
spec['supports_batch'] = info.supports_batch
if info.free_form_model:
spec['free_form_model'] = True
return spec
# Every analysis model rapidtools ships, in the order the GUI presents them.
MODEL_BACKENDS: dict[str, dict[str, Any]] = {
key: _backend_spec(info)
for key, info in PROVIDERS.items()
if info.kind in ('api', 'local')
}
# Convenience constants kept for backwards compatibility with earlier GUIs.
DEFAULT_GEMINI_MODEL_ID = MODEL_BACKENDS['gemini']['default_model']
DEFAULT_CLAUDE_MODEL_ID = MODEL_BACKENDS['claude']['default_model']
DEFAULT_OPENAI_MODEL_ID = MODEL_BACKENDS['openai']['default_model']
GEMMA4_MODEL_IDS = tuple(MODEL_BACKENDS['gemma4']['models'])
LLAMA_MODEL_IDS = tuple(MODEL_BACKENDS['llama']['models'])
OPEN_VLM_MODEL_IDS = tuple(MODEL_BACKENDS['hf']['models'])
# Basemaps that can be stitched over a region without an API key.
BASEMAP_PROVIDERS: dict[str, dict[str, Any]] = {
'bing': {'label': 'Bing', 'default_zoom': 19, 'max_zoom': 20},
'google': {'label': 'Google', 'default_zoom': 19, 'max_zoom': 21},
}
DETECTION_BASEMAPS = ('bing', 'google', 'recon')
# Where the crops analysed by the model come from, in the order the GUI shows.
IMAGERY_SOURCES: dict[str, dict[str, Any]] = {
'aerial': {
'label': 'Aerial crops from the loaded image',
'description': 'Crop the loaded orthomosaic around each asset footprint.',
'needs_token': False,
'supports_outline': True,
},
'google_streetview': {
'label': 'Google Street View',
'description': 'Nearest official panorama cropped to the footprint; no key.',
'needs_token': False,
'supports_outline': False,
},
'mapillary': {
'label': 'Mapillary panoramas',
'description': 'Views of each footprint from a Mapillary survey (token).',
'needs_token': True,
'supports_outline': False,
},
'mapillary_objects': {
'label': 'Mapillary object crops',
'description': 'Closest views of objects discovered along a street survey.',
'needs_token': True,
'supports_outline': True,
},
}
# Plain-English classes MapillaryFeatureExtractor understands out of the box.
STREET_CLASS_EXAMPLES = (
'vehicles',
'utility poles',
'traffic signs',
'fire hydrants',
'street lights',
'trash cans',
'benches',
'mailboxes',
)
MAX_BASEMAP_TILES = 2500
def list_available_models(backend: str, api_key: str = '') -> list[str]:
"""
Return the model IDs a backend can run.
Cloud backends query the provider with ``api_key``; local backends return
the curated list of Hugging Face repositories. Falls back to the static
catalogue when the provider cannot be reached.
"""
spec = MODEL_BACKENDS.get(backend)
if spec is None:
raise ValueError(f'Unknown inference backend: {backend!r}')
static = list(spec['models'])
if backend == 'hf':
from rapidtools.models import HFVisionInference
hub = HFVisionInference.search_hub_models()
return static + [m for m in hub if m not in static]
if spec['kind'] != 'api':
return static
if not api_key.strip():
return static
from rapidtools.models import get_model_class
from rapidtools.models.api_base import BaseAPIInferenceModel
model_class = get_model_class(backend)
try:
if not issubclass(model_class, BaseAPIInferenceModel):
raise TypeError(f'{model_class.__name__} cannot list remote models.')
remote = model_class.list_available_models(api_key.strip())
except Exception as exc: # noqa: BLE001 - network errors degrade gracefully
logger.warning(f'Could not list {spec["label"]} models: {exc}')
remote = []
if not remote:
return static
# Keep the provider order but make sure the default is offered first.
ordered = [m for m in static if m in remote]
ordered += [m for m in remote if m not in static]
return ordered
class WorkflowCancelled(OperationCancelled):
"""Raised when a workflow run is cancelled by the user."""
def slugify(text: str) -> str:
"""Turn a free-form asset name into a filesystem-safe token."""
slug = re.sub(r'[^0-9A-Za-z]+', '_', text.strip().lower()).strip('_')
return slug or 'asset'
def parse_asset_list(text: str) -> list[str]:
"""
Split a user-entered string of asset names into a clean list.
Names may be separated by commas, semicolons or newlines. Duplicates are
removed while preserving the order the user typed them in.
>>> parse_asset_list('building, tree; road\nbuilding')
['building', 'tree', 'road']
"""
seen: set[str] = set()
assets: list[str] = []
for raw in re.split(r'[,;\n]+', text):
name = ' '.join(raw.split())
if name and name.lower() not in seen:
seen.add(name.lower())
assets.append(name)
return assets
[docs]
@dataclass
class DetectionSettings:
"""User-configurable options for the asset detection stage."""
raster_path: Path
output_dir: Path
assets: list[str]
asset_size_m: float = 50.0
detect_in_recon_imagery: bool = False
basemap: str = 'bing'
threshold: float = 0.5
mask_threshold: float = 0.4
overlap_ratio: float = 0.25
batch_size: int = 4
crop_buffer: str = '20 m'
regularize: bool = True
merge_overlaps: bool = True
bing_max_workers: int = 10
def __post_init__(self) -> None:
"""
Coerce paths and validate the asset list, size and basemap.
``detect_in_recon_imagery=True`` is the legacy spelling of
``basemap='recon'``; the two are kept in sync.
"""
self.raster_path = Path(self.raster_path)
self.output_dir = Path(self.output_dir)
if not self.assets:
raise ValueError('At least one asset name is required.')
if self.asset_size_m <= 0:
raise ValueError('Approximate asset size must be positive.')
self.basemap = str(self.basemap or 'bing').strip().lower()
if self.detect_in_recon_imagery:
self.basemap = 'recon'
if self.basemap not in DETECTION_BASEMAPS:
raise ValueError(
f'basemap must be one of {DETECTION_BASEMAPS}, got {self.basemap!r}.'
)
self.detect_in_recon_imagery = self.basemap == 'recon'
class _OutlineKwargs(TypedDict):
"""Outline keyword arguments shared by the image extractors."""
overlay_asset_outline: bool
outline_shape: str
outline_buffer: float | str
outline_width: int | float | str
outline_color: str
[docs]
@dataclass
class InferenceSettings:
"""User-configurable options for the VLM inference stage."""
raster_path: Path
output_dir: Path
prompt: str
backend: InferenceBackend = 'gemini'
api_key: str = ''
model_id: str = ''
max_workers: int = 5
batch_size: int = 4
temperature: float = 0.4
max_tokens: int = 2048
json_mode: bool = False
load_in_4bit: bool = True
overlay_asset_outline: bool = True
outline_shape: str = 'geometry'
outline_buffer: float | str = 0
outline_width: int | float | str = 6
outline_color: str = 'red'
image_prefix: str = 'aerial'
only_analyze_inference_crops: bool = True
# Where the analysed crops come from (an IMAGERY_SOURCES key) and the
# options of the street-level extractors:
imagery: str = 'aerial'
min_footprint_coverage: float | None = None
pad_edges: bool | None = None
street_search_radius_m: float = 50.0
street_max_images: int = 1
street_vertical_crop: tuple[float, float] = (0.0, 1.0)
mapillary_token: str = ''
mapillary_start_date: str = ''
mapillary_end_date: str = ''
mapillary_rapid_only: bool = True
mapillary_max_images: int = 4
object_image_size: str = '2048'
object_crop_buffer: float | str = '25%'
def __post_init__(self) -> None:
"""
Coerce paths, resolve the default model and validate the backend.
Raises:
ValueError: If the prompt is empty, the backend or imagery source
is unknown, an API backend was selected without a key, a
Mapillary source has no token, or the outline options are
invalid.
"""
self.raster_path = Path(self.raster_path)
self.output_dir = Path(self.output_dir)
if not self.prompt.strip():
raise ValueError('An inference prompt is required.')
self.imagery = str(self.imagery or 'aerial').strip().lower()
if self.imagery not in IMAGERY_SOURCES:
raise ValueError(
f'imagery must be one of {tuple(IMAGERY_SOURCES)}, '
f'got {self.imagery!r}.'
)
if (
IMAGERY_SOURCES[self.imagery]['needs_token']
and not str(self.mapillary_token).strip()
):
raise ValueError(
f'A Mapillary access token is required for '
f'{IMAGERY_SOURCES[self.imagery]["label"]}.'
)
if self.min_footprint_coverage is not None and not (
0.0 <= float(self.min_footprint_coverage) <= 1.0
):
raise ValueError('min_footprint_coverage must be between 0 and 1.')
if self.street_search_radius_m <= 0:
raise ValueError('The Street View search radius must be positive.')
if self.street_max_images < 1 or self.mapillary_max_images < 1:
raise ValueError('At least one image per asset is required.')
top, bottom = (float(v) for v in self.street_vertical_crop)
if not (0.0 <= top < bottom <= 1.0):
raise ValueError(
'street_vertical_crop must be (top, bottom) fractions with '
'0 <= top < bottom <= 1.'
)
self.street_vertical_crop = (top, bottom)
# Validate the outline options with the extractor's own parsers so a
# bad value is rejected by the API instead of failing mid-run:
from rapidtools.processing.image_extractors import (
OUTLINE_SHAPES,
AerialImageryExtractor,
)
if self.outline_shape not in OUTLINE_SHAPES:
raise ValueError(
f'outline_shape must be one of {OUTLINE_SHAPES}, '
f'got {self.outline_shape!r}.'
)
AerialImageryExtractor._parse_outline_buffer(self.outline_buffer)
AerialImageryExtractor._parse_outline_width(self.outline_width)
if not str(self.outline_color).strip():
raise ValueError('An outline colour is required.')
from PIL import ImageColor
ImageColor.getrgb(str(self.outline_color))
spec = MODEL_BACKENDS.get(self.backend)
if spec is None:
raise ValueError(f'Unknown inference backend: {self.backend!r}')
if not self.model_id.strip():
self.model_id = spec['default_model']
if spec['kind'] == 'api' and not self.api_key.strip():
raise ValueError(
f'{spec["key_label"]} is required for the {spec["label"]} backend.'
)
@property
def backend_spec(self) -> dict[str, Any]:
"""The :data:`MODEL_BACKENDS` record describing the selected backend."""
return MODEL_BACKENDS[self.backend]
[docs]
@dataclass
class RegionImagerySettings:
"""
Options for :meth:`AssetAnalysisWorkflow.download_basemap`.
Either the four WGS84 bounds or ``geojson_path`` (whose extent is used)
must be given.
Example:
>>> RegionImagerySettings(
... output_dir='out', provider='google', zoom=18,
... min_lon=-118.15, min_lat=34.18, max_lon=-118.14, max_lat=34.19,
... ).provider
'google'
"""
output_dir: Path
provider: str = 'bing'
zoom: int = 19
min_lon: float | None = None
min_lat: float | None = None
max_lon: float | None = None
max_lat: float | None = None
geojson_path: Path | None = None
max_tiles: int = MAX_BASEMAP_TILES
max_workers: int = 10
def __post_init__(self) -> None:
"""Validate the provider, zoom and region definition."""
self.output_dir = Path(self.output_dir)
self.provider = str(self.provider or 'bing').strip().lower()
if self.provider not in BASEMAP_PROVIDERS:
raise ValueError(
f'provider must be one of {tuple(BASEMAP_PROVIDERS)}, '
f'got {self.provider!r}.'
)
self.zoom = int(self.zoom)
if not 10 <= self.zoom <= BASEMAP_PROVIDERS[self.provider]['max_zoom']:
raise ValueError(
f'zoom must be between 10 and '
f'{BASEMAP_PROVIDERS[self.provider]["max_zoom"]} for '
f'{BASEMAP_PROVIDERS[self.provider]["label"]}.'
)
if self.geojson_path is not None:
self.geojson_path = Path(self.geojson_path)
return
if (
self.min_lon is None
or self.min_lat is None
or self.max_lon is None
or self.max_lat is None
):
raise ValueError(
'Give the region as min/max longitude and latitude, or as a '
'GeoJSON file.'
)
self.min_lon, self.min_lat, self.max_lon, self.max_lat = (
float(self.min_lon),
float(self.min_lat),
float(self.max_lon),
float(self.max_lat),
)
if not (
-180 <= self.min_lon < self.max_lon <= 180
and -85 <= self.min_lat < self.max_lat <= 85
):
raise ValueError(
'The bounding box must satisfy min < max with longitudes in '
'[-180, 180] and latitudes in [-85, 85].'
)
[docs]
@dataclass
class StreetDetectionSettings:
"""
Options for :meth:`AssetAnalysisWorkflow.discover_street`.
Objects of ``classes`` are discovered in every Mapillary image of the
region, which defaults to the extent of ``raster_path`` so the GUI can
draw the results on the loaded imagery.
Example:
>>> StreetDetectionSettings(
... output_dir='out', classes=['vehicles'], access_token='MLY|...',
... raster_path='scene.tiff',
... ).frame_spacing_m
3.0
"""
output_dir: Path
classes: list[str]
access_token: str
raster_path: Path | None = None
region: Any = None
start_date: str = ''
end_date: str = ''
filter_rapid_only: bool = True
detection_source: str = 'auto'
frame_spacing_m: float = 3.0
min_observations: int = 2
cluster_radius_m: float = 4.0
camera_height_m: float = 2.4
max_range_m: float = 60.0
max_workers: int = 10
def __post_init__(self) -> None:
"""Validate the classes, token, region source and numeric options."""
self.output_dir = Path(self.output_dir)
self.classes = parse_asset_list(
self.classes if isinstance(self.classes, str) else ', '.join(self.classes)
)
if not self.classes:
raise ValueError('Enter at least one object class (e.g. "vehicles").')
self.access_token = str(self.access_token or '').strip()
if not self.access_token:
raise ValueError('A Mapillary access token is required.')
if self.raster_path is not None:
self.raster_path = Path(self.raster_path)
if self.raster_path is None and self.region is None:
raise ValueError(
'Load imagery (or give a region) to define the survey area.'
)
if self.detection_source not in ('auto', 'mapillary', 'sam3'):
raise ValueError("detection_source must be 'auto', 'mapillary' or 'sam3'.")
if self.frame_spacing_m < 0 or self.cluster_radius_m <= 0:
raise ValueError('Frame spacing must be >= 0 and cluster radius > 0.')
if self.min_observations < 1:
raise ValueError('min_observations must be at least 1.')
if self.camera_height_m <= 0 or self.max_range_m <= 0:
raise ValueError('Camera height and maximum range must be positive.')
[docs]
@dataclass
class AssistSettings:
"""
Options for :meth:`AssetAnalysisWorkflow.assist`.
``action`` is one of :data:`~rapidtools.gui.prompt_builder.ASSIST_ACTIONS`;
the remaining text fields feed the matching
:class:`~rapidtools.gui.prompt_builder.PromptAssistant` method.
Example:
>>> AssistSettings(action='draft', backend='gemma4', brief='grade roofs').action
'draft'
"""
action: str
backend: InferenceBackend = 'gemma4'
api_key: str = ''
model_id: str = ''
load_in_4bit: bool = True
brief: str = ''
instruction: str = ''
prompt_text: str = ''
class_value: str = ''
spec: dict[str, Any] | None = None
context: dict[str, Any] = field(default_factory=dict)
temperature: float = 0.2
max_tokens: int = 4096
def __post_init__(self) -> None:
"""Validate the action and backend, and require a key for API backends."""
self.action = str(self.action or '').strip().lower()
if self.action not in ASSIST_ACTIONS:
raise ValueError(
f'action must be one of {ASSIST_ACTIONS}, got {self.action!r}.'
)
spec = MODEL_BACKENDS.get(self.backend)
if spec is None:
raise ValueError(f'Unknown assistant backend: {self.backend!r}')
if not self.model_id.strip():
self.model_id = spec['default_model']
if spec['kind'] == 'api' and not self.api_key.strip():
raise ValueError(
f'{spec["key_label"]} is required to use {spec["label"]} as the '
'prompt assistant.'
)
if self.spec is not None and not isinstance(self.spec, dict):
raise ValueError('spec must be a JSON object.')
@property
def backend_spec(self) -> dict[str, Any]:
"""The :data:`MODEL_BACKENDS` record describing the selected backend."""
return MODEL_BACKENDS[self.backend]
@dataclass
class DetectionResult:
"""Outputs of :meth:`AssetAnalysisWorkflow.detect`."""
collection: PhysicalAssetCollection
detection_raster: Path
per_asset_geojson: dict[str, Path] = field(default_factory=dict)
combined_geojson: Path | None = None
basemap: str | None = None
@dataclass
class StreetDetectionResult:
"""Outputs of :meth:`AssetAnalysisWorkflow.discover_street`."""
collection: PhysicalAssetCollection
geojson_path: Path | None = None
n_images: int = 0
@dataclass
class InferenceResult:
"""Outputs of :meth:`AssetAnalysisWorkflow.analyze`."""
collection: PhysicalAssetCollection
geojson_path: Path
n_input: int
n_analyzed: int
[docs]
class AssetAnalysisWorkflow:
"""
Orchestrates asset detection and VLM inference on an aerial raster.
Args:
progress_callback (Callable[[str], None] | None):
Optional function receiving short human-readable status strings
(e.g. ``'Scanning for tree...'``). The GUI uses this to update its
status bar; scripts may pass ``print`` or leave it ``None``.
Example:
>>> wf = AssetAnalysisWorkflow()
>>> detection = wf.detect(DetectionSettings(
... raster_path='eaton_patch_20250214.tiff',
... output_dir='output',
... assets=['building', 'tree'],
... ))
>>> inference = wf.analyze(detection.collection, InferenceSettings(
... raster_path='eaton_patch_20250214.tiff',
... output_dir='output',
... prompt='Describe the damage to the outlined asset.',
... api_key='...',
... ))
"""
def __init__(self, progress_callback: Callable[[str], None] | None = None) -> None:
"""Create an idle workflow with a fresh cancellation event."""
self._progress_callback = progress_callback
self._cancel_event = threading.Event()
# (cache key, model) of the prompt assistant, kept between requests so
# a local model is loaded once per conversation.
self._assistant: tuple[tuple[Any, ...], Any] | None = None
# ------------------------------------------------------------------ utils
[docs]
def cancel(self) -> None:
"""Request cancellation. Honoured at the next stage boundary."""
self._cancel_event.set()
[docs]
def reset(self) -> None:
"""Clear any pending cancellation request."""
self._cancel_event.clear()
def _check_cancelled(self) -> None:
if self._cancel_event.is_set():
raise WorkflowCancelled('Workflow cancelled by user.')
def _progress(self, message: str) -> None:
logger.info(message)
if self._progress_callback is not None:
self._progress_callback(message)
# -------------------------------------------------------------- detection
[docs]
def detect(self, settings: DetectionSettings) -> DetectionResult:
"""
Detect one or more asset types in the raster with SAM 3.
Mirrors Part 1 of the generalized example notebook. When
``settings.detect_in_recon_imagery`` is ``False`` a Bing basemap is
stitched over the raster extent and used for detection instead (useful
when assets are destroyed in the post-disaster imagery).
Returns:
DetectionResult: The merged collection of every requested asset
type plus the paths of the GeoJSON files written to disk.
"""
self.reset()
raster_path = settings.raster_path.expanduser().resolve()
if not raster_path.is_file():
raise FileNotFoundError(f'Raster not found: {raster_path}')
output_dir = settings.output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
try:
return self._detect(settings, raster_path, output_dir)
except WorkflowCancelled:
raise
except OperationCancelled as exc:
self._progress('Detection cancelled.')
raise WorkflowCancelled(str(exc)) from exc
def _detect(
self, settings: DetectionSettings, raster_path: Path, output_dir: Path
) -> DetectionResult:
# Heavy imports are deferred so the GUI starts quickly.
from rapidtools import (
AerialImageryExtractor,
BingOrthomosaicExtractor,
BoundingBox,
GoogleOrthomosaicExtractor,
SAM3OrthoFeatureExtractor,
)
# 1. Decide which raster to scan.
self._release_local_assistant()
if settings.basemap == 'recon':
detection_raster = raster_path
else:
label = BASEMAP_PROVIDERS[settings.basemap]['label']
detection_raster = (
output_dir / f'{raster_path.stem}_{settings.basemap}.tiff'
)
if detection_raster.is_file():
self._progress(
f'Reusing existing {label} basemap: {detection_raster.name}'
)
else:
self._progress(f'Stitching {label} basemap over the raster extent...')
region = BoundingBox.from_raster(raster_path)
extractor_cls = (
BingOrthomosaicExtractor
if settings.basemap == 'bing'
else GoogleOrthomosaicExtractor
)
extractor = extractor_cls(
max_workers=settings.bing_max_workers,
cancel_event=self._cancel_event,
)
detection_raster = Path(
extractor(region=region, output_path=detection_raster)
)
self._check_cancelled()
# 2. Load SAM 3 once and reuse it for every asset prompt.
self._progress('Loading SAM 3 (first run downloads ~3.4 GB of weights)...')
sam_extractor = SAM3OrthoFeatureExtractor(
prompt=settings.assets[0],
patch_size=settings.asset_size_m,
unit='meters',
overlap_ratio=settings.overlap_ratio,
batch_size=settings.batch_size,
threshold=settings.threshold,
mask_threshold=settings.mask_threshold,
merge_overlaps=settings.merge_overlaps,
cancel_event=self._cancel_event,
)
combined = PhysicalAssetCollection()
per_asset_geojson: dict[str, Path] = {}
for asset_name in settings.assets:
self._check_cancelled()
slug = slugify(asset_name)
self._progress(f"Scanning for '{asset_name}'...")
sam_extractor.prompt = asset_name
prelim = sam_extractor(detection_raster)
prelim.to_geojson(output_dir / f'{slug}_preliminary.geojson')
if len(prelim) == 0:
logger.warning(f"No '{asset_name}' assets were detected.")
continue
# Crop imagery around each detection for regularization context.
self._progress(
f"Cropping imagery around {len(prelim)} '{asset_name}' detections..."
)
crop_extractor = AerialImageryExtractor(
dataset=detection_raster,
save_directory=output_dir / 'asset_crops' / slug,
buffer_asset=settings.crop_buffer,
force_square_image=True,
image_prefix=f'{slug}_crop',
cancel_event=self._cancel_event,
)
with_images = crop_extractor(prelim)
final = with_images
if settings.regularize:
final = self._regularize(asset_name, with_images)
final_path = output_dir / f'{slug}_final.geojson'
final.to_geojson(final_path, ignore_properties=['image_assets'])
per_asset_geojson[asset_name] = final_path
self._progress(f"Finished '{asset_name}': {len(final)} assets.")
combined.merge(final, strategy='skip')
combined_path: Path | None = None
if len(combined):
combined_path = output_dir / 'assets_final.geojson'
combined.to_geojson(combined_path, ignore_properties=['image_assets'])
self._progress(
f'Detection complete: {len(combined)} assets written to '
f'{combined_path.name}'
)
else:
self._progress('Detection complete: no assets were found.')
return DetectionResult(
collection=combined,
detection_raster=detection_raster,
per_asset_geojson=per_asset_geojson,
combined_geojson=combined_path,
basemap=None if settings.basemap == 'recon' else settings.basemap,
)
def _regularize(
self, asset_name: str, collection: PhysicalAssetCollection
) -> PhysicalAssetCollection:
"""Apply the building or road regularizer when the asset name matches."""
from rapidtools import BuildingRegularizer, RoadwayRegularizer
lowered = asset_name.lower()
if 'building' in lowered or 'house' in lowered or 'structure' in lowered:
self._progress(f"Regularizing '{asset_name}' footprints...")
regularizer = BuildingRegularizer(
batch_size=8, cancel_event=self._cancel_event
)
return regularizer(collection)
if 'road' in lowered or 'street' in lowered:
self._progress(f"Regularizing '{asset_name}' polygons...")
_centerlines, polygons = RoadwayRegularizer()(collection)
return polygons
return collection
# -------------------------------------------------------------- inference
[docs]
def analyze(
self,
collection: PhysicalAssetCollection,
settings: InferenceSettings,
) -> InferenceResult:
"""
Crop the raster around every asset and run VLM inference on the crops.
Mirrors Part 2 of the generalized example notebook.
Returns:
InferenceResult: The collection of assets that received at least
one inferred attribute, and the GeoJSON path it was written to.
"""
from rapidtools import Pipeline
self.reset()
self._release_local_assistant()
if len(collection) == 0:
raise ValueError('The asset collection is empty; nothing to analyze.')
raster_path = settings.raster_path.expanduser().resolve()
if settings.imagery == 'aerial' and not raster_path.is_file():
raise FileNotFoundError(f'Raster not found: {raster_path}')
output_dir = settings.output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
image_dir = (output_dir / 'inference_imagery').resolve()
extractor = self._build_extractor(settings, raster_path, image_dir)
# Detection may have attached basemap crops to the assets; restrict the
# VLM to the crops produced for inference unless the user opts out.
def _is_inference_crop(image) -> bool:
try:
return Path(image.path).resolve().is_relative_to(image_dir)
except (TypeError, ValueError):
return False
image_filter = (
_is_inference_crop if settings.only_analyze_inference_crops else None
)
analyzer = self._build_analyzer(settings, image_filter, self._cancel_event)
pipeline = Pipeline(cancel_event=self._cancel_event)
pipeline.add_step(extractor)
pipeline.add_step(analyzer)
# Detected assets already carry attributes (asset_type, source_model...),
# so count an asset as analyzed only if inference added new ones.
attributes_before = {a.id: set(a.attributes) for a in collection}
self._progress(f'Running inference on {len(collection)} assets...')
try:
processed = pipeline.run(collection)
except OperationCancelled as exc:
# Keep whatever was analyzed before the user pressed cancel.
partial = collection.filter_empty()
partial_path = output_dir / 'assets_inferred_partial.geojson'
partial.to_geojson(partial_path, ignore_properties=['image_assets'])
self._progress(
f'Inference cancelled. Partial results for {len(partial)} assets '
f'written to {partial_path.name}'
)
raise WorkflowCancelled(str(exc)) from exc
self._check_cancelled()
analyzed = processed.filter_empty()
n_analyzed = sum(
1
for a in analyzed
if set(a.attributes) - attributes_before.get(a.id, set())
)
geojson_path = output_dir / 'assets_inferred.geojson'
analyzed.to_geojson(geojson_path, ignore_properties=['image_assets'])
self._progress(
f'Inference complete: {n_analyzed}/{len(collection)} assets '
f'received new attributes. Results written to {geojson_path.name}'
)
return InferenceResult(
collection=analyzed,
geojson_path=geojson_path,
n_input=len(collection),
n_analyzed=n_analyzed,
)
def _build_extractor(
self, settings: InferenceSettings, raster_path: Path, image_dir: Path
):
"""
Instantiate the imagery extractor for ``settings.imagery``.
Every extractor writes below ``image_dir`` so the inference-crop
filter of :meth:`analyze` applies to all of them.
"""
from rapidtools import (
AerialImageryExtractor,
GoogleStreetViewImageExtractor,
MapillaryImageExtractor,
MapillaryObjectImageExtractor,
)
outline: _OutlineKwargs = {
'overlay_asset_outline': settings.overlay_asset_outline,
'outline_shape': settings.outline_shape,
'outline_buffer': settings.outline_buffer,
'outline_width': settings.outline_width,
'outline_color': settings.outline_color,
}
if settings.imagery == 'aerial':
return AerialImageryExtractor(
dataset=raster_path,
save_directory=image_dir,
image_prefix=settings.image_prefix,
keep_multiple_copies=True,
min_footprint_coverage=settings.min_footprint_coverage,
pad_edges=settings.pad_edges,
cancel_event=self._cancel_event,
**outline,
)
if settings.imagery == 'google_streetview':
self._progress('Fetching Google Street View panoramas...')
return GoogleStreetViewImageExtractor(
save_directory=image_dir / 'streetview',
search_radius_m=settings.street_search_radius_m,
max_images_per_asset=settings.street_max_images,
vertical_crop=settings.street_vertical_crop,
image_prefix='gsv',
max_workers=settings.max_workers,
cancel_event=self._cancel_event,
)
if settings.imagery == 'mapillary':
self._progress('Fetching Mapillary panoramas...')
return MapillaryImageExtractor(
access_token=settings.mapillary_token.strip(),
save_directory=image_dir / 'mapillary',
start_date=settings.mapillary_start_date,
end_date=settings.mapillary_end_date,
filter_rapid_only=settings.mapillary_rapid_only,
smart_crop=True,
image_prefix='street',
max_images_per_asset=settings.mapillary_max_images,
max_workers=settings.max_workers,
cancel_event=self._cancel_event,
)
self._progress('Cropping Mapillary views of each object...')
return MapillaryObjectImageExtractor(
save_directory=image_dir / 'street_objects',
access_token=settings.mapillary_token.strip(),
max_images_per_asset=settings.mapillary_max_images,
image_size=settings.object_image_size,
crop_buffer=settings.object_crop_buffer,
image_prefix='street',
max_workers=settings.max_workers,
cancel_event=self._cancel_event,
**outline,
)
@staticmethod
def _model_kwargs(
backend: str,
model_id: str,
api_key: str,
load_in_4bit: bool,
temperature: float,
max_tokens: int,
max_workers: int,
) -> dict[str, Any]:
"""Keyword arguments for :func:`rapidtools.models.load` of ``backend``."""
info = PROVIDERS[backend]
kwargs: dict[str, Any] = {
'model_id': model_id or MODEL_BACKENDS[backend]['default_model'],
'temperature': temperature,
'max_tokens': max_tokens,
}
if info.is_api:
kwargs['api_key'] = api_key.strip()
kwargs['max_workers'] = max_workers
elif info.supports_4bit:
kwargs['load_in_4bit'] = load_in_4bit
return kwargs
@classmethod
def _build_analyzer(
cls,
settings: InferenceSettings,
image_filter,
cancel_event: threading.Event | None = None,
):
"""
Instantiate the model for the selected backend and wrap it.
Uses :func:`rapidtools.models.load` and
:class:`~rapidtools.processing.AssetAnalyzer`, so every registered
provider is supported without backend-specific code.
"""
from rapidtools.models import GenerationConfig, load
from rapidtools.processing import AssetAnalyzer
info = PROVIDERS[settings.backend]
model = load(
settings.backend,
**cls._model_kwargs(
settings.backend,
settings.model_id,
settings.api_key,
settings.load_in_4bit,
settings.temperature,
settings.max_tokens,
settings.max_workers,
),
)
return AssetAnalyzer(
model,
prompt=settings.prompt,
max_workers=settings.max_workers if info.is_api else 1,
batch_size=settings.batch_size if info.supports_batch else 1,
image_filter=image_filter,
cancel_event=cancel_event,
generation=GenerationConfig(json_mode=settings.json_mode),
)
# ------------------------------------------------------------- basemap
[docs]
def download_basemap(self, settings: RegionImagerySettings) -> Path:
"""
Stitch a Bing or Google satellite basemap over a region into a GeoTIFF.
The region comes from the four WGS84 bounds or the extent of a
GeoJSON file. The tile count is checked against
``settings.max_tiles`` before any download starts.
Args:
settings (RegionImagerySettings): Provider, zoom and region.
Returns:
Path: The written GeoTIFF, named after the provider, zoom and
bounds inside ``settings.output_dir``.
Raises:
ValueError: If the region covers more than ``max_tiles`` tiles.
FileNotFoundError: If ``geojson_path`` does not exist.
"""
from rapidtools import (
BingOrthomosaicExtractor,
BoundingBox,
GoogleOrthomosaicExtractor,
)
self.reset()
output_dir = settings.output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
if settings.geojson_path is not None:
geojson = settings.geojson_path.expanduser().resolve()
if not geojson.is_file():
raise FileNotFoundError(f'GeoJSON not found: {geojson}')
region = BoundingBox.from_geojson(geojson)
bounds = region.bounds
else:
if (
settings.min_lon is None
or settings.min_lat is None
or settings.max_lon is None
or settings.max_lat is None
):
raise ValueError('The region bounds are incomplete.')
bounds = (
settings.min_lon,
settings.min_lat,
settings.max_lon,
settings.max_lat,
)
region = BoundingBox(*bounds)
n_tiles = estimate_tile_count(bounds, settings.zoom)
if n_tiles > settings.max_tiles:
raise ValueError(
f'The region needs about {n_tiles:,} tiles at zoom {settings.zoom} '
f'(limit {settings.max_tiles:,}). Lower the zoom level or shrink '
'the region.'
)
label = BASEMAP_PROVIDERS[settings.provider]['label']
stem = f'{settings.provider}_z{settings.zoom}_' + '_'.join(
f'{v:.5f}'.replace('-', 'm').replace('.', 'p') for v in bounds
)
output_path = output_dir / f'{stem}.tiff'
if output_path.is_file():
self._progress(f'Reusing existing {label} imagery: {output_path.name}')
return output_path
self._progress(
f'Downloading about {n_tiles:,} {label} tiles at zoom {settings.zoom}...'
)
extractor_cls = (
BingOrthomosaicExtractor
if settings.provider == 'bing'
else GoogleOrthomosaicExtractor
)
extractor = extractor_cls(
zoom_level=settings.zoom,
max_workers=settings.max_workers,
cancel_event=self._cancel_event,
)
try:
path = Path(extractor(region=region, output_path=output_path))
except WorkflowCancelled:
raise
except OperationCancelled as exc:
raise WorkflowCancelled(str(exc)) from exc
self._check_cancelled()
self._progress(f'{label} imagery written to {path.name}')
return path
# -------------------------------------------------------- street survey
[docs]
def discover_street(
self, settings: StreetDetectionSettings
) -> StreetDetectionResult:
"""
Discover objects along a Mapillary street survey.
Wraps :class:`~rapidtools.processing.MapillaryFeatureExtractor`: the
requested classes are located in every image of the region from
Mapillary's segmentation metadata, the survey vehicle is removed,
sightings are triangulated and clustered into one point asset per
object. No pixels are downloaded; crops are made at inference time
with the ``'mapillary_objects'`` imagery source.
Args:
settings (StreetDetectionSettings): Classes, token, region and
localization options.
Returns:
StreetDetectionResult: The point assets and the GeoJSON they were
written to (``None`` when nothing was found).
Raises:
FileNotFoundError: If ``raster_path`` is given but missing.
WorkflowCancelled: If cancelled.
"""
from rapidtools import BoundingBox, MapillaryFeatureExtractor
self.reset()
self._release_local_assistant()
output_dir = settings.output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
region = settings.region
if region is None:
if settings.raster_path is None:
raise ValueError(
'Load imagery (or give a region) to define the survey area.'
)
raster_path = settings.raster_path.expanduser().resolve()
if not raster_path.is_file():
raise FileNotFoundError(f'Raster not found: {raster_path}')
region = BoundingBox.from_raster(raster_path)
classes = ', '.join(settings.classes)
self._progress(f'Searching the Mapillary survey for {classes}...')
extractor = MapillaryFeatureExtractor(
classes=settings.classes,
access_token=settings.access_token,
region=region,
start_date=settings.start_date,
end_date=settings.end_date,
filter_rapid_only=settings.filter_rapid_only,
detection_source=settings.detection_source,
frame_spacing_m=settings.frame_spacing_m,
min_observations=settings.min_observations,
cluster_radius_m=settings.cluster_radius_m,
camera_height_m=settings.camera_height_m,
max_range_m=settings.max_range_m,
save_directory=output_dir / 'street_detections',
max_workers=settings.max_workers,
cancel_event=self._cancel_event,
)
try:
collection = extractor()
except WorkflowCancelled:
raise
except OperationCancelled as exc:
self._progress('Street discovery cancelled.')
raise WorkflowCancelled(str(exc)) from exc
self._check_cancelled()
geojson_path: Path | None = None
n_images = sum(len(a.image_assets) for a in collection)
if len(collection):
geojson_path = output_dir / 'street_objects.geojson'
collection.to_geojson(geojson_path, ignore_properties=['image_assets'])
self._progress(
f'Street discovery complete: {len(collection)} objects written to '
f'{geojson_path.name}'
)
else:
self._progress('Street discovery complete: no objects were found.')
return StreetDetectionResult(
collection=collection, geojson_path=geojson_path, n_images=n_images
)
# ------------------------------------------------------------ assistant
[docs]
def assist(self, settings: AssistSettings) -> dict[str, Any]:
"""
Run one prompt-assistant action with a rapidtools model backend.
The model is loaded through :func:`rapidtools.models.load` and cached
until a different backend or model is requested, or until detection or
inference needs the GPU (local models are then released).
Args:
settings (AssistSettings): The action and its inputs.
Returns:
dict[str, Any]: The
:meth:`~rapidtools.gui.prompt_builder.PromptAssistant.run` result:
``spec``, ``text`` or ``indicators``.
Raises:
ValueError: If the model reply cannot be used.
"""
self.reset()
label = settings.backend_spec['label']
self._progress(f'Asking {label} ({settings.model_id})...')
model = self._assistant_model(settings)
assistant = PromptAssistant(
model, temperature=settings.temperature, max_tokens=settings.max_tokens
)
result = assistant.run(
settings.action,
brief=settings.brief,
instruction=settings.instruction,
prompt_text=settings.prompt_text,
class_value=settings.class_value,
spec=settings.spec,
context=settings.context,
)
self._check_cancelled()
self._progress(f'{label} answered.')
return result
def _assistant_model(self, settings: AssistSettings):
"""Return the cached assistant model, loading it when the key changed."""
from rapidtools.models import load
key = (
settings.backend,
settings.model_id,
settings.api_key.strip(),
settings.load_in_4bit,
)
if self._assistant is not None and self._assistant[0] == key:
return self._assistant[1]
self.release_assistant()
if settings.backend_spec['kind'] != 'api':
self._progress(
f'Loading {settings.model_id} for the prompt assistant (first use '
'downloads the weights)...'
)
model = load(
settings.backend,
**self._model_kwargs(
settings.backend,
settings.model_id,
settings.api_key,
settings.load_in_4bit,
settings.temperature,
settings.max_tokens,
1,
),
)
self._assistant = (key, model)
return model
[docs]
def release_assistant(self) -> None:
"""Drop the cached assistant model and free GPU memory if it was local."""
if self._assistant is None:
return
backend = self._assistant[0][0]
self._assistant = None
gc.collect()
torch = sys.modules.get('torch')
if MODEL_BACKENDS.get(backend, {}).get('kind') == 'local' and torch is not None:
try:
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception: # noqa: BLE001 - best effort
pass
def _release_local_assistant(self) -> None:
"""Free a cached *local* assistant model before GPU-heavy work."""
if (
self._assistant is not None
and MODEL_BACKENDS.get(self._assistant[0][0], {}).get('kind') == 'local'
):
self.release_assistant()
def estimate_tile_count(bounds: tuple[float, float, float, float], zoom: int) -> int:
"""
Number of Web Mercator tiles covering ``bounds`` at ``zoom``.
Args:
bounds (tuple[float, float, float, float]): ``(min_lon, min_lat,
max_lon, max_lat)`` in WGS84.
zoom (int): Tile zoom level.
Returns:
int: The tile count (at least 1).
Example:
>>> estimate_tile_count((-118.15, 34.18, -118.14, 34.19), 19)
288
"""
min_lon, min_lat, max_lon, max_lat = bounds
n = 2**zoom
def tile_x(lon: float) -> int:
return int((lon + 180.0) / 360.0 * n)
def tile_y(lat: float) -> int:
lat_rad = math.radians(max(min(lat, 85.05), -85.05))
return int(
(1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi)
/ 2
* n
)
width = abs(tile_x(max_lon) - tile_x(min_lon)) + 1
height = abs(tile_y(min_lat) - tile_y(max_lat)) + 1
return max(1, width * height)