Core Geometry

Note

For theory behind these functions, see Refractive Geometry and Coordinate Conventions.

The core package provides refractive ray tracing, camera models, and geometric primitives.

Refractive Projection

aquacal.core.refractive_geometry.refractive_project(camera, interface, point_3d, max_iterations=10, tolerance=1e-09)[source]

Project 3D underwater point to 2D pixel through refractive interface.

Auto-selects the fastest algorithm: - Flat interface (normal ≈ [0,0,-1]): Newton-Raphson (2-4 iterations, ~50x faster) - General interface: Brent-search fallback

This is the forward projection used for computing reprojection error.

Parameters:
  • camera (Camera) – Camera object

  • interface (Interface) – Interface object

  • point_3d (ndarray[tuple[int, ...], dtype[float64]]) – 3D point in water (world coordinates, Z > interface_z)

  • max_iterations (int) – Maximum Newton iterations for flat interface (default 10)

  • tolerance (float) – Convergence tolerance for flat interface (default 1e-9 meters)

Returns:

2D pixel coordinates, or None if projection fails.

Return type:

ndarray[tuple[int, …], dtype[float64]] | None

Example

>>> import numpy as np
>>> from aquacal.core.camera import Camera
>>> from aquacal.core.interface_model import Interface
>>> # Assuming camera and interface are set up
>>> point_3d = np.array([0.5, 0.3, 0.8])  # Underwater point
>>> pixel = refractive_project(camera, interface, point_3d)
>>> if pixel is not None:
>>>     print(f"Projected to pixel: {pixel}")

Note

For a detailed explanation of the refractive geometry model, see the Refractive Geometry guide.

Notes

  • Returns None if: point above interface, TIR, optimization fails, or refracted ray doesn’t reach camera

aquacal.core.refractive_geometry.refractive_project_batch(camera, interface, points_3d, max_iterations=10, tolerance=1e-09)[source]

Project multiple 3D underwater points to 2D pixels (vectorized).

Currently only supports flat interfaces (normal ≈ [0,0,-1]). Uses vectorized Newton-Raphson for fast batch projection.

Parameters:
  • camera (Camera) – Camera object

  • interface (Interface) – Interface object

  • points_3d (ndarray[tuple[int, ...], dtype[float64]]) – Array of shape (N, 3) with 3D points

  • max_iterations (int) – Maximum Newton iterations (default 10)

  • tolerance (float) – Convergence tolerance (default 1e-9 meters)

Returns:

Array of shape (N, 2) with pixel coordinates. Invalid projections have NaN values.

Raises:

ValueError – If interface normal is not horizontal [0, 0, -1]

Return type:

ndarray[tuple[int, …], dtype[float64]]

Example

>>> import numpy as np
>>> points = np.array([[0.5, 0.3, 0.8], [0.2, 0.4, 0.9], [0.1, 0.2, 1.0]])
>>> pixels = refractive_project_batch(camera, interface, points)
>>> valid_pixels = pixels[~np.isnan(pixels).any(axis=1)]

Note

For a detailed explanation of the refractive geometry model, see the Refractive Geometry guide.

Notes

  • Batch Brent-search is not implemented. For non-flat interfaces, use refractive_project() in a loop instead.

aquacal.core.refractive_geometry.refractive_project_fast(camera, interface, point_3d, max_iterations=10, tolerance=1e-09)[source]

Deprecated: use refractive_project() instead.

refractive_project() auto-selects the fast Newton-Raphson path for flat interfaces, making this function redundant.

Parameters:
Return type:

ndarray[tuple[int, …], dtype[float64]] | None

Snell’s Law and Ray Tracing

aquacal.core.refractive_geometry.snells_law_3d(incident_direction, surface_normal, n_ratio)[source]

Apply Snell’s law in 3D to compute refracted ray direction.

