# 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
"""
Geometry for placing street-level detections on the ground.
A detection in a street-level image is a polygon in normalised image
coordinates. Knowing where the camera stood and looked, the polygon's centre
column gives the bearing to the object and its lowest row gives the elevation
of the ground contact, from which the camera height yields a range. One view
gives a rough point; several views of the same object from different camera
positions give a much better one by intersecting the bearings. This module
provides those steps plus the two filters every street survey needs: dropping
the collection vehicle itself (it sits at the same place in every frame of a
sequence) and thinning frames that are only a metre or two apart.
Example:
>>> from rapidtools.core import Observation
>>> from rapidtools.processing.street_localization import localize
>>> obs = Observation(
... image_id='1', label='object--vehicle--car',
... polygon=[(0.70, 0.55), (0.76, 0.55), (0.76, 0.62), (0.70, 0.62)],
... camera_lon=-117.41, camera_lat=47.66, compass_angle=0.0,
... )
>>> localize(obs, camera_height_m=2.4)
True
>>> round(obs.bearing), round(obs.range_m, 1)
(83, 6.1)
"""
from __future__ import annotations
import math
from collections import defaultdict
from collections.abc import Callable, Iterable, Sequence
import numpy as np
from rapidtools.core import Observation
EARTH_RADIUS_M = 6_371_000.0
# A detection whose lowest edge is less than this far below the horizon has
# no usable ground contact (the range would be hundreds of metres or worse):
MIN_DEPRESSION_DEG = 1.0
# Bearings closer than this cannot be intersected reliably:
MIN_TRIANGULATION_SEPARATION_DEG = 8.0
# Outlines smaller than this share of the image are decoding noise (a few
# hundred pixels on a survey panorama). Whether an object is big enough is
# judged in metres once its distance is known, not in pixels.
MIN_POLYGON_AREA_FRACTION = 5e-6
# Farthest triangulated position worth reporting by default.
DEFAULT_MAX_RANGE_M = 60.0
# --------------------------------------------------------------- basics
def haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
"""
Great-circle distance between two WGS84 points in metres.
Example:
>>> round(haversine_m(0.0, 0.0, 0.0, 0.001))
111
"""
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi, dlam = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
a = (
math.sin(dphi / 2) ** 2
+ math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
)
return 2 * EARTH_RADIUS_M * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def destination_point(
lon: float, lat: float, bearing_deg: float, distance_m: float
) -> tuple[float, float]:
"""
Point reached from ``(lon, lat)`` travelling ``distance_m`` along a bearing.
Example:
>>> lon, lat = destination_point(0.0, 0.0, 0.0, 111_195)
>>> round(lon, 6), round(lat, 3)
(0.0, 1.0)
"""
delta = distance_m / EARTH_RADIUS_M
theta = math.radians(bearing_deg)
phi1, lam1 = math.radians(lat), math.radians(lon)
phi2 = math.asin(
math.sin(phi1) * math.cos(delta)
+ math.cos(phi1) * math.sin(delta) * math.cos(theta)
)
lam2 = lam1 + math.atan2(
math.sin(theta) * math.sin(delta) * math.cos(phi1),
math.cos(delta) - math.sin(phi1) * math.sin(phi2),
)
return math.degrees(lam2), math.degrees(phi2)
def local_projection(
lon0: float, lat0: float
) -> tuple[
Callable[[float, float], tuple[float, float]],
Callable[[float, float], tuple[float, float]],
]:
"""
Return ``(project, unproject)`` for a local east/north metre frame.
Uses the equirectangular approximation centred on ``(lon0, lat0)``, which
is accurate to well under a metre over the few kilometres a survey covers.
Example:
>>> project, unproject = local_projection(-117.4, 47.66)
>>> x, y = project(-117.4, 47.661)
>>> round(y), round(x)
(111, 0)
>>> [round(float(v), 4) for v in unproject(x, y)]
[-117.4, 47.661]
"""
cos_lat = math.cos(math.radians(lat0))
def project(lon: float, lat: float) -> tuple[float, float]:
dlon = (lon - lon0 + 180.0) % 360.0 - 180.0
return (
math.radians(dlon) * cos_lat * EARTH_RADIUS_M,
math.radians(lat - lat0) * EARTH_RADIUS_M,
)
def unproject(x: float, y: float) -> tuple[float, float]:
return (
lon0 + math.degrees(x / (EARTH_RADIUS_M * cos_lat)),
lat0 + math.degrees(y / EARTH_RADIUS_M),
)
return project, unproject
def bbox_iou(
a: tuple[float, float, float, float], b: tuple[float, float, float, float]
) -> float:
"""
Intersection over union of two ``(min_x, min_y, max_x, max_y)`` boxes.
Example:
>>> bbox_iou((0, 0, 1, 1), (0.5, 0, 1.5, 1))
0.3333333333333333
"""
ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
inter = ix * iy
if inter <= 0:
return 0.0
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / union if union > 0 else 0.0
# --------------------------------------------------------------- single view
def view_angles(
polygon: Sequence[tuple[float, float]],
compass_angle: float,
is_pano: bool = True,
focal_norm: float | None = None,
aspect: float = 2.0,
) -> tuple[float, float]:
"""
Bearing to a detection and elevation of its ground contact.
Args:
polygon: Detection outline in normalised image coordinates (``y``
down).
compass_angle: Heading of the image centre column, degrees clockwise
from north.
is_pano: Equirectangular panorama (360 x 180 degrees). For panoramas
the horizontal centre is a circular mean so objects straddling
the left/right seam are handled.
focal_norm: Focal length divided by image width, for perspective
images. Defaults to ``0.85`` when unknown.
aspect: ``width / height`` of a perspective image.
Returns:
tuple[float, float]: ``(bearing_deg, elevation_deg)``. The bearing is
absolute (0-360); the elevation is negative below the horizon.
Example:
>>> bearing, elevation = view_angles(
... [(0.7, 0.5), (0.8, 0.5), (0.8, 0.6), (0.7, 0.6)], compass_angle=10.0
... )
>>> round(bearing), round(elevation)
(100, -18)
"""
xs = [p[0] for p in polygon]
y_bottom = max(p[1] for p in polygon)
if is_pano:
angles = [2 * math.pi * x for x in xs]
cx = (
math.atan2(sum(map(math.sin, angles)), sum(map(math.cos, angles)))
/ (2 * math.pi)
) % 1.0
rel_bearing = (cx - 0.5) * 360.0
elevation = (0.5 - y_bottom) * 180.0
else:
f = focal_norm if focal_norm else 0.85
cx = sum(xs) / len(xs)
# OpenSfM convention: image coordinates are normalised by the larger
# image side, which is also what the focal length is relative to.
if aspect >= 1.0:
x_n = cx - 0.5
y_n = (y_bottom - 0.5) / aspect
else:
x_n = (cx - 0.5) * aspect
y_n = y_bottom - 0.5
# The contact point lies on the ray (x_n, y_n, f): its bearing is the
# azimuth of that ray and its depression is measured against the full
# horizontal component, not just the focal length (an off-axis pixel
# at the same row is less steep than one in the centre column).
rel_bearing = math.degrees(math.atan2(x_n, f))
elevation = -math.degrees(math.atan2(y_n, math.hypot(f, x_n)))
return (compass_angle + rel_bearing) % 360.0, elevation
def ground_range(elevation_deg: float, camera_height_m: float) -> float | None:
"""
Horizontal distance to a ground contact seen ``elevation_deg`` below level.
Returns ``None`` when the contact is at or above the horizon (within
:data:`MIN_DEPRESSION_DEG`), where no range can be inferred.
Example:
>>> round(ground_range(-45.0, 2.4), 2)
2.4
>>> ground_range(0.5, 2.4) is None
True
"""
if elevation_deg > -MIN_DEPRESSION_DEG:
return None
return camera_height_m / math.tan(math.radians(-elevation_deg))
[docs]
def localize(
obs: Observation,
camera_height_m: float = 2.4,
min_range_m: float = 2.0,
max_range_m: float = DEFAULT_MAX_RANGE_M,
min_area_fraction: float = MIN_POLYGON_AREA_FRACTION,
) -> bool:
"""
Fill in the single-view ground position of an observation.
Sets ``bearing``, ``elevation``, ``range_m``, ``lon`` and ``lat`` on
``obs`` and returns ``True`` when the geometry is plausible. Detections
that are too small, have no ground contact, or fall outside the range
window are left without a position and ``False`` is returned.
The bearing and elevation come from the full camera pose when the
observation carries Mapillary's ``computed_rotation`` in ``extra`` (see
:func:`observation_angles`); otherwise the camera is assumed level.
Args:
obs: The observation to update in place.
camera_height_m: Height of the camera above the ground.
min_range_m: Closest plausible object (the ego vehicle and the road
surface sit inside this radius).
max_range_m: Farthest single-view range worth keeping as a prior.
min_area_fraction: Outlines smaller than this share of the image are
treated as noise. The default only rejects decoding garbage;
whether an object is large enough is decided in metres by
:func:`~rapidtools.processing.street_tracking.classify_track`.
"""
if obs.area < min_area_fraction:
return False
bearing, elevation = observation_angles(obs)
obs.bearing, obs.elevation = bearing, elevation
rng = ground_range(elevation, camera_height_m)
if rng is None or not (min_range_m <= rng <= max_range_m):
return False
obs.range_m = rng
obs.lon, obs.lat = destination_point(obs.camera_lon, obs.camera_lat, bearing, rng)
return True
# --------------------------------------------------------------- camera model
[docs]
def rotation_matrix(rotation_vector: Sequence[float] | np.ndarray) -> np.ndarray:
"""
Rotation matrix of an axis-angle vector (Rodrigues' formula).
Mapillary's ``computed_rotation`` is such a vector in OpenSfM's
convention: it rotates world coordinates (east, north, up) into camera
coordinates (x right, y down, z forward), so ``R.T`` maps camera rays
back into the world.
Example:
>>> import numpy as np
>>> R = rotation_matrix([0.0, 0.0, 0.0])
>>> bool(np.allclose(R, np.eye(3)))
True
>>> R = rotation_matrix([0.0, 0.0, np.pi / 2]) # quarter turn about z
>>> [round(float(v), 6) for v in R @ np.array([1.0, 0.0, 0.0])]
[0.0, 1.0, 0.0]
"""
rv = np.asarray(rotation_vector, dtype=float)
theta = float(np.linalg.norm(rv))
if theta < 1e-12:
return np.eye(3)
kx, ky, kz = rv / theta
k = np.array([[0.0, -kz, ky], [kz, 0.0, -kx], [-ky, kx, 0.0]])
return np.eye(3) + math.sin(theta) * k + (1.0 - math.cos(theta)) * (k @ k)
[docs]
def pixel_bearing(
x: float,
y: float,
camera_type: str = 'spherical',
width: int | None = None,
height: int | None = None,
camera_parameters: Sequence[float] | None = None,
) -> np.ndarray:
"""
Unit direction in the camera frame of a normalised image point.
Follows OpenSfM's camera models, which is what Mapillary's
``camera_type`` and ``camera_parameters`` describe. The camera frame has
``x`` to the right, ``y`` down and ``z`` forward.
Args:
x: Column as a fraction of the image width (``0`` left, ``1`` right).
y: Row as a fraction of the image height (``0`` top, ``1`` bottom).
camera_type: ``'spherical'`` / ``'equirectangular'`` for panoramas,
``'perspective'`` / ``'brown'`` for rectilinear images, or
``'fisheye'``. Unknown types are treated as perspective.
width: Image width in pixels. Needed with ``height`` for the aspect
ratio; a 2:1 panorama or a 4:3 frame is assumed when unknown.
height: Image height in pixels.
camera_parameters: ``[focal, k1, k2]`` with the focal length
normalised by the larger image side (OpenSfM convention). Ignored
for spherical cameras; ``[0.85, 0, 0]`` is assumed when missing.
Returns:
numpy.ndarray: Unit vector ``(x, y, z)`` in the camera frame.
Example:
>>> [round(float(v), 3) for v in pixel_bearing(0.5, 0.5)] # pano centre
[0.0, 0.0, 1.0]
>>> [round(float(v), 3) for v in pixel_bearing(0.75, 0.5)] # 90 deg right
[1.0, 0.0, 0.0]
>>> [round(float(v), 3) for v in pixel_bearing(0.5, 1.0)] # straight down
[0.0, 1.0, 0.0]
>>> b = pixel_bearing(0.5, 0.5, 'perspective', 4000, 3000, [0.9, 0, 0])
>>> [round(float(v), 3) for v in b]
[0.0, 0.0, 1.0]
"""
kind = (camera_type or 'spherical').lower()
is_pano = kind in ('spherical', 'equirectangular')
if width and height:
w, h = float(width), float(height)
else:
w, h = (2.0, 1.0) if is_pano else (4.0, 3.0)
scale = max(w, h)
x_n = (x - 0.5) * w / scale
y_n = (y - 0.5) * h / scale
if is_pano:
lon = 2.0 * math.pi * x_n
lat = -2.0 * math.pi * y_n
return np.array(
[
math.cos(lat) * math.sin(lon),
-math.sin(lat),
math.cos(lat) * math.cos(lon),
]
)
params = list(camera_parameters) if camera_parameters else []
focal = float(params[0]) if params and params[0] else 0.85
k1 = float(params[1]) if len(params) > 1 else 0.0
k2 = float(params[2]) if len(params) > 2 else 0.0
if kind in ('fisheye', 'fisheye_opencv', 'fisheye62'):
# Equidistant model: r_d / f = theta * (1 + k1 theta^2 + k2 theta^4).
r_d = math.hypot(x_n, y_n) / focal
theta = r_d
for _ in range(10):
theta = r_d / (1.0 + k1 * theta**2 + k2 * theta**4)
if r_d < 1e-12:
return np.array([0.0, 0.0, 1.0])
s = math.sin(theta) / math.hypot(x_n, y_n)
return np.array([x_n * s, y_n * s, math.cos(theta)])
# Perspective with Brown radial distortion: iterate the inverse mapping.
xd, yd = x_n / focal, y_n / focal
xu, yu = xd, yd
if k1 or k2:
for _ in range(10):
r2 = xu * xu + yu * yu
d = 1.0 + k1 * r2 + k2 * r2 * r2
xu, yu = xd / d, yd / d
v = np.array([xu, yu, 1.0])
return v / np.linalg.norm(v)
[docs]
def world_ray(
camera_bearing: Sequence[float] | np.ndarray,
rotation_vector: Sequence[float] | np.ndarray,
) -> np.ndarray:
"""
Rotate a camera-frame direction into the world (east, north, up) frame.
Example:
>>> ray = world_ray([0.0, 0.0, 1.0], [0.0, 0.0, 0.0])
>>> [round(float(v), 3) for v in ray]
[0.0, 0.0, 1.0]
"""
r = rotation_matrix(rotation_vector)
ray = r.T @ np.asarray(camera_bearing, dtype=float)
return ray / np.linalg.norm(ray)
def ray_angles(ray: Sequence[float] | np.ndarray) -> tuple[float, float]:
"""
Compass bearing and elevation of a world-frame direction.
Args:
ray: ``(east, north, up)`` direction, not necessarily unit length.
Returns:
tuple[float, float]: ``(bearing_deg, elevation_deg)``; the bearing is
clockwise from north in ``[0, 360)``, the elevation is negative
below the horizon.
Example:
>>> ray_angles([1.0, 0.0, 0.0])
(90.0, 0.0)
>>> bearing, elevation = ray_angles([0.0, 1.0, -1.0])
>>> round(bearing), round(elevation)
(0, -45)
"""
e, n, u = (float(v) for v in ray)
horizontal = math.hypot(e, n)
bearing = math.degrees(math.atan2(e, n)) % 360.0
elevation = math.degrees(math.atan2(u, horizontal))
return bearing, elevation
def ground_range_from_ray(
ray: Sequence[float] | np.ndarray, camera_height_m: float
) -> float | None:
"""
Horizontal distance at which a world-frame ray meets flat ground.
Returns ``None`` for rays at or above the horizon (within
:data:`MIN_DEPRESSION_DEG`).
Example:
>>> round(ground_range_from_ray([0.0, 1.0, -1.0], 2.4), 2)
2.4
>>> ground_range_from_ray([0.0, 1.0, 0.0], 2.4) is None
True
"""
_, elevation = ray_angles(ray)
if elevation > -MIN_DEPRESSION_DEG:
return None
return camera_height_m / math.tan(math.radians(-elevation))
def polygon_anchor(
polygon: Sequence[tuple[float, float]], is_pano: bool
) -> tuple[float, float, float]:
"""
Centre column, vertical centre and bottom row of a detection outline.
For panoramas the centre column is a circular mean so an outline that
straddles the left/right seam is handled.
Example:
>>> cx, cy, bottom = polygon_anchor(
... [(0.1, 0.4), (0.3, 0.4), (0.3, 0.6), (0.1, 0.6)], False
... )
>>> round(cx, 3), round(cy, 3), round(bottom, 3)
(0.2, 0.5, 0.6)
"""
xs = [p[0] for p in polygon]
ys = [p[1] for p in polygon]
if is_pano:
angles = [2 * math.pi * x for x in xs]
cx = (
math.atan2(sum(map(math.sin, angles)), sum(map(math.cos, angles)))
/ (2 * math.pi)
) % 1.0
else:
cx = sum(xs) / len(xs)
return cx, (min(ys) + max(ys)) / 2.0, max(ys)
[docs]
def observation_angles(
obs: Observation,
) -> tuple[float, float]:
"""
Bearing to an observation and elevation of its ground contact.
Uses the full camera pose (``obs.extra['computed_rotation']``, the camera
type and lens parameters) when the frame carries one, which accounts for
the camera's pitch and roll; otherwise falls back to :func:`view_angles`,
which assumes a level camera.
Returns:
tuple[float, float]: ``(bearing_deg, elevation_deg)``.
"""
rotation = obs.extra.get('computed_rotation')
cx, cy, y_bottom = polygon_anchor(obs.polygon, obs.is_pano)
if rotation is None:
focal_norm = None
params = obs.extra.get('camera_parameters')
if params:
focal_norm = float(params[0])
aspect = 2.0
if obs.image_width and obs.image_height:
aspect = obs.image_width / obs.image_height
return view_angles(
obs.polygon, obs.compass_angle, obs.is_pano, focal_norm, aspect
)
camera_type = obs.extra.get('camera_type') or (
'spherical' if obs.is_pano else 'perspective'
)
params = obs.extra.get('camera_parameters')
centre = world_ray(
pixel_bearing(cx, cy, camera_type, obs.image_width, obs.image_height, params),
rotation,
)
contact = world_ray(
pixel_bearing(
cx, y_bottom, camera_type, obs.image_width, obs.image_height, params
),
rotation,
)
bearing, _ = ray_angles(centre)
_, elevation = ray_angles(contact)
return bearing, elevation
def polygon_angular_size(obs: Observation) -> tuple[float, float]:
"""
Angular width and height of a detection outline as seen from the camera.
Uses the same camera model as :func:`observation_angles`, so the result
is correct for panoramas (including outlines across the seam) and for
perspective and fisheye frames. Together with a distance this gives the
object's physical size: ``2 * distance * tan(width / 2)``.
Returns:
tuple[float, float]: ``(width_deg, height_deg)``.
Example:
>>> from rapidtools.core import Observation
>>> obs = Observation('i', 'car', [(0.45, 0.5), (0.55, 0.5), (0.55, 0.55)],
... 0.0, 0.0, 0.0, is_pano=True)
>>> width, height = polygon_angular_size(obs)
>>> round(width), round(height)
(36, 9)
"""
xs = [p[0] for p in obs.polygon]
ys = [p[1] for p in obs.polygon]
if obs.is_pano and (max(xs) - min(xs)) > 0.5:
# The outline straddles the seam: unwrap it before taking the span.
xs = [x + 1.0 if x < 0.5 else x for x in xs]
x0, x1 = min(xs), max(xs)
cx = (x0 + x1) / 2.0
if obs.is_pano:
x0, x1, cx = x0 % 1.0, x1 % 1.0, cx % 1.0
y0, y1 = min(ys), max(ys)
cy = (y0 + y1) / 2.0
camera_type = obs.extra.get('camera_type') or (
'spherical' if obs.is_pano else 'perspective'
)
params = obs.extra.get('camera_parameters')
w, h = obs.image_width, obs.image_height
def azimuth(b: np.ndarray) -> float:
return math.degrees(math.atan2(float(b[0]), float(b[2])))
def elevation(b: np.ndarray) -> float:
return math.degrees(
math.atan2(-float(b[1]), math.hypot(float(b[0]), float(b[2])))
)
# Width is the azimuth span (what a ground footprint subtends), height the
# elevation span between the top and bottom edges on the centre column.
left = pixel_bearing(x0, cy, camera_type, w, h, params)
right = pixel_bearing(x1, cy, camera_type, w, h, params)
top = pixel_bearing(cx, y0, camera_type, w, h, params)
bottom = pixel_bearing(cx, y1, camera_type, w, h, params)
width = (azimuth(right) - azimuth(left)) % 360.0
return min(width, 360.0 - width), abs(elevation(top) - elevation(bottom))
# --------------------------------------------------------------- multi view
[docs]
def intersect_bearings(
rays: Sequence[tuple[float, float, float]],
project: Callable[[float, float], tuple[float, float]],
unproject: Callable[[float, float], tuple[float, float]],
max_range_m: float = 60.0,
min_separation_deg: float = MIN_TRIANGULATION_SEPARATION_DEG,
) -> tuple[float, float, float] | None:
"""
Least-squares intersection of bearings from several camera positions.
Args:
rays: ``(camera_lon, camera_lat, bearing_deg)`` per observation.
project: Local metre projection from :func:`local_projection`.
unproject: Its inverse.
max_range_m: Reject solutions farther than this from any camera.
min_separation_deg: Require at least one pair of bearings this far
apart; near-parallel rays give an ill-conditioned intersection.
Returns:
tuple[float, float, float] | None: ``(lon, lat, rms_residual_m)`` or
``None`` when the rays cannot be intersected (fewer than two distinct
cameras, near-parallel bearings, or a solution behind a camera).
Example:
>>> project, unproject = local_projection(0.0, 0.0)
>>> # Two cameras 20 m apart both looking at a point 10 m north of the
>>> # midpoint between them:
>>> lon_a, lat_a = unproject(-10.0, 0.0)
>>> lon_b, lat_b = unproject(10.0, 0.0)
>>> lon, lat, rms = intersect_bearings(
... [(lon_a, lat_a, 45.0), (lon_b, lat_b, 315.0)], project, unproject
... )
>>> [round(v) for v in project(lon, lat)], round(rms, 3)
([0, 10], 0.0)
"""
cams = [project(lon, lat) for lon, lat, _ in rays]
bearings = [math.radians(b) for _, _, b in rays]
# Need at least two cameras that are not (nearly) the same position:
distinct = {(round(x, 1), round(y, 1)) for x, y in cams}
if len(distinct) < 2:
return None
degs = [b for _, _, b in rays]
spread = max(
min(abs(a - b) % 360.0, 360.0 - abs(a - b) % 360.0) for a in degs for b in degs
)
if spread < min_separation_deg:
return None
# Each ray: point p_i, unit direction d_i = (sin b, cos b) in east/north.
# Minimise sum of squared perpendicular distances: solve (sum (I - d d^T)) x
# = sum (I - d d^T) p.
a_sum = np.zeros((2, 2))
b_sum = np.zeros(2)
for (x, y), b in zip(cams, bearings, strict=True):
d = np.array([math.sin(b), math.cos(b)])
m = np.eye(2) - np.outer(d, d)
a_sum += m
b_sum += m @ np.array([x, y])
try:
point = np.linalg.solve(a_sum, b_sum)
except np.linalg.LinAlgError:
return None
residuals = []
for (x, y), b in zip(cams, bearings, strict=True):
d = np.array([math.sin(b), math.cos(b)])
rel = point - np.array([x, y])
along = float(rel @ d)
if along <= 0 or along > max_range_m:
return None # behind the camera or implausibly far
residuals.append(float(np.hypot(*(rel - along * d))))
lon, lat = unproject(float(point[0]), float(point[1]))
rms = math.sqrt(sum(r * r for r in residuals) / len(residuals))
return lon, lat, rms
# --------------------------------------------------------------- filters
[docs]
def estimate_ego_mask(
observations: Iterable[Observation],
min_recurrence: float = 0.5,
iou_threshold: float = 0.5,
min_frames: int = 3,
) -> set[int]:
"""
Find detections of the collection vehicle itself.
A parked car drifts across the frame as the camera drives past; the
vehicle carrying the camera does not. Within each sequence, a detection
whose box recurs (IoU above ``iou_threshold``) in at least
``min_recurrence`` of the frames is flagged as ego. Sequences with fewer
than ``min_frames`` frames are left alone, since recurrence cannot be
judged.
Args:
observations: Observations of one label, in any order.
min_recurrence: Fraction of a sequence's frames the box must recur in.
iou_threshold: Box overlap that counts as "the same place".
min_frames: Minimum frames in a sequence before flagging anything.
Returns:
set[int]: Indices into ``observations`` (in iteration order) that
belong to the ego vehicle.
Example:
>>> from rapidtools.core import Observation
>>> ego = [(0.40, 0.80), (0.60, 0.80), (0.60, 1.00), (0.40, 1.00)]
>>> frames = [
... Observation(str(i), 'car', ego, 0.0, 0.0, 0.0, sequence_id='s')
... for i in range(4)
... ]
>>> box = [(0.1, 0.5), (0.2, 0.5), (0.2, 0.6), (0.1, 0.6)]
>>> parked = Observation('9', 'car', box, 0.0, 0.0, 0.0, sequence_id='s')
>>> sorted(estimate_ego_mask(frames + [parked]))
[0, 1, 2, 3]
"""
obs_list = list(observations)
by_sequence: dict[str, list[int]] = defaultdict(list)
for i, obs in enumerate(obs_list):
by_sequence[obs.sequence_id or '__none__'].append(i)
ego: set[int] = set()
for indices in by_sequence.values():
frames = {obs_list[i].image_id for i in indices}
if len(frames) < min_frames:
continue
# The survey vehicle sits at the same place in every frame, so its
# boxes are near-identical. Group boxes that round to the same
# position first and compare groups, not sightings: a sequence of
# thousands of frames then costs one comparison per distinct box
# instead of one per pair, which made city-wide runs quadratic.
quantum = 0.01
groups: dict[tuple[int, int, int, int], dict] = {}
for i in indices:
box = obs_list[i].bbox
key = (
int(round(box[0] / quantum)),
int(round(box[1] / quantum)),
int(round(box[2] / quantum)),
int(round(box[3] / quantum)),
)
group = groups.get(key)
if group is None:
group = groups[key] = {
'box': list(box),
'n': 0,
'frames': set(),
'members': [],
}
n = group['n']
group['box'] = [(group['box'][k] * n + box[k]) / (n + 1) for k in range(4)]
group['n'] = n + 1
group['frames'].add(obs_list[i].image_id)
group['members'].append(i)
# Bucket the group boxes by their centre so each is compared with few others:
cell = 0.05
buckets: dict[tuple[int, int], list[tuple[int, int, int, int]]] = defaultdict(
list
)
for key, group in groups.items():
b = group['box']
buckets[
(int((b[0] + b[2]) / 2 / cell), int((b[1] + b[3]) / 2 / cell))
].append(key)
for group in groups.values():
b = group['box']
cx, cy = int((b[0] + b[2]) / 2 / cell), int((b[1] + b[3]) / 2 / cell)
matching_frames: set[str] = set()
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for other in buckets.get((cx + dx, cy + dy), ()):
if bbox_iou(b, groups[other]['box']) >= iou_threshold:
matching_frames |= groups[other]['frames']
if len(matching_frames) / len(frames) >= min_recurrence:
ego.update(group['members'])
return ego
def simplify_polygon(
polygon: Sequence[tuple[float, float]], tolerance: float
) -> list[tuple[float, float]]:
"""
Reduce a detection outline to the vertices that matter at ``tolerance``.
Mapillary segmentation outlines trace every pixel step of a mask, so a
single car can carry hundreds of vertices. Only the bounding box, the
bottom edge and the horizontal centre feed the localisation, and a crop
outline needs no sub-pixel detail, so the ring is simplified with
Douglas-Peucker before it is stored. The bounding box of the result is
within ``tolerance`` of the original's.
Args:
polygon: Exterior ring in normalised image coordinates.
tolerance: Largest allowed deviation, in normalised units (``0.002``
is about four pixels of a 2048-wide image). ``0`` disables the
simplification.
Returns:
list[tuple[float, float]]: The simplified ring (at least three
vertices), or the input unchanged when simplification fails.
Example:
>>> ring = [(0.1, 0.1), (0.2, 0.1001), (0.3, 0.1), (0.3, 0.3), (0.1, 0.3)]
>>> simplify_polygon(ring, 0.002)
[(0.1, 0.1), (0.3, 0.1), (0.3, 0.3), (0.1, 0.3)]
"""
points = [(float(x), float(y)) for x, y in polygon]
if tolerance <= 0 or len(points) <= 4:
return points
try:
from shapely.geometry import Polygon
simplified = Polygon(points).simplify(tolerance, preserve_topology=True)
if simplified.is_empty or simplified.geom_type != 'Polygon':
return points
ring = [(float(x), float(y)) for x, y in simplified.exterior.coords[:-1]]
except Exception: # noqa: BLE001 - provider outlines can be degenerate
return points
return ring if len(ring) >= 3 else points
[docs]
def thin_frames(
frames: Sequence[tuple[str, float, float, str | None, str | None]],
spacing_m: float,
) -> set[str]:
"""
Keep one frame per ``spacing_m`` metres along each sequence.
Args:
frames: ``(image_id, lon, lat, captured_at, sequence_id)`` tuples.
Frames are ordered by ``captured_at`` within a sequence.
spacing_m: Minimum distance between kept frames; ``0`` keeps all.
Returns:
set[str]: The image IDs to keep.
Example:
>>> frames = [('a', 0.0, 0.0, '1', 's'), ('b', 0.00003, 0.0, '2', 's'),
... ('c', 0.0002, 0.0, '3', 's')]
>>> sorted(thin_frames(frames, spacing_m=10))
['a', 'c']
"""
if spacing_m <= 0:
return {f[0] for f in frames}
by_sequence: dict[str, list] = defaultdict(list)
for frame in frames:
by_sequence[frame[4] or frame[0]].append(frame)
keep: set[str] = set()
for seq in by_sequence.values():
seq.sort(key=lambda f: (f[3] or '', f[0]))
last = None
for image_id, lon, lat, _, _ in seq:
if last is None or haversine_m(last[0], last[1], lon, lat) >= spacing_m:
keep.add(image_id)
last = (lon, lat)
return keep
def _ground_position(obs: Observation) -> tuple[float, float]:
"""Return ``(lon, lat)`` of an observation that :func:`localize` accepted."""
if obs.lon is None or obs.lat is None:
raise ValueError(f'Observation {obs.image_id!r} has no ground position.')
return obs.lon, obs.lat
def cluster_observations(
observations: Sequence[Observation], radius_m: float
) -> list[list[Observation]]:
"""
Group located observations that are within ``radius_m`` of each other.
Greedy single-linkage clustering seeded from the closest views first:
each observation joins the first existing cluster whose running centre
lies within the radius, otherwise it starts a new one.
Args:
observations: Observations with ``lon``/``lat`` set (others are
skipped).
radius_m: Two views closer than this are the same object.
Returns:
list[list[Observation]]: One list of observations per object.
Example:
>>> from rapidtools.core import Observation
>>> def obs(i, lon, rng):
... o = Observation(str(i), 'car', [(0, 0), (1, 0), (1, 1)], 0.0, 0.0, 0.0)
... o.lon, o.lat, o.range_m = lon, 0.0, rng
... return o
>>> views = [obs(1, 0.0, 5), obs(2, 0.00001, 6), obs(3, 0.001, 5)]
>>> groups = cluster_observations(views, 4.0)
>>> [len(g) for g in groups]
[2, 1]
"""
located = [o for o in observations if o.has_location]
if not located:
return []
project, _ = local_projection(*_ground_position(located[0]))
grid: dict[tuple[int, int], list[dict]] = defaultdict(list)
clusters: list[dict] = []
for obs in sorted(
located, key=lambda o: o.range_m if o.range_m is not None else 1e9
):
x, y = project(*_ground_position(obs))
gx, gy = int(math.floor(x / radius_m)), int(math.floor(y / radius_m))
target = None
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for cluster in grid.get((gx + dx, gy + dy), ()):
if math.hypot(cluster['x'] - x, cluster['y'] - y) <= radius_m:
target = cluster
break
if target:
break
if target:
break
if target is None:
target = {'x': x, 'y': y, 'members': []}
clusters.append(target)
grid[(gx, gy)].append(target)
target['members'].append(obs)
n = len(target['members'])
target['x'] += (x - target['x']) / n
target['y'] += (y - target['y']) / n
return [c['members'] for c in clusters]