Source code for aquacal.triangulation.triangulate
"""Refractive triangulation for 3D reconstruction."""
import numpy as np
from aquacal.config.schema import CalibrationResult, Vec2, Vec3
from aquacal.core.camera import Camera
from aquacal.core.interface_model import Interface
from aquacal.core.refractive_geometry import refractive_back_project
[docs]
def triangulate_point(
calibration: CalibrationResult,
observations: dict[str, Vec2],
) -> Vec3 | None:
"""
Triangulate a single 3D point from multi-camera observations.
Args:
calibration: Complete calibration result
observations: Dict mapping camera_name to 2D pixel coordinates.
Must have at least 2 cameras.
Returns:
3D point in world coordinates, or None if triangulation fails.
Failure occurs if: fewer than 2 observations, camera not in calibration,
or refractive_back_project() fails for all cameras.
Notes:
- Uses refractive_back_project() to get rays in water
- Finds point minimizing sum of squared distances to all rays
"""
if len(observations) < 2:
return None
# Build camera_distances dict with ALL cameras
camera_distances = {
cam_name: calibration.cameras[cam_name].water_z
for cam_name in calibration.cameras
}
# Create single shared interface with all cameras
interface = Interface(
normal=calibration.interface.normal,
camera_distances=camera_distances,
n_air=calibration.interface.n_air,
n_water=calibration.interface.n_water,
)
rays = []
for cam_name, pixel in observations.items():
if cam_name not in calibration.cameras:
continue
cam_calib = calibration.cameras[cam_name]
camera = Camera(cam_name, cam_calib.intrinsics, cam_calib.extrinsics)
result = refractive_back_project(camera, interface, pixel)
if result[0] is not None:
rays.append((result[0], result[1]))
if len(rays) < 2:
return None
try:
return triangulate_rays(rays)
except ValueError:
return None
[docs]
def triangulate_rays(rays: list[tuple[Vec3, Vec3]]) -> Vec3:
"""
Find 3D point minimizing sum of squared distances to all rays.
Uses closed-form linear least squares solution.
Args:
rays: List of (origin, direction) tuples. Directions must be unit vectors.
Must have at least 2 rays.
Returns:
3D point that minimizes sum of squared distances to all rays.
Raises:
ValueError: If fewer than 2 rays provided or system is degenerate.
"""
if len(rays) < 2:
raise ValueError("Need at least 2 rays")
A_sum = np.zeros((3, 3), dtype=np.float64)
b_sum = np.zeros(3, dtype=np.float64)
for origin, direction in rays:
d = direction / np.linalg.norm(direction) # ensure unit
I_minus_ddT = np.eye(3) - np.outer(d, d)
A_sum += I_minus_ddT
b_sum += I_minus_ddT @ origin
# Solve A_sum @ P = b_sum
try:
P = np.linalg.solve(A_sum, b_sum)
except np.linalg.LinAlgError:
raise ValueError("Degenerate ray configuration")
return P
[docs]
def point_to_ray_distance(point: Vec3, ray_origin: Vec3, ray_direction: Vec3) -> float:
"""
Compute perpendicular distance from point to ray.
Args:
point: 3D point
ray_origin: Origin of ray
ray_direction: Unit direction of ray
Returns:
Perpendicular distance from point to ray (always non-negative).
"""
v = point - ray_origin
# Project v onto ray direction
proj_length = np.dot(v, ray_direction)
proj = proj_length * ray_direction
# Perpendicular component
perp = v - proj
return np.linalg.norm(perp)