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.
Files changed (86) hide show
  1. dory/__init__.py +101 -0
  2. dory/auth/__init__.py +10 -0
  3. dory/auth/oauth2.py +153 -0
  4. dory/auto_instrument.py +142 -0
  5. dory/cli/__init__.py +5 -0
  6. dory/cli/main.py +137 -0
  7. dory/cli/templates.py +123 -0
  8. dory/config/__init__.py +23 -0
  9. dory/config/defaults.py +24 -0
  10. dory/config/loader.py +430 -0
  11. dory/config/presets.py +73 -0
  12. dory/config/schema.py +84 -0
  13. dory/core/__init__.py +27 -0
  14. dory/core/app.py +434 -0
  15. dory/core/context.py +209 -0
  16. dory/core/lifecycle.py +214 -0
  17. dory/core/meta.py +121 -0
  18. dory/core/modes.py +479 -0
  19. dory/core/processor.py +564 -0
  20. dory/core/signals.py +122 -0
  21. dory/decorators.py +142 -0
  22. dory/edge/__init__.py +88 -0
  23. dory/edge/adaptive.py +644 -0
  24. dory/edge/detector.py +546 -0
  25. dory/edge/fencing.py +488 -0
  26. dory/edge/heartbeat.py +598 -0
  27. dory/edge/role.py +419 -0
  28. dory/errors/__init__.py +139 -0
  29. dory/errors/classification.py +362 -0
  30. dory/errors/codes.py +498 -0
  31. dory/geo/__init__.py +40 -0
  32. dory/geo/geolocalizer.py +1034 -0
  33. dory/health/__init__.py +12 -0
  34. dory/health/probes.py +210 -0
  35. dory/health/server.py +635 -0
  36. dory/k8s/__init__.py +80 -0
  37. dory/k8s/annotation_watcher.py +184 -0
  38. dory/k8s/client.py +251 -0
  39. dory/k8s/labels.py +505 -0
  40. dory/k8s/pod_metadata.py +182 -0
  41. dory/logging/__init__.py +9 -0
  42. dory/logging/logger.py +148 -0
  43. dory/metrics/__init__.py +7 -0
  44. dory/metrics/collector.py +301 -0
  45. dory/middleware/__init__.py +46 -0
  46. dory/middleware/connection_tracker.py +608 -0
  47. dory/middleware/request_id.py +325 -0
  48. dory/middleware/request_tracker.py +511 -0
  49. dory/migration/__init__.py +33 -0
  50. dory/migration/configmap.py +232 -0
  51. dory/migration/s3_store.py +594 -0
  52. dory/migration/serialization.py +135 -0
  53. dory/migration/state_manager.py +286 -0
  54. dory/migration/transfer.py +382 -0
  55. dory/monitoring/__init__.py +29 -0
  56. dory/monitoring/opentelemetry.py +489 -0
  57. dory/output/__init__.py +31 -0
  58. dory/output/envelope.py +137 -0
  59. dory/output/formatter.py +113 -0
  60. dory/output/rabbitmq.py +632 -0
  61. dory/output/routing.py +318 -0
  62. dory/output/validator.py +199 -0
  63. dory/py.typed +2 -0
  64. dory/recovery/__init__.py +60 -0
  65. dory/recovery/golden_image.py +487 -0
  66. dory/recovery/golden_snapshot.py +713 -0
  67. dory/recovery/golden_validator.py +518 -0
  68. dory/recovery/partial_recovery.py +482 -0
  69. dory/recovery/recovery_decision.py +242 -0
  70. dory/recovery/restart_detector.py +142 -0
  71. dory/recovery/state_validator.py +183 -0
  72. dory/resilience/__init__.py +45 -0
  73. dory/resilience/circuit_breaker.py +457 -0
  74. dory/resilience/retry.py +389 -0
  75. dory/simple.py +342 -0
  76. dory/types.py +68 -0
  77. dory/utils/__init__.py +31 -0
  78. dory/utils/errors.py +59 -0
  79. dory/utils/retry.py +115 -0
  80. dory/utils/timeout.py +80 -0
  81. dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
  82. dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
  83. dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
  84. dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
  85. dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
  86. dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
@@ -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)