dory-processor-sdk 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dory/__init__.py +101 -0
- dory/auth/__init__.py +10 -0
- dory/auth/oauth2.py +153 -0
- dory/auto_instrument.py +142 -0
- dory/cli/__init__.py +5 -0
- dory/cli/main.py +137 -0
- dory/cli/templates.py +123 -0
- dory/config/__init__.py +23 -0
- dory/config/defaults.py +24 -0
- dory/config/loader.py +430 -0
- dory/config/presets.py +73 -0
- dory/config/schema.py +84 -0
- dory/core/__init__.py +27 -0
- dory/core/app.py +434 -0
- dory/core/context.py +209 -0
- dory/core/lifecycle.py +214 -0
- dory/core/meta.py +121 -0
- dory/core/modes.py +479 -0
- dory/core/processor.py +564 -0
- dory/core/signals.py +122 -0
- dory/decorators.py +142 -0
- dory/edge/__init__.py +88 -0
- dory/edge/adaptive.py +644 -0
- dory/edge/detector.py +546 -0
- dory/edge/fencing.py +488 -0
- dory/edge/heartbeat.py +598 -0
- dory/edge/role.py +419 -0
- dory/errors/__init__.py +139 -0
- dory/errors/classification.py +362 -0
- dory/errors/codes.py +498 -0
- dory/geo/__init__.py +40 -0
- dory/geo/geolocalizer.py +1034 -0
- dory/health/__init__.py +12 -0
- dory/health/probes.py +210 -0
- dory/health/server.py +635 -0
- dory/k8s/__init__.py +80 -0
- dory/k8s/annotation_watcher.py +184 -0
- dory/k8s/client.py +251 -0
- dory/k8s/labels.py +505 -0
- dory/k8s/pod_metadata.py +182 -0
- dory/logging/__init__.py +9 -0
- dory/logging/logger.py +148 -0
- dory/metrics/__init__.py +7 -0
- dory/metrics/collector.py +301 -0
- dory/middleware/__init__.py +46 -0
- dory/middleware/connection_tracker.py +608 -0
- dory/middleware/request_id.py +325 -0
- dory/middleware/request_tracker.py +511 -0
- dory/migration/__init__.py +33 -0
- dory/migration/configmap.py +232 -0
- dory/migration/s3_store.py +594 -0
- dory/migration/serialization.py +135 -0
- dory/migration/state_manager.py +286 -0
- dory/migration/transfer.py +382 -0
- dory/monitoring/__init__.py +29 -0
- dory/monitoring/opentelemetry.py +489 -0
- dory/output/__init__.py +31 -0
- dory/output/envelope.py +137 -0
- dory/output/formatter.py +113 -0
- dory/output/rabbitmq.py +632 -0
- dory/output/routing.py +318 -0
- dory/output/validator.py +199 -0
- dory/py.typed +2 -0
- dory/recovery/__init__.py +60 -0
- dory/recovery/golden_image.py +487 -0
- dory/recovery/golden_snapshot.py +713 -0
- dory/recovery/golden_validator.py +518 -0
- dory/recovery/partial_recovery.py +482 -0
- dory/recovery/recovery_decision.py +242 -0
- dory/recovery/restart_detector.py +142 -0
- dory/recovery/state_validator.py +183 -0
- dory/resilience/__init__.py +45 -0
- dory/resilience/circuit_breaker.py +457 -0
- dory/resilience/retry.py +389 -0
- dory/simple.py +342 -0
- dory/types.py +68 -0
- dory/utils/__init__.py +31 -0
- dory/utils/errors.py +59 -0
- dory/utils/retry.py +115 -0
- dory/utils/timeout.py +80 -0
- dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
- dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
- dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
- dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
- dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
- dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
dory/geo/geolocalizer.py
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geolocation estimation from a single fixed camera.
|
|
3
|
+
|
|
4
|
+
Maps detected object positions (pixel coordinates) to geographic
|
|
5
|
+
coordinates (lat/lng) using one of two strategies:
|
|
6
|
+
|
|
7
|
+
**Homography** (``GeoMethod.HOMOGRAPHY``):
|
|
8
|
+
Computes a 3×3 planar perspective transform (DLT) from reference
|
|
9
|
+
correspondences. Best when the ground is truly flat and a full
|
|
10
|
+
perspective mapping is needed.
|
|
11
|
+
|
|
12
|
+
**Trilateration + linear interpolation** (``GeoMethod.TRILATERATION``):
|
|
13
|
+
1. Inside the calibrated region: finds the enclosing triangle of
|
|
14
|
+
reference points and uses barycentric (linear) interpolation.
|
|
15
|
+
2. Outside the calibrated region: estimates pixel-to-metre scale
|
|
16
|
+
from reference pairs and solves linearised trilateration.
|
|
17
|
+
Better when reference points are scattered and the ground may not
|
|
18
|
+
be perfectly flat.
|
|
19
|
+
|
|
20
|
+
Both methods:
|
|
21
|
+
1. Convert reference geo-coordinates to a local Cartesian frame (metres).
|
|
22
|
+
2. Perform the computation in metre-space.
|
|
23
|
+
3. Convert results back to lat/lng.
|
|
24
|
+
|
|
25
|
+
Pure-Python implementation — no numpy/scipy dependency.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from enum import Enum
|
|
33
|
+
from typing import Sequence
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Constants
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Metres per degree of latitude (WGS-84 mean)
|
|
40
|
+
_METRES_PER_DEG_LAT = 111_319.5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Enums
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GeoMethod(Enum):
|
|
49
|
+
"""Strategy for converting pixel coordinates to geographic coordinates.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
HOMOGRAPHY: Planar perspective transform via DLT. Requires ≥ 4
|
|
53
|
+
non-collinear reference points. Best for flat ground planes.
|
|
54
|
+
TRILATERATION: Trilateration (outside) + barycentric linear
|
|
55
|
+
interpolation (inside the reference mesh). Works well with
|
|
56
|
+
scattered reference points on uneven surfaces.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
HOMOGRAPHY = "homography"
|
|
60
|
+
TRILATERATION = "trilateration"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Data classes
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class GeoPoint:
|
|
70
|
+
"""Geographic coordinate."""
|
|
71
|
+
|
|
72
|
+
lat: float
|
|
73
|
+
lng: float
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class ReferencePoint:
|
|
78
|
+
"""A calibration point linking a pixel position to a geographic location.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
image_x: Horizontal pixel coordinate in the camera frame.
|
|
82
|
+
image_y: Vertical pixel coordinate in the camera frame.
|
|
83
|
+
lat: Latitude in decimal degrees.
|
|
84
|
+
lng: Longitude in decimal degrees.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
image_x: float
|
|
88
|
+
image_y: float
|
|
89
|
+
lat: float
|
|
90
|
+
lng: float
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class BoundingBox:
|
|
95
|
+
"""Axis-aligned bounding box from an object detector.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
left: X-coordinate of the left edge (pixels).
|
|
99
|
+
top: Y-coordinate of the top edge (pixels).
|
|
100
|
+
width: Box width (pixels, must be >= 0).
|
|
101
|
+
height: Box height (pixels, must be >= 0).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
left: float
|
|
105
|
+
top: float
|
|
106
|
+
width: float
|
|
107
|
+
height: float
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
if self.width < 0:
|
|
111
|
+
raise ValueError(f"width must be >= 0, got {self.width}")
|
|
112
|
+
if self.height < 0:
|
|
113
|
+
raise ValueError(f"height must be >= 0, got {self.height}")
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def ground_point(self) -> tuple[float, float]:
|
|
117
|
+
"""Bottom-centre of the box — assumed ground-contact point.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
(x, y) pixel coordinates.
|
|
121
|
+
"""
|
|
122
|
+
return (self.left + self.width / 2.0, self.top + self.height)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Coordinate conversion helpers
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _geo_to_local(
|
|
131
|
+
lat: float,
|
|
132
|
+
lng: float,
|
|
133
|
+
origin_lat: float,
|
|
134
|
+
origin_lng: float,
|
|
135
|
+
cos_origin: float,
|
|
136
|
+
) -> tuple[float, float]:
|
|
137
|
+
"""Convert lat/lng to local Cartesian metres relative to *origin*.
|
|
138
|
+
|
|
139
|
+
Uses an equirectangular projection which is accurate for the small
|
|
140
|
+
areas covered by a single camera's field of view (typically < 1 km).
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
lat: Point latitude.
|
|
144
|
+
lng: Point longitude.
|
|
145
|
+
origin_lat: Origin latitude (used only for documentation here).
|
|
146
|
+
origin_lng: Origin longitude.
|
|
147
|
+
cos_origin: ``cos(radians(origin_lat))`` — precomputed.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
(x_metres, y_metres) east/north offsets from origin.
|
|
151
|
+
"""
|
|
152
|
+
x = (lng - origin_lng) * cos_origin * _METRES_PER_DEG_LAT
|
|
153
|
+
y = (lat - origin_lat) * _METRES_PER_DEG_LAT
|
|
154
|
+
return (x, y)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _local_to_geo(
|
|
158
|
+
x: float,
|
|
159
|
+
y: float,
|
|
160
|
+
origin_lat: float,
|
|
161
|
+
origin_lng: float,
|
|
162
|
+
cos_origin: float,
|
|
163
|
+
) -> GeoPoint:
|
|
164
|
+
"""Convert local Cartesian metres back to lat/lng.
|
|
165
|
+
|
|
166
|
+
Inverse of :func:`_geo_to_local`.
|
|
167
|
+
"""
|
|
168
|
+
lat = origin_lat + y / _METRES_PER_DEG_LAT
|
|
169
|
+
lng = origin_lng + x / (cos_origin * _METRES_PER_DEG_LAT)
|
|
170
|
+
return GeoPoint(lat=lat, lng=lng)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Linear-algebra helpers (pure Python, no numpy)
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
# A "matrix" is a list of rows, each row a list of floats.
|
|
178
|
+
_Matrix = list[list[float]]
|
|
179
|
+
_Vector = list[float]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _mat_mul(a: _Matrix, b: _Matrix) -> _Matrix:
|
|
183
|
+
"""Multiply two matrices."""
|
|
184
|
+
rows_a, cols_a = len(a), len(a[0])
|
|
185
|
+
cols_b = len(b[0])
|
|
186
|
+
result: _Matrix = [[0.0] * cols_b for _ in range(rows_a)]
|
|
187
|
+
for i in range(rows_a):
|
|
188
|
+
for k in range(cols_a):
|
|
189
|
+
a_ik = a[i][k]
|
|
190
|
+
if a_ik == 0.0:
|
|
191
|
+
continue
|
|
192
|
+
for j in range(cols_b):
|
|
193
|
+
result[i][j] += a_ik * b[k][j]
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _transpose(m: _Matrix) -> _Matrix:
|
|
198
|
+
"""Transpose a matrix."""
|
|
199
|
+
rows, cols = len(m), len(m[0])
|
|
200
|
+
return [[m[i][j] for i in range(rows)] for j in range(cols)]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _solve_linear(a: _Matrix, b: _Vector) -> _Vector:
|
|
204
|
+
"""Solve Ax = b via Gaussian elimination with partial pivoting.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
a: n×n coefficient matrix (modified in place).
|
|
208
|
+
b: Right-hand side vector of length n (modified in place).
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Solution vector x of length n.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: If the system is singular or near-singular.
|
|
215
|
+
"""
|
|
216
|
+
n = len(b)
|
|
217
|
+
|
|
218
|
+
# Forward elimination with partial pivoting
|
|
219
|
+
for col in range(n):
|
|
220
|
+
# Find pivot row
|
|
221
|
+
max_val = abs(a[col][col])
|
|
222
|
+
max_row = col
|
|
223
|
+
for row in range(col + 1, n):
|
|
224
|
+
val = abs(a[row][col])
|
|
225
|
+
if val > max_val:
|
|
226
|
+
max_val = val
|
|
227
|
+
max_row = row
|
|
228
|
+
|
|
229
|
+
if max_val < 1e-12:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
"Singular or near-singular matrix encountered. "
|
|
232
|
+
"Check that reference points are not collinear."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Swap rows
|
|
236
|
+
if max_row != col:
|
|
237
|
+
a[col], a[max_row] = a[max_row], a[col]
|
|
238
|
+
b[col], b[max_row] = b[max_row], b[col]
|
|
239
|
+
|
|
240
|
+
# Eliminate below
|
|
241
|
+
pivot = a[col][col]
|
|
242
|
+
for row in range(col + 1, n):
|
|
243
|
+
factor = a[row][col] / pivot
|
|
244
|
+
for j in range(col, n):
|
|
245
|
+
a[row][j] -= factor * a[col][j]
|
|
246
|
+
b[row] -= factor * b[col]
|
|
247
|
+
|
|
248
|
+
# Back substitution
|
|
249
|
+
x = [0.0] * n
|
|
250
|
+
for i in range(n - 1, -1, -1):
|
|
251
|
+
s = b[i]
|
|
252
|
+
for j in range(i + 1, n):
|
|
253
|
+
s -= a[i][j] * x[j]
|
|
254
|
+
x[i] = s / a[i][i]
|
|
255
|
+
|
|
256
|
+
return x
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _solve_least_squares(a: _Matrix, b: _Vector) -> _Vector:
|
|
260
|
+
"""Solve overdetermined Ax = b via normal equations (A^T A) x = A^T b.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
a: m×n matrix (m >= n).
|
|
264
|
+
b: Right-hand side vector of length m.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Least-squares solution vector of length n.
|
|
268
|
+
"""
|
|
269
|
+
at = _transpose(a)
|
|
270
|
+
# A^T b
|
|
271
|
+
atb_col = _mat_mul(at, [[v] for v in b])
|
|
272
|
+
atb: _Vector = [atb_col[i][0] for i in range(len(at))]
|
|
273
|
+
# A^T A
|
|
274
|
+
ata = _mat_mul(at, a)
|
|
275
|
+
return _solve_linear(ata, atb)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Hartley normalisation
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _normalise_points(
|
|
284
|
+
pts: list[tuple[float, float]],
|
|
285
|
+
) -> tuple[list[tuple[float, float]], _Matrix]:
|
|
286
|
+
"""Apply Hartley normalisation: translate centroid to origin, scale so
|
|
287
|
+
that the mean distance from origin is sqrt(2).
|
|
288
|
+
|
|
289
|
+
This dramatically improves numerical conditioning of the DLT system.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
pts: List of (x, y) points.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
(normalised_points, 3×3 normalisation matrix T).
|
|
296
|
+
"""
|
|
297
|
+
n = len(pts)
|
|
298
|
+
cx = sum(p[0] for p in pts) / n
|
|
299
|
+
cy = sum(p[1] for p in pts) / n
|
|
300
|
+
|
|
301
|
+
mean_dist = sum(math.hypot(p[0] - cx, p[1] - cy) for p in pts) / n
|
|
302
|
+
|
|
303
|
+
if mean_dist < 1e-12:
|
|
304
|
+
# All points coincide — return identity transform
|
|
305
|
+
t: _Matrix = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
|
306
|
+
return pts, t
|
|
307
|
+
|
|
308
|
+
scale = math.sqrt(2.0) / mean_dist
|
|
309
|
+
|
|
310
|
+
normed = [(scale * (p[0] - cx), scale * (p[1] - cy)) for p in pts]
|
|
311
|
+
|
|
312
|
+
t = [
|
|
313
|
+
[scale, 0.0, -scale * cx],
|
|
314
|
+
[0.0, scale, -scale * cy],
|
|
315
|
+
[0.0, 0.0, 1.0],
|
|
316
|
+
]
|
|
317
|
+
return normed, t
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _invert_3x3(m: _Matrix) -> _Matrix:
|
|
321
|
+
"""Invert a 3×3 matrix analytically."""
|
|
322
|
+
a, b, c = m[0]
|
|
323
|
+
d, e, f = m[1]
|
|
324
|
+
g, h, i = m[2]
|
|
325
|
+
|
|
326
|
+
det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
|
|
327
|
+
if abs(det) < 1e-15:
|
|
328
|
+
raise ValueError("3x3 matrix is singular, cannot invert.")
|
|
329
|
+
|
|
330
|
+
inv_det = 1.0 / det
|
|
331
|
+
return [
|
|
332
|
+
[
|
|
333
|
+
(e * i - f * h) * inv_det,
|
|
334
|
+
(c * h - b * i) * inv_det,
|
|
335
|
+
(b * f - c * e) * inv_det,
|
|
336
|
+
],
|
|
337
|
+
[
|
|
338
|
+
(f * g - d * i) * inv_det,
|
|
339
|
+
(a * i - c * g) * inv_det,
|
|
340
|
+
(c * d - a * f) * inv_det,
|
|
341
|
+
],
|
|
342
|
+
[
|
|
343
|
+
(d * h - e * g) * inv_det,
|
|
344
|
+
(b * g - a * h) * inv_det,
|
|
345
|
+
(a * e - b * d) * inv_det,
|
|
346
|
+
],
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# Homography computation (DLT)
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _compute_homography(
|
|
356
|
+
src: list[tuple[float, float]],
|
|
357
|
+
dst: list[tuple[float, float]],
|
|
358
|
+
) -> _Matrix:
|
|
359
|
+
"""Compute the 3×3 homography mapping *src* → *dst* using normalised DLT.
|
|
360
|
+
|
|
361
|
+
Each correspondence ``(x, y) → (X, Y)`` satisfies::
|
|
362
|
+
|
|
363
|
+
[X'] [h11 h12 h13] [x]
|
|
364
|
+
[Y'] = [h21 h22 h23] [y]
|
|
365
|
+
[W'] [h31 h32 h33] [1]
|
|
366
|
+
|
|
367
|
+
where ``X = X'/W'`` and ``Y = Y'/W'``.
|
|
368
|
+
|
|
369
|
+
We fix ``h33 = 1`` (valid when the origin is not mapped to infinity)
|
|
370
|
+
and solve the resulting 2n × 8 system via least squares (normal
|
|
371
|
+
equations) when n > 4, or direct solve when n = 4.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
src: Image points (at least 4).
|
|
375
|
+
dst: World points (same length as *src*).
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
3×3 homography matrix as nested lists.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
ValueError: If fewer than 4 correspondences or system is singular.
|
|
382
|
+
"""
|
|
383
|
+
n = len(src)
|
|
384
|
+
if n != len(dst):
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"src and dst must have the same length, got {n} and {len(dst)}."
|
|
387
|
+
)
|
|
388
|
+
if n < 4:
|
|
389
|
+
raise ValueError(f"Need >= 4 point correspondences, got {n}.")
|
|
390
|
+
|
|
391
|
+
# Step 1: Hartley normalisation
|
|
392
|
+
src_norm, t_src = _normalise_points(src)
|
|
393
|
+
dst_norm, t_dst = _normalise_points(dst)
|
|
394
|
+
|
|
395
|
+
# Step 2: Build the DLT system with h33 = 1
|
|
396
|
+
# For each correspondence (x,y) → (X,Y):
|
|
397
|
+
# h11*x + h12*y + h13 - h31*x*X - h32*y*X = X
|
|
398
|
+
# h21*x + h22*y + h23 - h31*x*Y - h32*y*Y = Y
|
|
399
|
+
# Unknowns: [h11, h12, h13, h21, h22, h23, h31, h32]
|
|
400
|
+
|
|
401
|
+
num_unknowns = 8
|
|
402
|
+
a_rows: _Matrix = []
|
|
403
|
+
b_vec: _Vector = []
|
|
404
|
+
|
|
405
|
+
for i in range(n):
|
|
406
|
+
x, y = src_norm[i]
|
|
407
|
+
cap_x, cap_y = dst_norm[i]
|
|
408
|
+
|
|
409
|
+
a_rows.append([x, y, 1.0, 0.0, 0.0, 0.0, -x * cap_x, -y * cap_x])
|
|
410
|
+
b_vec.append(cap_x)
|
|
411
|
+
|
|
412
|
+
a_rows.append([0.0, 0.0, 0.0, x, y, 1.0, -x * cap_y, -y * cap_y])
|
|
413
|
+
b_vec.append(cap_y)
|
|
414
|
+
|
|
415
|
+
# Step 3: Solve
|
|
416
|
+
if n == 4:
|
|
417
|
+
# Exact 8×8 system
|
|
418
|
+
h_vec = _solve_linear(a_rows, b_vec)
|
|
419
|
+
else:
|
|
420
|
+
# Overdetermined → least squares via normal equations
|
|
421
|
+
h_vec = _solve_least_squares(a_rows, b_vec)
|
|
422
|
+
|
|
423
|
+
# Reconstruct normalised 3×3 H (h33 = 1)
|
|
424
|
+
h_norm: _Matrix = [
|
|
425
|
+
[h_vec[0], h_vec[1], h_vec[2]],
|
|
426
|
+
[h_vec[3], h_vec[4], h_vec[5]],
|
|
427
|
+
[h_vec[6], h_vec[7], 1.0],
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
# Step 4: Denormalise — H = T_dst^{-1} · H_norm · T_src
|
|
431
|
+
t_dst_inv = _invert_3x3(t_dst)
|
|
432
|
+
h_final = _mat_mul(t_dst_inv, _mat_mul(h_norm, t_src))
|
|
433
|
+
|
|
434
|
+
return h_final
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _apply_homography(h: _Matrix, x: float, y: float) -> tuple[float, float]:
|
|
438
|
+
"""Apply homography H to a single point (x, y).
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Projected (X, Y) in the destination coordinate system.
|
|
442
|
+
|
|
443
|
+
Raises:
|
|
444
|
+
ValueError: If the projected w-coordinate is near zero.
|
|
445
|
+
"""
|
|
446
|
+
w = h[2][0] * x + h[2][1] * y + h[2][2]
|
|
447
|
+
if abs(w) < 1e-12:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"Homography maps point ({x}, {y}) to infinity (w ≈ 0). "
|
|
450
|
+
"This usually means the point is outside the valid ground plane."
|
|
451
|
+
)
|
|
452
|
+
inv_w = 1.0 / w
|
|
453
|
+
out_x = (h[0][0] * x + h[0][1] * y + h[0][2]) * inv_w
|
|
454
|
+
out_y = (h[1][0] * x + h[1][1] * y + h[1][2]) * inv_w
|
|
455
|
+
return (out_x, out_y)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# Trilateration + linear interpolation
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _barycentric(
|
|
464
|
+
px: float,
|
|
465
|
+
py: float,
|
|
466
|
+
ax: float,
|
|
467
|
+
ay: float,
|
|
468
|
+
bx: float,
|
|
469
|
+
by: float,
|
|
470
|
+
cx: float,
|
|
471
|
+
cy: float,
|
|
472
|
+
) -> tuple[float, float, float] | None:
|
|
473
|
+
"""Compute barycentric coordinates (u, v, w) of point P in triangle ABC.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
(u, v, w) where ``P = u*A + v*B + w*C`` and ``u + v + w = 1``.
|
|
477
|
+
Returns None if the triangle is degenerate (area ≈ 0).
|
|
478
|
+
"""
|
|
479
|
+
# Twice the signed area of triangle ABC
|
|
480
|
+
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
|
481
|
+
if abs(denom) < 1e-12:
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
u = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
|
485
|
+
v = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
|
486
|
+
w = 1.0 - u - v
|
|
487
|
+
return (u, v, w)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _find_enclosing_triangle(
|
|
491
|
+
px: float,
|
|
492
|
+
py: float,
|
|
493
|
+
image_pts: list[tuple[float, float]],
|
|
494
|
+
) -> tuple[int, int, int, float, float, float] | None:
|
|
495
|
+
"""Find a triangle from *image_pts* that contains (px, py).
|
|
496
|
+
|
|
497
|
+
Tries all combinations of 3 reference points and returns the first
|
|
498
|
+
triangle where all barycentric coordinates are in [0, 1].
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
(i, j, k, u, v, w) — indices and barycentric coords.
|
|
502
|
+
None if no enclosing triangle is found (point is outside the
|
|
503
|
+
convex hull of the reference points).
|
|
504
|
+
"""
|
|
505
|
+
n = len(image_pts)
|
|
506
|
+
# Try triangles formed by nearest points first (more likely to enclose)
|
|
507
|
+
dists = sorted(range(n), key=lambda i: math.hypot(
|
|
508
|
+
image_pts[i][0] - px, image_pts[i][1] - py
|
|
509
|
+
))
|
|
510
|
+
|
|
511
|
+
for ii in range(len(dists)):
|
|
512
|
+
for jj in range(ii + 1, len(dists)):
|
|
513
|
+
for kk in range(jj + 1, len(dists)):
|
|
514
|
+
i, j, k = dists[ii], dists[jj], dists[kk]
|
|
515
|
+
bary = _barycentric(
|
|
516
|
+
px, py,
|
|
517
|
+
image_pts[i][0], image_pts[i][1],
|
|
518
|
+
image_pts[j][0], image_pts[j][1],
|
|
519
|
+
image_pts[k][0], image_pts[k][1],
|
|
520
|
+
)
|
|
521
|
+
if bary is None:
|
|
522
|
+
continue
|
|
523
|
+
u, v, w = bary
|
|
524
|
+
# Allow a small tolerance for points on edges
|
|
525
|
+
if u >= -1e-9 and v >= -1e-9 and w >= -1e-9:
|
|
526
|
+
return (i, j, k, u, v, w)
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _compute_pixel_to_metre_scale(
|
|
531
|
+
image_pts: list[tuple[float, float]],
|
|
532
|
+
world_pts: list[tuple[float, float]],
|
|
533
|
+
) -> float:
|
|
534
|
+
"""Compute a robust pixel-to-metre scale factor from reference pairs.
|
|
535
|
+
|
|
536
|
+
Uses the median of all pairwise ratios (real_distance / pixel_distance)
|
|
537
|
+
for robustness against outlier pairs.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
image_pts: Pixel positions of reference points.
|
|
541
|
+
world_pts: Local-XY positions (metres) of reference points.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Median scale factor (metres per pixel).
|
|
545
|
+
|
|
546
|
+
Raises:
|
|
547
|
+
ValueError: If scale cannot be computed (e.g. coincident points).
|
|
548
|
+
"""
|
|
549
|
+
scales: list[float] = []
|
|
550
|
+
n = len(image_pts)
|
|
551
|
+
for i in range(n):
|
|
552
|
+
for j in range(i + 1, n):
|
|
553
|
+
pixel_dist = math.hypot(
|
|
554
|
+
image_pts[i][0] - image_pts[j][0],
|
|
555
|
+
image_pts[i][1] - image_pts[j][1],
|
|
556
|
+
)
|
|
557
|
+
world_dist = math.hypot(
|
|
558
|
+
world_pts[i][0] - world_pts[j][0],
|
|
559
|
+
world_pts[i][1] - world_pts[j][1],
|
|
560
|
+
)
|
|
561
|
+
if pixel_dist > 1e-6:
|
|
562
|
+
scales.append(world_dist / pixel_dist)
|
|
563
|
+
|
|
564
|
+
if not scales:
|
|
565
|
+
raise ValueError(
|
|
566
|
+
"Cannot compute pixel-to-metre scale: all reference points "
|
|
567
|
+
"have identical pixel positions."
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Median is robust to outlier pairs
|
|
571
|
+
scales.sort()
|
|
572
|
+
mid = len(scales) // 2
|
|
573
|
+
if len(scales) % 2 == 0:
|
|
574
|
+
return (scales[mid - 1] + scales[mid]) / 2.0
|
|
575
|
+
return scales[mid]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _trilaterate(
|
|
579
|
+
world_pts: list[tuple[float, float]],
|
|
580
|
+
distances: list[float],
|
|
581
|
+
) -> tuple[float, float]:
|
|
582
|
+
"""Estimate position from known positions and estimated distances.
|
|
583
|
+
|
|
584
|
+
Linearises the trilateration equations by subtracting the first
|
|
585
|
+
equation from all others::
|
|
586
|
+
|
|
587
|
+
|P - P_i|^2 = d_i^2 for i = 1..n
|
|
588
|
+
|
|
589
|
+
Subtracting equation 1 from equation i gives::
|
|
590
|
+
|
|
591
|
+
2(x_1 - x_i) * x + 2(y_1 - y_i) * y =
|
|
592
|
+
d_i^2 - d_1^2 - x_i^2 + x_1^2 - y_i^2 + y_1^2
|
|
593
|
+
|
|
594
|
+
This yields (n-1) linear equations for 2 unknowns, solved via
|
|
595
|
+
least squares when n > 3.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
world_pts: Known positions (x, y) in metres — at least 3.
|
|
599
|
+
distances: Estimated distances from each position to the unknown
|
|
600
|
+
point — same length as *world_pts*.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
(x, y) estimated position in metres.
|
|
604
|
+
|
|
605
|
+
Raises:
|
|
606
|
+
ValueError: If the system is degenerate.
|
|
607
|
+
"""
|
|
608
|
+
n = len(world_pts)
|
|
609
|
+
x1, y1 = world_pts[0]
|
|
610
|
+
d1_sq = distances[0] ** 2
|
|
611
|
+
|
|
612
|
+
# Build the (n-1) × 2 linear system Ah = b
|
|
613
|
+
a_rows: _Matrix = []
|
|
614
|
+
b_vec: _Vector = []
|
|
615
|
+
for i in range(1, n):
|
|
616
|
+
xi, yi = world_pts[i]
|
|
617
|
+
di_sq = distances[i] ** 2
|
|
618
|
+
a_rows.append([2.0 * (x1 - xi), 2.0 * (y1 - yi)])
|
|
619
|
+
b_vec.append(di_sq - d1_sq - xi * xi + x1 * x1 - yi * yi + y1 * y1)
|
|
620
|
+
|
|
621
|
+
if n - 1 == 2:
|
|
622
|
+
# Exact 2×2 system
|
|
623
|
+
return tuple(_solve_linear(a_rows, b_vec)) # type: ignore[return-value]
|
|
624
|
+
|
|
625
|
+
# Overdetermined → least squares
|
|
626
|
+
result = _solve_least_squares(a_rows, b_vec)
|
|
627
|
+
return (result[0], result[1])
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _estimate_trilateration(
|
|
631
|
+
px: float,
|
|
632
|
+
py: float,
|
|
633
|
+
image_pts: list[tuple[float, float]],
|
|
634
|
+
world_pts: list[tuple[float, float]],
|
|
635
|
+
scale: float,
|
|
636
|
+
) -> tuple[float, float]:
|
|
637
|
+
"""Estimate local-XY position using trilateration.
|
|
638
|
+
|
|
639
|
+
Converts pixel distances to estimated real distances using *scale*,
|
|
640
|
+
then solves the linearised trilateration system.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
px: Query pixel x.
|
|
644
|
+
py: Query pixel y.
|
|
645
|
+
image_pts: Reference image positions.
|
|
646
|
+
world_pts: Reference world positions (local metres).
|
|
647
|
+
scale: Metres-per-pixel scale factor.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
(x, y) estimated world position in metres.
|
|
651
|
+
"""
|
|
652
|
+
# Estimate real-world distance from query to each reference
|
|
653
|
+
distances: list[float] = []
|
|
654
|
+
for ix, iy in image_pts:
|
|
655
|
+
pixel_dist = math.hypot(px - ix, py - iy)
|
|
656
|
+
distances.append(pixel_dist * scale)
|
|
657
|
+
|
|
658
|
+
return _trilaterate(world_pts, distances)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ---------------------------------------------------------------------------
|
|
662
|
+
# Fallback: inverse-distance-weighted interpolation
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _idw_interpolate(
|
|
667
|
+
image_point: tuple[float, float],
|
|
668
|
+
refs: Sequence[ReferencePoint],
|
|
669
|
+
power: float = 2.0,
|
|
670
|
+
) -> GeoPoint:
|
|
671
|
+
"""Inverse-distance-weighted interpolation as a fallback.
|
|
672
|
+
|
|
673
|
+
Used when neither homography nor trilateration can be computed
|
|
674
|
+
(e.g. collinear points or degenerate geometry).
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
image_point: (x, y) pixel coordinate to geolocate.
|
|
678
|
+
refs: Reference points.
|
|
679
|
+
power: Distance weighting exponent (default 2).
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Interpolated GeoPoint.
|
|
683
|
+
"""
|
|
684
|
+
px, py = image_point
|
|
685
|
+
weights: list[float] = []
|
|
686
|
+
for ref in refs:
|
|
687
|
+
dist = math.hypot(ref.image_x - px, ref.image_y - py)
|
|
688
|
+
if dist < 1e-9:
|
|
689
|
+
# Exact match with a reference point
|
|
690
|
+
return GeoPoint(lat=ref.lat, lng=ref.lng)
|
|
691
|
+
weights.append(1.0 / (dist ** power))
|
|
692
|
+
|
|
693
|
+
total = sum(weights)
|
|
694
|
+
lat = sum(w * r.lat for w, r in zip(weights, refs)) / total
|
|
695
|
+
lng = sum(w * r.lng for w, r in zip(weights, refs)) / total
|
|
696
|
+
return GeoPoint(lat=lat, lng=lng)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# ---------------------------------------------------------------------------
|
|
700
|
+
# Public API
|
|
701
|
+
# ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class CameraGeolocalizer:
|
|
705
|
+
"""Reusable geolocalizer for a fixed camera with known reference points.
|
|
706
|
+
|
|
707
|
+
Precomputes the mapping data once so that repeated calls to
|
|
708
|
+
:meth:`estimate` are fast.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
reference_points: At least 4 calibration points (3 for
|
|
712
|
+
trilateration) linking pixel positions to geographic
|
|
713
|
+
coordinates.
|
|
714
|
+
method: ``GeoMethod.HOMOGRAPHY`` (default) or
|
|
715
|
+
``GeoMethod.TRILATERATION``.
|
|
716
|
+
|
|
717
|
+
Raises:
|
|
718
|
+
ValueError: If too few reference points or invalid coordinates.
|
|
719
|
+
|
|
720
|
+
Examples::
|
|
721
|
+
|
|
722
|
+
# Homography (default)
|
|
723
|
+
geo = CameraGeolocalizer(refs)
|
|
724
|
+
|
|
725
|
+
# Trilateration + linear interpolation
|
|
726
|
+
geo = CameraGeolocalizer(refs, method=GeoMethod.TRILATERATION)
|
|
727
|
+
|
|
728
|
+
location = geo.estimate(BoundingBox(left=100, top=50, width=60, height=180))
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
def __init__(
|
|
732
|
+
self,
|
|
733
|
+
reference_points: Sequence[ReferencePoint],
|
|
734
|
+
method: GeoMethod = GeoMethod.HOMOGRAPHY,
|
|
735
|
+
) -> None:
|
|
736
|
+
self._method = method
|
|
737
|
+
|
|
738
|
+
min_points = 3 if method == GeoMethod.TRILATERATION else 4
|
|
739
|
+
if len(reference_points) < min_points:
|
|
740
|
+
raise ValueError(
|
|
741
|
+
f"At least {min_points} reference points are required for "
|
|
742
|
+
f"{method.value}, got {len(reference_points)}."
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
self._refs = list(reference_points)
|
|
746
|
+
|
|
747
|
+
# Validate geographic coordinates
|
|
748
|
+
for i, ref in enumerate(self._refs):
|
|
749
|
+
if not (-90.0 <= ref.lat <= 90.0):
|
|
750
|
+
raise ValueError(
|
|
751
|
+
f"Reference point {i}: lat must be in [-90, 90], got {ref.lat}."
|
|
752
|
+
)
|
|
753
|
+
if not (-180.0 <= ref.lng <= 180.0):
|
|
754
|
+
raise ValueError(
|
|
755
|
+
f"Reference point {i}: lng must be in [-180, 180], got {ref.lng}."
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Compute the geographic origin (centroid of reference geo-coords)
|
|
759
|
+
self._origin_lat = sum(r.lat for r in self._refs) / len(self._refs)
|
|
760
|
+
self._origin_lng = sum(r.lng for r in self._refs) / len(self._refs)
|
|
761
|
+
self._cos_origin = math.cos(math.radians(self._origin_lat))
|
|
762
|
+
|
|
763
|
+
# Guard against near-pole origins where cos → 0 (longitude becomes
|
|
764
|
+
# undefined and the equirectangular projection breaks down).
|
|
765
|
+
if self._cos_origin < 1e-6:
|
|
766
|
+
raise ValueError(
|
|
767
|
+
f"Reference points are too close to a pole "
|
|
768
|
+
f"(origin latitude {self._origin_lat:.4f}°). "
|
|
769
|
+
f"Equirectangular projection is not valid at extreme latitudes."
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# Convert reference geo-coords to local XY (metres)
|
|
773
|
+
image_pts: list[tuple[float, float]] = []
|
|
774
|
+
world_pts: list[tuple[float, float]] = []
|
|
775
|
+
for ref in self._refs:
|
|
776
|
+
image_pts.append((ref.image_x, ref.image_y))
|
|
777
|
+
world_pts.append(
|
|
778
|
+
_geo_to_local(
|
|
779
|
+
ref.lat, ref.lng,
|
|
780
|
+
self._origin_lat, self._origin_lng,
|
|
781
|
+
self._cos_origin,
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
self._image_pts = image_pts
|
|
786
|
+
self._world_pts = world_pts
|
|
787
|
+
|
|
788
|
+
# Method-specific initialisation
|
|
789
|
+
self._homography: _Matrix | None = None
|
|
790
|
+
self._pixel_to_metre_scale: float = 0.0
|
|
791
|
+
self._fallback = False
|
|
792
|
+
|
|
793
|
+
if method == GeoMethod.HOMOGRAPHY:
|
|
794
|
+
self._init_homography()
|
|
795
|
+
else:
|
|
796
|
+
self._init_trilateration()
|
|
797
|
+
|
|
798
|
+
def _init_homography(self) -> None:
|
|
799
|
+
"""Compute the homography matrix from reference correspondences."""
|
|
800
|
+
try:
|
|
801
|
+
self._homography = _compute_homography(
|
|
802
|
+
self._image_pts, self._world_pts
|
|
803
|
+
)
|
|
804
|
+
except ValueError:
|
|
805
|
+
self._fallback = True
|
|
806
|
+
|
|
807
|
+
def _init_trilateration(self) -> None:
|
|
808
|
+
"""Precompute the pixel-to-metre scale for trilateration."""
|
|
809
|
+
try:
|
|
810
|
+
self._pixel_to_metre_scale = _compute_pixel_to_metre_scale(
|
|
811
|
+
self._image_pts, self._world_pts
|
|
812
|
+
)
|
|
813
|
+
except ValueError:
|
|
814
|
+
self._fallback = True
|
|
815
|
+
|
|
816
|
+
@property
|
|
817
|
+
def method(self) -> GeoMethod:
|
|
818
|
+
"""The geolocation method used by this instance."""
|
|
819
|
+
return self._method
|
|
820
|
+
|
|
821
|
+
@property
|
|
822
|
+
def is_fallback(self) -> bool:
|
|
823
|
+
"""True if the chosen method failed and IDW interpolation is used."""
|
|
824
|
+
return self._fallback
|
|
825
|
+
|
|
826
|
+
def reprojection_errors(self) -> list[float]:
|
|
827
|
+
"""Compute reprojection error (metres) for each reference point.
|
|
828
|
+
|
|
829
|
+
Projects each reference point's image coordinates through the
|
|
830
|
+
chosen method and measures the Euclidean distance to its known
|
|
831
|
+
world position. Useful for assessing calibration quality.
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
List of errors in metres, one per reference point.
|
|
835
|
+
Empty list if using fallback (IDW) mode.
|
|
836
|
+
"""
|
|
837
|
+
if self._fallback:
|
|
838
|
+
return []
|
|
839
|
+
|
|
840
|
+
errors: list[float] = []
|
|
841
|
+
for idx, ((ix, iy), (wx, wy)) in enumerate(
|
|
842
|
+
zip(self._image_pts, self._world_pts)
|
|
843
|
+
):
|
|
844
|
+
if self._method == GeoMethod.HOMOGRAPHY:
|
|
845
|
+
if self._homography is None:
|
|
846
|
+
return []
|
|
847
|
+
px, py = _apply_homography(self._homography, ix, iy)
|
|
848
|
+
else:
|
|
849
|
+
# For trilateration, try linear interpolation first
|
|
850
|
+
enc = _find_enclosing_triangle(ix, iy, self._image_pts)
|
|
851
|
+
if enc is not None:
|
|
852
|
+
i, j, k, u, v, w = enc
|
|
853
|
+
# Skip if this point IS one of the triangle vertices
|
|
854
|
+
# (would trivially give 0 error from itself)
|
|
855
|
+
if idx in (i, j, k):
|
|
856
|
+
# Use leave-one-out: trilaterate without this point
|
|
857
|
+
other_img = [p for n, p in enumerate(self._image_pts) if n != idx]
|
|
858
|
+
other_world = [p for n, p in enumerate(self._world_pts) if n != idx]
|
|
859
|
+
try:
|
|
860
|
+
px, py = _estimate_trilateration(
|
|
861
|
+
ix, iy, other_img, other_world,
|
|
862
|
+
self._pixel_to_metre_scale,
|
|
863
|
+
)
|
|
864
|
+
except ValueError:
|
|
865
|
+
px, py = wx, wy # can't compute, assume 0 error
|
|
866
|
+
else:
|
|
867
|
+
px = u * self._world_pts[i][0] + v * self._world_pts[j][0] + w * self._world_pts[k][0]
|
|
868
|
+
py = u * self._world_pts[i][1] + v * self._world_pts[j][1] + w * self._world_pts[k][1]
|
|
869
|
+
else:
|
|
870
|
+
try:
|
|
871
|
+
px, py = _estimate_trilateration(
|
|
872
|
+
ix, iy, self._image_pts, self._world_pts,
|
|
873
|
+
self._pixel_to_metre_scale,
|
|
874
|
+
)
|
|
875
|
+
except ValueError:
|
|
876
|
+
px, py = wx, wy
|
|
877
|
+
|
|
878
|
+
errors.append(math.hypot(px - wx, py - wy))
|
|
879
|
+
return errors
|
|
880
|
+
|
|
881
|
+
def mean_reprojection_error(self) -> float:
|
|
882
|
+
"""Mean reprojection error across all reference points (metres).
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
Mean error in metres, or 0.0 if using fallback mode.
|
|
886
|
+
"""
|
|
887
|
+
errs = self.reprojection_errors()
|
|
888
|
+
if not errs:
|
|
889
|
+
return 0.0
|
|
890
|
+
return sum(errs) / len(errs)
|
|
891
|
+
|
|
892
|
+
def estimate(self, box: BoundingBox) -> GeoPoint:
|
|
893
|
+
"""Estimate the geographic location of a detected object.
|
|
894
|
+
|
|
895
|
+
Derives the ground-contact point from the bounding box
|
|
896
|
+
(bottom-centre) and projects it using the chosen method.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
box: Detection bounding box.
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
Estimated geographic coordinate.
|
|
903
|
+
"""
|
|
904
|
+
px, py = box.ground_point
|
|
905
|
+
|
|
906
|
+
if self._fallback:
|
|
907
|
+
return _idw_interpolate((px, py), self._refs)
|
|
908
|
+
|
|
909
|
+
if self._method == GeoMethod.HOMOGRAPHY:
|
|
910
|
+
return self._estimate_homography(px, py)
|
|
911
|
+
return self._estimate_trilateration_method(px, py)
|
|
912
|
+
|
|
913
|
+
def _estimate_homography(self, px: float, py: float) -> GeoPoint:
|
|
914
|
+
"""Project pixel through the homography."""
|
|
915
|
+
if self._homography is None:
|
|
916
|
+
return _idw_interpolate((px, py), self._refs)
|
|
917
|
+
|
|
918
|
+
local_x, local_y = _apply_homography(self._homography, px, py)
|
|
919
|
+
return _local_to_geo(
|
|
920
|
+
local_x, local_y,
|
|
921
|
+
self._origin_lat, self._origin_lng,
|
|
922
|
+
self._cos_origin,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
def _estimate_trilateration_method(self, px: float, py: float) -> GeoPoint:
|
|
926
|
+
"""Estimate location via trilateration + linear interpolation.
|
|
927
|
+
|
|
928
|
+
Strategy:
|
|
929
|
+
1. If the pixel falls inside a triangle of reference points,
|
|
930
|
+
use barycentric (linear) interpolation of their world coords.
|
|
931
|
+
This is exact at reference points and smooth between them.
|
|
932
|
+
2. If outside (extrapolation), use trilateration with estimated
|
|
933
|
+
pixel-to-metre distances.
|
|
934
|
+
"""
|
|
935
|
+
# Step 1: Try linear interpolation inside a reference triangle
|
|
936
|
+
enc = _find_enclosing_triangle(px, py, self._image_pts)
|
|
937
|
+
if enc is not None:
|
|
938
|
+
i, j, k, u, v, w = enc
|
|
939
|
+
# Interpolate in local-XY space (metres), then convert to geo
|
|
940
|
+
local_x = (
|
|
941
|
+
u * self._world_pts[i][0]
|
|
942
|
+
+ v * self._world_pts[j][0]
|
|
943
|
+
+ w * self._world_pts[k][0]
|
|
944
|
+
)
|
|
945
|
+
local_y = (
|
|
946
|
+
u * self._world_pts[i][1]
|
|
947
|
+
+ v * self._world_pts[j][1]
|
|
948
|
+
+ w * self._world_pts[k][1]
|
|
949
|
+
)
|
|
950
|
+
return _local_to_geo(
|
|
951
|
+
local_x, local_y,
|
|
952
|
+
self._origin_lat, self._origin_lng,
|
|
953
|
+
self._cos_origin,
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
# Step 2: Outside the reference mesh — use trilateration
|
|
957
|
+
try:
|
|
958
|
+
local_x, local_y = _estimate_trilateration(
|
|
959
|
+
px, py,
|
|
960
|
+
self._image_pts, self._world_pts,
|
|
961
|
+
self._pixel_to_metre_scale,
|
|
962
|
+
)
|
|
963
|
+
except ValueError:
|
|
964
|
+
# Degenerate case — fall back to IDW
|
|
965
|
+
return _idw_interpolate((px, py), self._refs)
|
|
966
|
+
|
|
967
|
+
return _local_to_geo(
|
|
968
|
+
local_x, local_y,
|
|
969
|
+
self._origin_lat, self._origin_lng,
|
|
970
|
+
self._cos_origin,
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
def estimate_batch(self, boxes: Sequence[BoundingBox]) -> list[GeoPoint]:
|
|
974
|
+
"""Estimate geographic locations for multiple detections.
|
|
975
|
+
|
|
976
|
+
Args:
|
|
977
|
+
boxes: Sequence of bounding boxes.
|
|
978
|
+
|
|
979
|
+
Returns:
|
|
980
|
+
List of GeoPoints in the same order as *boxes*.
|
|
981
|
+
"""
|
|
982
|
+
return [self.estimate(box) for box in boxes]
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def estimate_location(
|
|
986
|
+
box: BoundingBox,
|
|
987
|
+
reference_points: Sequence[ReferencePoint],
|
|
988
|
+
method: GeoMethod = GeoMethod.HOMOGRAPHY,
|
|
989
|
+
) -> GeoPoint:
|
|
990
|
+
"""One-shot geolocation estimate for a single detection.
|
|
991
|
+
|
|
992
|
+
Convenience function that builds the geolocalizer and projects in one
|
|
993
|
+
call. For repeated estimates from the same camera, prefer
|
|
994
|
+
:class:`CameraGeolocalizer` to avoid recomputing.
|
|
995
|
+
|
|
996
|
+
Args:
|
|
997
|
+
box: Detection bounding box.
|
|
998
|
+
reference_points: Calibration points (≥ 4 for homography, ≥ 3 for
|
|
999
|
+
trilateration).
|
|
1000
|
+
method: ``GeoMethod.HOMOGRAPHY`` (default) or
|
|
1001
|
+
``GeoMethod.TRILATERATION``.
|
|
1002
|
+
|
|
1003
|
+
Returns:
|
|
1004
|
+
Estimated geographic coordinate.
|
|
1005
|
+
|
|
1006
|
+
Raises:
|
|
1007
|
+
ValueError: If too few reference points are provided.
|
|
1008
|
+
"""
|
|
1009
|
+
geo = CameraGeolocalizer(reference_points, method=method)
|
|
1010
|
+
return geo.estimate(box)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def estimate_locations_batch(
|
|
1014
|
+
boxes: Sequence[BoundingBox],
|
|
1015
|
+
reference_points: Sequence[ReferencePoint],
|
|
1016
|
+
method: GeoMethod = GeoMethod.HOMOGRAPHY,
|
|
1017
|
+
) -> list[GeoPoint]:
|
|
1018
|
+
"""One-shot batch geolocation for multiple detections.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
boxes: Sequence of bounding boxes.
|
|
1022
|
+
reference_points: Calibration points (≥ 4 for homography, ≥ 3 for
|
|
1023
|
+
trilateration).
|
|
1024
|
+
method: ``GeoMethod.HOMOGRAPHY`` (default) or
|
|
1025
|
+
``GeoMethod.TRILATERATION``.
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
List of GeoPoints in the same order as *boxes*.
|
|
1029
|
+
|
|
1030
|
+
Raises:
|
|
1031
|
+
ValueError: If too few reference points are provided.
|
|
1032
|
+
"""
|
|
1033
|
+
geo = CameraGeolocalizer(reference_points, method=method)
|
|
1034
|
+
return geo.estimate_batch(boxes)
|