mirror of
https://github.com/Rulez777/FramePicoTracker.git
synced 2026-10-23 07:01:36 +02:00
182 lines
5.4 KiB
Python
182 lines
5.4 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
# Copyright (C) 2026 L8OLOOE and contributors. See CREDITS.md and NOTICE.md.
|
|
"""Brown-Conrady Camera Lens Distortion Model.
|
|
|
|
Provides fast forward distortion and inverse undistortion for 2D subpixel blob
|
|
centroids detected on wide-angle tracking cameras (e.g. Steam Frame tracking cameras).
|
|
|
|
Undistorting blob centers directly in O(N) is orders of magnitude faster (< 1 us)
|
|
than undistorting entire high-resolution camera frames (5-10 ms).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import numpy as np
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class DistortionCoeffs:
|
|
"""Radial and tangential lens distortion coefficients.
|
|
|
|
Model:
|
|
r^2 = x^2 + y^2
|
|
radial = 1 + k1*r^2 + k2*r^4 + k3*r^6
|
|
x_dist = x * radial + 2*p1*x*y + p2*(r^2 + 2*x^2)
|
|
y_dist = y * radial + p1*(r^2 + 2*y^2) + 2*p2*x*y
|
|
"""
|
|
|
|
k1: float = 0.0
|
|
k2: float = 0.0
|
|
p1: float = 0.0
|
|
p2: float = 0.0
|
|
k3: float = 0.0
|
|
|
|
@property
|
|
def is_zero(self) -> bool:
|
|
return (
|
|
self.k1 == 0.0
|
|
and self.k2 == 0.0
|
|
and self.p1 == 0.0
|
|
and self.p2 == 0.0
|
|
and self.k3 == 0.0
|
|
)
|
|
|
|
def as_array(self) -> np.ndarray:
|
|
return np.array([self.k1, self.k2, self.p1, self.p2, self.k3], dtype=np.float64)
|
|
|
|
|
|
def distort_normalized_points(
|
|
pts_norm: np.ndarray,
|
|
coeffs: DistortionCoeffs,
|
|
) -> np.ndarray:
|
|
"""Apply radial and tangential distortion to normalized points (x, y) = (X/Z, Y/Z).
|
|
|
|
Args:
|
|
pts_norm: (N, 2) array of normalized coordinates.
|
|
coeffs: DistortionCoeffs.
|
|
|
|
Returns:
|
|
(N, 2) array of distorted normalized coordinates (xd, yd).
|
|
"""
|
|
if coeffs.is_zero or len(pts_norm) == 0:
|
|
return np.copy(pts_norm)
|
|
|
|
pts = np.asarray(pts_norm, dtype=np.float64)
|
|
x = pts[:, 0]
|
|
y = pts[:, 1]
|
|
|
|
r2 = x * x + y * y
|
|
r4 = r2 * r2
|
|
r6 = r4 * r2
|
|
|
|
radial = 1.0 + coeffs.k1 * r2 + coeffs.k2 * r4 + coeffs.k3 * r6
|
|
|
|
xd = x * radial + 2.0 * coeffs.p1 * x * y + coeffs.p2 * (r2 + 2.0 * x * x)
|
|
yd = y * radial + coeffs.p1 * (r2 + 2.0 * y * y) + 2.0 * coeffs.p2 * x * y
|
|
|
|
return np.column_stack((xd, yd))
|
|
|
|
|
|
def undistort_normalized_points(
|
|
pts_dist_norm: np.ndarray,
|
|
coeffs: DistortionCoeffs,
|
|
max_iters: int = 5,
|
|
tol: float = 1e-6,
|
|
) -> np.ndarray:
|
|
"""Invert distortion using Newton-Raphson iteration.
|
|
|
|
Given distorted normalized points (xd, yd), finds undistorted normalized points (x, y).
|
|
|
|
Args:
|
|
pts_dist_norm: (N, 2) distorted normalized points.
|
|
coeffs: DistortionCoeffs.
|
|
max_iters: Max Newton-Raphson iterations (typically converges in 3-4 steps).
|
|
tol: Convergence tolerance.
|
|
|
|
Returns:
|
|
(N, 2) undistorted normalized coordinates (x, y).
|
|
"""
|
|
if coeffs.is_zero or len(pts_dist_norm) == 0:
|
|
return np.copy(pts_dist_norm)
|
|
|
|
pts_d = np.asarray(pts_dist_norm, dtype=np.float64)
|
|
# Initial guess is the distorted point
|
|
x = np.copy(pts_d[:, 0])
|
|
y = np.copy(pts_d[:, 1])
|
|
xd_target = pts_d[:, 0]
|
|
yd_target = pts_d[:, 1]
|
|
|
|
for _ in range(max_iters):
|
|
r2 = x * x + y * y
|
|
r4 = r2 * r2
|
|
r6 = r4 * r2
|
|
|
|
radial = 1.0 + coeffs.k1 * r2 + coeffs.k2 * r4 + coeffs.k3 * r6
|
|
|
|
fx = x * radial + 2.0 * coeffs.p1 * x * y + coeffs.p2 * (r2 + 2.0 * x * x) - xd_target
|
|
fy = y * radial + coeffs.p1 * (r2 + 2.0 * y * y) + 2.0 * coeffs.p2 * x * y - yd_target
|
|
|
|
if np.max(np.abs(fx)) < tol and np.max(np.abs(fy)) < tol:
|
|
break
|
|
|
|
# Derivative of radial w.r.t r2
|
|
d_radial_dr2 = coeffs.k1 + 2.0 * coeffs.k2 * r2 + 3.0 * coeffs.k3 * r4
|
|
|
|
# Partial derivatives
|
|
dfx_dx = radial + x * d_radial_dr2 * (2.0 * x) + 2.0 * coeffs.p1 * y + 6.0 * coeffs.p2 * x
|
|
dfx_dy = x * d_radial_dr2 * (2.0 * y) + 2.0 * coeffs.p1 * x + 2.0 * coeffs.p2 * y
|
|
|
|
dfy_dx = y * d_radial_dr2 * (2.0 * x) + 2.0 * coeffs.p1 * x + 2.0 * coeffs.p2 * y
|
|
dfy_dy = radial + y * d_radial_dr2 * (2.0 * y) + 6.0 * coeffs.p1 * y + 2.0 * coeffs.p2 * x
|
|
|
|
# 2x2 determinant per point: det = dfx_dx * dfy_dy - dfx_dy * dfy_dx
|
|
det = dfx_dx * dfy_dy - dfx_dy * dfy_dx
|
|
det = np.where(np.abs(det) < 1e-12, 1e-12, det)
|
|
|
|
# Newton step: delta = -inv(J) * f
|
|
dx = -(dfy_dy * fx - dfx_dy * fy) / det
|
|
dy = -(-dfy_dx * fx + dfx_dx * fy) / det
|
|
|
|
x += dx
|
|
y += dy
|
|
|
|
return np.column_stack((x, y))
|
|
|
|
|
|
def undistort_pixel_points(
|
|
pixel_pts: np.ndarray,
|
|
fx: float,
|
|
fy: float,
|
|
cx: float,
|
|
cy: float,
|
|
coeffs: DistortionCoeffs,
|
|
) -> np.ndarray:
|
|
"""Convert distorted pixel points (u_d, v_d) to undistorted pixel points (u, v).
|
|
|
|
Args:
|
|
pixel_pts: (N, 2) distorted pixel coordinates.
|
|
fx, fy, cx, cy: Camera intrinsics.
|
|
coeffs: Distortion coefficients.
|
|
|
|
Returns:
|
|
(N, 2) undistorted pixel coordinates.
|
|
"""
|
|
if coeffs.is_zero or len(pixel_pts) == 0:
|
|
return np.copy(pixel_pts)
|
|
|
|
pts = np.asarray(pixel_pts, dtype=np.float64)
|
|
# 1. Pixel -> Distorted Normalized
|
|
xd = (pts[:, 0] - cx) / fx
|
|
yd = (pts[:, 1] - cy) / fy
|
|
pts_d_norm = np.column_stack((xd, yd))
|
|
|
|
# 2. Undistort normalized
|
|
pts_norm = undistort_normalized_points(pts_d_norm, coeffs)
|
|
|
|
# 3. Normalized -> Undistorted Pixel
|
|
u = pts_norm[:, 0] * fx + cx
|
|
v = pts_norm[:, 1] * fy + cy
|
|
|
|
return np.column_stack((u, v))
|