Parameters:
  • incident_direction (ndarray[tuple[int, ...], dtype[float64]]) – Unit vector of incoming ray (toward interface)

  • surface_normal (ndarray[tuple[int, ...], dtype[float64]]) – Unit normal of interface (always pass interface.normal, which points from water toward air [0,0,-1])

  • n_ratio (float) – Ratio n1/n2 where ray goes from medium 1 to medium 2

Returns:

Unit vector of refracted ray direction, or None if total internal reflection.

Return type:

ndarray[tuple[int, …], dtype[float64]] | None

Example

>>> import numpy as np
>>> incident = np.array([0.0, 0.1, -1.0])  # Ray going down into water
>>> normal = np.array([0.0, 0.0, -1.0])    # Points up from water to air
>>> refracted = snells_law_3d(incident, normal, n_ratio=0.75)  # Air to water
>>> print(f"Refracted: {refracted}")

Note

For a detailed explanation of the refractive geometry model, see the Refractive Geometry guide.

Notes

  • Function handles normal orientation internally based on ray direction

  • For air-to-water: n_ratio = n_air / n_water ~= 0.75

  • For water-to-air: n_ratio = n_water / n_air ~= 1.33

  • TIR only possible when going from denser to less dense medium

aquacal.core.refractive_geometry.trace_ray_air_to_water(camera, interface, pixel)[source]

Trace ray from camera through air-water interface.

Parameters:
  • camera (Camera) – Camera object (camera.name must be in interface.camera_distances)

  • interface (Interface) – Interface object

  • pixel (ndarray[tuple[int, ...], dtype[float64]]) – 2D pixel coordinates

Returns:

  • intersection_point: where ray hits interface (world coords)

  • refracted_direction: unit direction of ray in water (points +Z, into water)

Returns (None, None) if ray doesn’t hit interface or TIR occurs.

Return type:

Tuple of (intersection_point, refracted_direction)

aquacal.core.refractive_geometry.refractive_back_project(camera, interface, pixel)[source]

Back-project pixel to ray in water.

This is a convenience wrapper around trace_ray_air_to_water, providing an API consistent with Camera.pixel_to_ray_world.

Parameters:
Returns:

  • ray_origin: point on interface where ray enters water

  • ray_direction: unit direction of ray in water

Returns (None, None) if back-projection fails.

Return type:

Tuple of (ray_origin, ray_direction)

Camera Models

class aquacal.core.camera.Camera(name, intrinsics, extrinsics)[source]

Bases: object

Camera model combining intrinsics and extrinsics.

Handles standard pinhole projection with distortion, but NOT refraction. For refractive projection, use refractive_geometry module.

Parameters:
name

Camera identifier string

intrinsics

CameraIntrinsics dataclass

extrinsics

CameraExtrinsics dataclass

Example

>>> from aquacal.core.camera import Camera
>>> from aquacal.config.schema import CameraIntrinsics, CameraExtrinsics
>>> import numpy as np
>>> # Create camera with intrinsics and extrinsics
>>> camera = Camera("cam1", intrinsics, extrinsics)
>>> point_3d = np.array([1.0, 0.5, 2.0])
>>> pixel = camera.project(point_3d)

Note

For coordinate system conventions, see the Coordinate Conventions guide.

property K: ndarray[tuple[int, ...], dtype[float64]]

3x3 intrinsic matrix.

property dist_coeffs: ndarray[tuple[int, ...], dtype[float64]]

Distortion coefficients.

property R: ndarray[tuple[int, ...], dtype[float64]]

3x3 rotation matrix (world to camera).

property t: ndarray[tuple[int, ...], dtype[float64]]

Translation vector.

property C: ndarray[tuple[int, ...], dtype[float64]]

Camera center in world coordinates. Delegates to extrinsics.C.

property image_size: tuple[int, int]

Image size as (width, height).

property P: ndarray[tuple[int, ...], dtype[float64]]

3x4 projection matrix (without distortion).

P = K @ [R | t]

Note: This is the ideal pinhole projection. For projection with distortion, use the project() method.

world_to_camera(point_world)[source]

