import numpy as np
from typing import List, Tuple, Union, Sequence, Callable
import copy
from dataclasses import dataclass
import matplotlib.pyplot as plt
[docs]
class Base:
[docs]
def __init__(self, **kwargs):
if not hasattr(self, "_id"):
self._id = kwargs.get(
"id", id(self)
) # when a ray copy is made, the id will be inherited
for key, value in kwargs.items():
setattr(self, key, value)
[docs]
def copy(self, **kwargs):
"""Create a copy of the object with the same id."""
obj = copy.deepcopy(self)
for key, value in kwargs.items():
setattr(obj, key, value)
return obj
[docs]
class Vector(Base):
"""3D vector class with basic operations."""
[docs]
def __init__(self, origin, **kwargs):
super().__init__(**kwargs)
self.origin = np.array(origin, dtype=float)
self.unit = kwargs.get("unit", 1e-2) # default unit is cm
def _normalize_vector(self, vector) -> np.ndarray:
"""Normalize a vector to have unit length."""
vec = np.array(vector, dtype=float)
return vec / np.linalg.norm(vec)
[docs]
def R(self, axis, theta: float) -> np.ndarray:
# u is the rotaion axis, theta is the rotation angle
"""
Returns the rotation matrix for a proper rotation by angle theta
around the axis (u_x, u_y, u_z), where the axis is a unit vector.
Args:
axis: tuple or list of length 3 (unit vector u_x, u_y, u_z)
theta: float, rotation angle in radians
Returns:
A 3x3 numpy array representing the rotation matrix.
"""
axis = np.array(axis, dtype=float)
axis = axis / np.linalg.norm(axis)
u_x, u_y, u_z = axis
cos_theta = np.cos(theta)
sin_theta = np.sin(theta)
one_minus_cos = 1 - cos_theta
# Rotation matrix components, see https://en.wikipedia.org/wiki/Rotation_matrix
R = np.array(
[
[
u_x**2 * one_minus_cos + cos_theta,
u_x * u_y * one_minus_cos - u_z * sin_theta,
u_x * u_z * one_minus_cos + u_y * sin_theta,
],
[
u_y * u_x * one_minus_cos + u_z * sin_theta,
u_y**2 * one_minus_cos + cos_theta,
u_y * u_z * one_minus_cos - u_x * sin_theta,
],
[
u_z * u_x * one_minus_cos - u_y * sin_theta,
u_z * u_y * one_minus_cos + u_x * sin_theta,
u_z**2 * one_minus_cos + cos_theta,
],
]
)
return R
def _vector_to_R(self, t: np.ndarray) -> np.ndarray:
"""
Return 3x3 rotation matrix taking (1,0,0) to t = (tx,ty,tz).
Args:
t: (tx, ty, tz) target vector
Returns:
A 3x3 numpy array representing the rotation matrix.
"""
v = self._normalize_vector(t)
# Special cases: colinear with ±x
if np.allclose(v, [1, 0, 0]): # already aligned
return np.eye(3)
if np.allclose(v, [-1, 0, 0]): # opposite; rotate 180° about y-axis
return np.array([[-1, 0, 0], [0, -1, 0], [0, 0, 1]])
k = np.cross([1, 0, 0], v)
k = k / np.linalg.norm(k)
K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
c = v[0] # cos_theta
R = c * np.eye(3) + (1 - c) * np.outer(k, k) + np.sin(np.arccos(c)) * K
return R
def _RotAroundLocal(self, axis, localpoint, theta):
raise NotImplementedError("_RotAroundLocal method not implemented")
def _RotAroundCenter(self, axis, theta):
return self._RotAroundLocal(axis, [0, 0, 0], theta)
[docs]
def RotX(self, theta):
"""Rotate around global x-axis."""
return self._RotAroundCenter([1, 0, 0], theta)
[docs]
def RotY(self, theta):
"""Rotate around global y-axis."""
return self._RotAroundCenter([0, 1, 0], theta)
[docs]
def RotZ(self, theta):
"""Rotate around global z-axis."""
return self._RotAroundCenter([0, 0, 1], theta)
def _RotAround(self, axis, point, theta):
"""Rotate around an arbitrary axis and point."""
localpoint = np.array(point) - self.origin
return self._RotAroundLocal(axis, localpoint, theta)
[docs]
def RotXAroundLocal(self, localpoint, theta):
"""Rotate around global x-axis at a local point."""
return self._RotAroundLocal([1, 0, 0], localpoint, theta)
[docs]
def RotYAroundLocal(self, localpoint, theta):
"""Rotate around global y-axis at a local point."""
return self._RotAroundLocal([0, 1, 0], localpoint, theta)
[docs]
def RotZAroundLocal(self, localpoint, theta):
"""Rotate around global z-axis at a local point."""
return self._RotAroundLocal([0, 0, 1], localpoint, theta)
def _Translate(self, movement):
"""Translate the object by a given movement vector."""
self.origin += np.array(movement)
return self
[docs]
def TX(self, dx):
"""Translate the object along the x-axis."""
return self._Translate([dx, 0, 0])
[docs]
def TY(self, dy):
"""Translate the object along the y-axis."""
return self._Translate([0, dy, 0])
[docs]
def TZ(self, dz):
"""Translate the object along the z-axis."""
return self._Translate([0, 0, dz])
[docs]
class Path:
[docs]
def __init__(self, pts: List):
self.pts = [np.array(pt) for pt in pts]
self.pts.append(self.pts[0]) # close the path
self.accumulated_length = self._accumulate_length()
self.round_trip = self.accumulated_length[-1]
def _accumulate_length(self):
accum_length = [0]
length = 0
for i in range(1, len(self.pts)):
length += np.linalg.norm(np.array(self.pts[i]) - np.array(self.pts[i - 1]))
accum_length.append(length)
return accum_length
def _get_segment(self, l):
"""get the segment index at the position l, from pts[i-1] to pts[i]"""
# map l into the range of round_trip
l = l % self.round_trip
# print(l)
for i in range(1, len(self.pts)):
if l <= self.accumulated_length[i]:
break
return i
[docs]
def coord(self, l):
"""calculate the coordinate at the position l"""
# calculate the coordinate
i = self._get_segment(l)
l0 = self.accumulated_length[i - 1]
l1 = self.accumulated_length[i]
# print(i)
ratio = (l - l0) / (l1 - l0)
pt0 = np.array(self.pts[i - 1])
# print(pt0)
pt1 = np.array(self.pts[i])
# print(pt1)
# print(ratio)
return pt0 + (pt1 - pt0) * ratio
[docs]
def direction(self, l):
i = self._get_segment(l)
pt0 = np.array(self.pts[i - 1])
pt1 = np.array(self.pts[i])
return np.linalg.norm(pt1 - pt0)
[docs]
def rotz_theta(self, l):
direction = self.direction(l)
return np.arctan2(direction[1], direction[0])
[docs]
def bbox(self):
x = [pt[0] for pt in self.pts]
y = [pt[1] for pt in self.pts]
return (min(x) - 0.3, max(x) + 0.3, min(y) - 0.3, max(y) + 0.3)
[docs]
@dataclass(frozen=True)
class Color:
SCIENCE_RED_LIGHT: str = "#febfbe"
SCIENCE_RED_DARK: str = "#fa331a"
SCIENCE_BLUE_LIGHT: str = "#bdd3ec"
SCIENCE_BLUE_DARK: str = "#2556ae"
[docs]
def run_code_block(filepath, marker, globals=None):
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
start_marker = "# >>> " + marker
end_marker = "# <<< " + marker
inside_block = False
code_lines = []
for line in lines:
if line.strip() == start_marker:
inside_block = True
continue
if line.strip() == end_marker:
break
if inside_block:
code_lines.append(line)
print(
f"Loading code block {len(code_lines)} lines from {filepath} between markers: {start_marker} and {end_marker}"
)
code = "".join(code_lines)
exec(code, globals)
[docs]
def to_mathematical_str(s):
if s == "None":
return "None"
return s.replace("[", "{").replace("]", "}").replace("e", "*10^").replace("j", "I")
[docs]
def get_attr_str(obj, attr_name, default=None):
return (
default
if not hasattr(obj, attr_name) or not getattr(obj, attr_name)
else getattr(obj, attr_name)
)
[docs]
def base_merge_bboxs(bboxs):
return (
np.min([b[0] for b in bboxs]),
np.max([b[1] for b in bboxs]),
np.min([b[2] for b in bboxs]),
np.max([b[3] for b in bboxs]),
np.min([b[4] for b in bboxs]),
np.max([b[5] for b in bboxs]),
)
[docs]
def wavelength_to_rgb(wavelength_m, gamma=0.8):
"""
Converts wavelength (nm) to an RGB tuple (range 0.0 to 1.0).
Based on Dan Bruton's algorithm, extended for False Color Infrared.
"""
wavelength = float(wavelength_m) * 1e9
R, G, B = 0.0, 0.0, 0.0
# --- Visible Spectrum (380 - 750 nm) ---
if 380 <= wavelength <= 440:
attenuation = 0.3 + 0.7 * (wavelength - 380) / (440 - 380)
R = ((-(wavelength - 440) / (440 - 380)) * attenuation) ** gamma
B = (1.0 * attenuation) ** gamma
elif 440 <= wavelength <= 485:
G = ((wavelength - 440) / (485 - 440)) ** gamma
B = 1.0
elif 485 <= wavelength <= 500:
G = 1.0
B = (-(wavelength - 500) / (500 - 485)) ** gamma
elif 500 <= wavelength <= 565:
R = ((wavelength - 500) / (565 - 500)) ** gamma
G = 1.0
elif 565 <= wavelength <= 590:
R = 1.0
G = (-(wavelength - 590) / (590 - 565)) ** gamma
elif 590 <= wavelength <= 750:
attenuation = 0.3 + 0.7 * (750 - wavelength) / (750 - 590)
R = (1.0 * attenuation) ** gamma
# --- High-Contrast IR Mapping ---
elif 750 < wavelength <= 1100:
# Near IR: Shift from Deep Red to Bright Magenta
ratio = (wavelength - 750) / (1100 - 750)
R = 1.0
G = 0.0
B = ratio # Adding blue makes it Magenta
elif 1100 < wavelength <= 1800:
# Short-wave IR: Shift from Magenta to Cyan
ratio = (wavelength - 1100) / (1800 - 1100)
R = 1.0 - ratio
G = ratio
B = 1.0
elif 1800 < wavelength <= 2500:
# Mid-IR: Steady Teal/Cyan
R, G, B = 0.0, 0.5, 0.5
else:
R, G, B = 0.0, 0.0, 0.0
return (R, G, B)