Source code for rapidtools.core.observation

# 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:
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-29-2026

"""
Observation records linking a physical asset to the street-level image it was
detected in.

Street-level discovery (for example
:class:`~rapidtools.processing.MapillaryFeatureExtractor`) sees the same object
from several camera positions. Each sighting is an :class:`Observation`: which
image, where the camera stood and looked, the object's outline in normalised
image coordinates, and the ground position estimated from that single view.
The observations of one asset are stored as plain dictionaries under
``asset.attributes['observations']`` so they survive GeoJSON round trips, and
the imagery extractors read them back to decide which images to download and
where to crop.

Example:
    >>> from rapidtools.core import Observation
    >>> obs = Observation(
    ...     image_id='123', label='object--vehicle--car',
    ...     polygon=[(0.40, 0.55), (0.46, 0.55), (0.46, 0.62), (0.40, 0.62)],
    ...     camera_lon=-117.41, camera_lat=47.66, compass_angle=90.0,
    ... )
    >>> obs.bbox
    (0.4, 0.55, 0.46, 0.62)
    >>> Observation.from_dict(obs.to_dict()) == obs
    True
"""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from typing import Any


[docs] @dataclass class Observation: """ One sighting of an object in a street-level image. Attributes: image_id (str): Provider image identifier (Mapillary image key). label (str): Class label of the detection, in the provider's vocabulary for Mapillary detections (``'object--vehicle--car'``) or the text prompt for model-based detections. polygon (list[tuple[float, float]]): Exterior ring of the detection in normalised image coordinates: ``x`` grows to the right and ``y`` downwards, both in ``[0, 1]``. camera_lon (float): Longitude of the camera in WGS84 degrees. camera_lat (float): Latitude of the camera in WGS84 degrees. compass_angle (float): Heading of the image centre column in degrees clockwise from north. is_pano (bool): ``True`` for equirectangular panoramas (360 degrees across the width, 180 down the height). sequence_id (str | None): Capture sequence the image belongs to; observations of one sequence share a camera rig. captured_at (str | None): Capture date or timestamp as an ISO string. confidence (float | None): Detector confidence in ``[0, 1]`` when the provider reports one. source (str): Where the detection came from: ``'mapillary'`` or ``'sam3'``. bearing (float | None): Absolute bearing from the camera to the object, degrees clockwise from north. Filled in by the localisation step. elevation (float | None): Elevation angle of the object's ground contact (its lowest pixel) in degrees; negative below the horizon. range_m (float | None): Estimated horizontal distance from camera to object in metres. lon (float | None): Single-view estimate of the object's longitude. lat (float | None): Single-view estimate of the object's latitude. image_width (int | None): Pixel width of the source image, when known. image_height (int | None): Pixel height of the source image, when known. extra (dict[str, Any]): Free-form provider metadata. """ image_id: str label: str polygon: list[tuple[float, float]] camera_lon: float camera_lat: float compass_angle: float is_pano: bool = True sequence_id: str | None = None captured_at: str | None = None confidence: float | None = None source: str = 'mapillary' bearing: float | None = None elevation: float | None = None range_m: float | None = None lon: float | None = None lat: float | None = None image_width: int | None = None image_height: int | None = None extra: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: """Normalise the polygon to a list of float tuples.""" self.polygon = [(float(x), float(y)) for x, y in self.polygon] if len(self.polygon) < 3: raise ValueError('An observation polygon needs at least three vertices.') @property def bbox(self) -> tuple[float, float, float, float]: """ Axis-aligned bounds of the polygon as ``(min_x, min_y, max_x, max_y)``. Example: >>> Observation('i', 'car', [(0.1, 0.2), (0.3, 0.2), (0.3, 0.4)], ... 0.0, 0.0, 0.0).bbox (0.1, 0.2, 0.3, 0.4) """ xs = [p[0] for p in self.polygon] ys = [p[1] for p in self.polygon] return (min(xs), min(ys), max(xs), max(ys)) @property def area(self) -> float: """ Polygon area as a fraction of the image (shoelace formula). Example: >>> Observation('i', 'car', [(0, 0), (0.5, 0), (0.5, 0.5), (0, 0.5)], ... 0.0, 0.0, 0.0).area 0.25 """ pts = self.polygon total = 0.0 for (x1, y1), (x2, y2) in zip(pts, pts[1:] + pts[:1], strict=True): total += x1 * y2 - x2 * y1 return abs(total) / 2.0 @property def has_location(self) -> bool: """``True`` once a single-view ground position has been estimated.""" return self.lon is not None and self.lat is not None
[docs] def to_dict(self) -> dict[str, Any]: """ Return a JSON-serialisable dictionary of the observation. Example: >>> obs = Observation('i', 'car', [(0, 0), (1, 0), (1, 1)], 1.0, 2.0, 3.0) >>> d = obs.to_dict() >>> d['image_id'], d['polygon'][0] ('i', [0.0, 0.0]) """ data = asdict(self) data['polygon'] = [[x, y] for x, y in self.polygon] return data
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> Observation: """ Rebuild an observation from :meth:`to_dict` output. Unknown keys are kept under ``extra`` so records written by a newer version still load. Example: >>> Observation.from_dict({ ... 'image_id': 'i', 'label': 'car', ... 'polygon': [[0, 0], [1, 0], [1, 1]], ... 'camera_lon': 1.0, 'camera_lat': 2.0, 'compass_angle': 3.0, ... }).label 'car' """ known = {f for f in cls.__dataclass_fields__} kwargs = {k: v for k, v in data.items() if k in known} extra = dict(kwargs.pop('extra', {}) or {}) extra.update({k: v for k, v in data.items() if k not in known}) kwargs['polygon'] = [tuple(p) for p in kwargs['polygon']] return cls(extra=extra, **kwargs)