Transform point from world to camera coordinates.

Parameters:

point_world (ndarray[tuple[int, ...], dtype[float64]]) – 3D point in world frame, shape (3,)

Returns:

3D point in camera frame, shape (3,)

Return type:

ndarray[tuple[int, …], dtype[float64]]

Formula: p_cam = R @ p_world + t

project(point_world, apply_distortion=True)[source]

Project 3D world point to 2D pixel coordinates.

Parameters:
  • point_world (ndarray[tuple[int, ...], dtype[float64]]) – 3D point in world frame, shape (3,)

  • apply_distortion (bool) – If True, apply lens distortion. Default True.

Returns:

2D pixel coordinates shape (2,), or None if point is behind camera.

Return type:

ndarray[tuple[int, …], dtype[float64]] | None

Notes

  • Returns None if point’s Z coordinate in camera frame is ≤ 0

  • Uses cv2.projectPoints when apply_distortion=True

  • Uses direct K @ (p_cam / p_cam[2]) when apply_distortion=False

pixel_to_ray(pixel, undistort=True)[source]

Back-project pixel to unit ray in camera frame.

Parameters:
  • pixel (ndarray[tuple[int, ...], dtype[float64]]) – 2D pixel coordinates, shape (2,)

  • undistort (bool) – If True, undistort pixel first. Default True.

Returns:

Unit direction vector in camera frame (Z forward), shape (3,)

Return type:

ndarray[tuple[int, …], dtype[float64]]

Notes

  • Principal point maps to ray [0, 0, 1]

  • Uses cv2.undistortPoints when undistort=True

pixel_to_ray_world(pixel, undistort=True)[source]

Back-project pixel to ray in world frame.

Parameters:
  • pixel (ndarray[tuple[int, ...], dtype[float64]]) – 2D pixel coordinates, shape (2,)

  • undistort (bool) – If True, undistort pixel first. Default True.

Returns:

  • ray_origin: Camera center in world frame, shape (3,)

  • ray_direction: Unit direction vector in world frame, shape (3,)

Return type:

Tuple of (ray_origin, ray_direction)

aquacal.core.camera.undistort_points(points, K, dist_coeffs)[source]

Undistort pixel coordinates.

Parameters:
Returns:

Undistorted pixel coordinates, shape (N, 2)

Return type:

ndarray[tuple[int, …], dtype[float64]]

Notes

  • Thin wrapper around cv2.undistortPoints

  • Output is in pixel coordinates (not normalized), same as input

Board Geometry

ChArUco board geometry and utilities.

class aquacal.core.board.BoardGeometry(config)[source]

Bases: object

ChArUco board 3D geometry.

The board frame has origin at the top-left corner (when viewed from front), with X pointing right, Y pointing down, and Z pointing into the board (away from viewer). This matches OpenCV 4.6+ CharucoBoard convention.

Parameters:

config (BoardConfig)

config

Board configuration

corner_positions

Dict mapping corner_id to 3D position in board frame

num_corners

Total number of interior corners

property corner_positions: dict[int, ndarray[tuple[int, ...], dtype[float64]]]

Get 3D positions of all corners in board frame.

Returns:

Dict mapping corner_id (int) to position (3,) in meters

Example

>>> board = BoardGeometry(config)
>>> pos = board.corner_positions[0]
>>> pos.shape
(3,)
property num_corners: int

Get total number of interior corners.

Returns:

Number of corners = (squares_x - 1) * (squares_y - 1)

get_opencv_board()[source]

Get OpenCV CharucoBoard object for detection.

Returns:

OpenCV CharucoBoard instance

Return type:

CharucoBoard

transform_corners(rvec, tvec)[source]

Transform all corners from board frame to world frame.

Parameters:
Returns:

Dict mapping corner_id to 3D position in world frame

Return type:

dict[int, ndarray[tuple[int, …], dtype[float64]]]

Example

