| """Pinhole camera → ground projection for aerial detections (nadir + oblique). |
| |
| Geometry (local ENU frame, camera at the origin): |
| world axes X = East, Y = North, Z = Up |
| camera position C = (0, 0, H) H = altitude above ground (AGL, metres) |
| ground plane Z = 0 (flat-earth; terrain DEM is a later refinement) |
| |
| A detection at pixel (u, v) defines a ray from the camera; we rotate it into |
| the world by the camera pose and intersect it with the ground plane to get the |
| animal's ground position. The camera's *own* ground position is (0, 0) — the |
| nadir point — so every returned coordinate is metres East / North of the point |
| directly below the aircraft. |
| |
| Pose convention (degrees): |
| pitch = 0 -> nadir (optical axis straight down) |
| pitch > 0 -> tilted FORWARD toward the horizon (oblique); the image centre |
| then lands H*tan(pitch) ahead of the nadir point |
| yaw -> heading / rotation of the ground pattern about vertical |
| roll -> rotation about the optical axis (banking); usually ~0 |
| |
| The maths is validated against closed-form cases in tests/test_geolocation.py |
| (nadir centre -> (0,0); nadir edge -> GSD-scaled offset; oblique centre -> |
| H*tan(pitch) forward). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| from dataclasses import dataclass |
|
|
| import numpy as np |
|
|
|
|
| @dataclass |
| class GroundPoint: |
| """A detection projected to the ground, in the local ENU frame (metres).""" |
|
|
| east: float |
| north: float |
| range_m: float |
| perp_distance: float | None = None |
|
|
|
|
| def _rot_z(deg: float) -> np.ndarray: |
| a = math.radians(deg) |
| c, s = math.cos(a), math.sin(a) |
| return np.array([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]) |
|
|
|
|
| def _rot_x(deg: float) -> np.ndarray: |
| a = math.radians(deg) |
| c, s = math.cos(a), math.sin(a) |
| return np.array([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]) |
|
|
|
|
| |
| |
| _R0 = np.array([[1.0, 0.0, 0.0], |
| [0.0, -1.0, 0.0], |
| [0.0, 0.0, -1.0]]) |
|
|
|
|
| @dataclass |
| class CameraModel: |
| """Aerial camera pose + intrinsics, enough to project pixels to the ground. |
| |
| Provide focal length either as ``hfov_deg`` (horizontal field of view) or |
| ``focal_px`` directly. ``altitude`` is height above the ground plane. |
| """ |
|
|
| altitude: float |
| image_width: int |
| image_height: int |
| hfov_deg: float | None = None |
| focal_px: float | None = None |
| pitch_deg: float = 0.0 |
| roll_deg: float = 0.0 |
| yaw_deg: float = 0.0 |
|
|
| def focal_pixels(self) -> float: |
| if self.focal_px is not None: |
| return float(self.focal_px) |
| if self.hfov_deg is None: |
| raise ValueError("Provide either hfov_deg or focal_px.") |
| return (self.image_width / 2.0) / math.tan(math.radians(self.hfov_deg) / 2.0) |
|
|
| def _world_from_cam(self) -> np.ndarray: |
| |
| return _rot_z(self.yaw_deg) @ _R0 @ _rot_x(self.pitch_deg) @ _rot_z(self.roll_deg) |
|
|
| def ground_sample_distance(self) -> float: |
| """Metres-per-pixel at the image centre for a nadir view (altitude/focal).""" |
| return self.altitude / self.focal_pixels() |
|
|
| def pixel_to_ground(self, u: float, v: float) -> GroundPoint: |
| """Project image pixel (u, v) to a ground point in ENU metres. |
| |
| Raises ValueError if the ray points at or above the horizon (no ground |
| intersection) — which happens for oblique views looking too high. |
| """ |
| f = self.focal_pixels() |
| cx, cy = self.image_width / 2.0, self.image_height / 2.0 |
| |
| ray_cam = np.array([u - cx, v - cy, f]) |
| ray_world = self._world_from_cam() @ ray_cam |
|
|
| dz = ray_world[2] |
| if dz >= -1e-9: |
| raise ValueError( |
| "Pixel ray does not intersect the ground (at/above horizon) — " |
| "check pitch/altitude; this pixel is sky, not ground." |
| ) |
| t = -self.altitude / dz |
| east = float(t * ray_world[0]) |
| north = float(t * ray_world[1]) |
| |
| rng = float(math.sqrt(east**2 + north**2 + self.altitude**2)) |
| return GroundPoint(east=east, north=north, range_m=rng) |
|
|
|
|
| def ground_to_pixel(self, east: float, north: float) -> tuple[float, float] | None: |
| """Inverse projection: ground point (east, north, 0) -> pixel (u, v). |
| |
| Returns None if the point is behind the camera. The caller checks whether |
| (u, v) falls within the image bounds. Used to simulate what a camera at a |
| given pose would actually capture (round-trips pixel_to_ground exactly). |
| """ |
| R = self._world_from_cam() |
| d_world = np.array([east, north, -self.altitude]) |
| d_cam = R.T @ d_world |
| if d_cam[2] <= 1e-9: |
| return None |
| f = self.focal_pixels() |
| u = self.image_width / 2.0 + f * d_cam[0] / d_cam[2] |
| v = self.image_height / 2.0 + f * d_cam[1] / d_cam[2] |
| return (float(u), float(v)) |
|
|
|
|
| def perpendicular_distance(east: float, north: float, heading_deg: float) -> float: |
| """Distance from a ground point to the flight line. |
| |
| The flight line passes through the nadir point (0,0) along the aircraft |
| heading. Perpendicular distance is the across-track offset — exactly the |
| quantity line-transect distance sampling consumes. |
| """ |
| |
| h = math.radians(heading_deg) |
| dir_e, dir_n = math.sin(h), math.cos(h) |
| |
| return abs(east * dir_n - north * dir_e) |
|
|
|
|
| def project_detections( |
| detections, |
| camera: CameraModel, |
| heading_deg: float | None = None, |
| ) -> list[GroundPoint]: |
| """Project a batch of pixel detections to ground points (+ perpendicular distance). |
| |
| Args: |
| detections: iterable of (u, v) image points — typically each detection's |
| ground-contact point (e.g. bottom-centre of its box). |
| camera: the CameraModel for the frame. |
| heading_deg: aircraft heading; if given, each GroundPoint gets its |
| ``perp_distance`` (across-track offset) filled in for distance |
| sampling. Defaults to the camera yaw. |
| |
| Returns: |
| list of GroundPoint. Points whose rays miss the ground (sky) are skipped. |
| """ |
| hd = camera.yaw_deg if heading_deg is None else heading_deg |
| out: list[GroundPoint] = [] |
| for u, v in detections: |
| try: |
| gp = camera.pixel_to_ground(u, v) |
| except ValueError: |
| continue |
| gp.perp_distance = perpendicular_distance(gp.east, gp.north, hd) |
| out.append(gp) |
| return out |
|
|