>>> board = BoardGeometry(config)
>>> # Identity transform
>>> world_pts = board.transform_corners(np.zeros(3), np.zeros(3))
>>> np.allclose(world_pts[0], board.corner_positions[0])
True
get_corner_array(corner_ids)[source]

Get 3D positions for specific corners as array.

Parameters:

corner_ids (ndarray[tuple[int, ...], dtype[int32]]) – Array of corner IDs to retrieve

Returns:

Array of shape (N, 3) with 3D positions in board frame

Return type:

ndarray[tuple[int, …], dtype[float64]]

Example

>>> board = BoardGeometry(config)
>>> pts = board.get_corner_array(np.array([0, 1, 2]))
>>> pts.shape
(3, 3)

Interface Model

Refractive interface (water surface) model.

class aquacal.core.interface_model.Interface(normal, camera_distances, n_air=1.0, n_water=1.333)[source]

Bases: object

Planar refractive interface (air-water boundary).

The interface is a horizontal plane at a fixed Z-coordinate in the world frame.

Parameters:
normal

Unit normal vector pointing from water toward air [0, 0, -1]

camera_distances

Per-camera Z-coordinate of the water surface in world frame. After optimization this is the same value (water_z) for all cameras. The physical camera-to-water gap is computed internally by projection functions as water_z - C_z.

n_air

Refractive index of air (default 1.0)

n_water

Refractive index of water (default 1.333)

get_water_z(camera_name)[source]

Get the water surface Z-coordinate for a specific camera.

This is the Z-coordinate of the interface plane in world frame. The physical camera-to-water gap is z_interface - C_z, computed internally by the projection functions.

Parameters:

camera_name (str) – Name of camera

Returns:

Water surface Z-coordinate for the specified camera

Raises:

KeyError – If camera_name not in camera_distances

Return type:

float

get_interface_point(camera_center, camera_name)[source]

Get the point on the interface directly below the camera center.

Assumes cameras look straight down (+Z direction in Z-down world frame).

Parameters:
  • camera_center (ndarray[tuple[int, ...], dtype[float64]]) – Camera center in world coordinates [x, y, z]

  • camera_name (str) – Name of camera (for distance lookup)

Returns:

3D point on interface plane [x, y, z_interface]

Return type:

ndarray[tuple[int, …], dtype[float64]]

Note

Only uses camera_center[0] and camera_center[1] (XY position). The Z-coordinate is determined by the camera’s interface distance, not by camera_center[2]. This is intentional — the interface is at a fixed world Z position.

property n_ratio_air_to_water: float

Ratio n_air / n_water for Snell’s law (air to water).

property n_ratio_water_to_air: float

Ratio n_water / n_air for Snell’s law (water to air).

aquacal.core.interface_model.ray_plane_intersection(ray_origin, ray_direction, plane_point, plane_normal)[source]

Compute intersection of ray with plane.

Uses the parametric ray equation: P = origin + t * direction And plane equation: (P - plane_point) · plane_normal = 0

Solving: t = ((plane_point - origin) · normal) / (direction · normal)

Parameters:
  • ray_origin (ndarray[tuple[int, ...], dtype[float64]]) – Origin of ray, shape (3,)

  • ray_direction (ndarray[tuple[int, ...], dtype[float64]]) – Direction of ray (need not be unit), shape (3,)

  • plane_point (ndarray[tuple[int, ...], dtype[float64]]) – Any point on the plane, shape (3,)

  • plane_normal (ndarray[tuple[int, ...], dtype[float64]]) – Normal vector of plane (need not be unit), shape (3,)

Returns:

Tuple of (intersection_point, t) where intersection = origin + t * direction. Returns (None, None) if ray is parallel to plane (direction · normal ≈ 0).

Return type:

tuple[ndarray[tuple[int, …], dtype[float64]], float] | tuple[None, None]

Notes

  • Returns intersection for ANY t value, including negative (behind ray origin)

  • Caller should check t > 0 if only forward intersections are desired

  • Uses tolerance of 1e-10 for parallel check