"""Camera view visualization module.
Provides functions for visualizing the camera's view of the eye.
Shows pupil detection, corneal reflections, and camera image coordinates.
"""
from typing import TYPE_CHECKING, Any
import matplotlib.lines as mlines
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
from ..core import Camera
from ..types import CameraImage
from .plot_config import create_plot_config
[docs]
def plot_camera_view_of_eye(
camera_images: list[CameraImage] | CameraImage,
cameras: list[Camera] | Camera,
cr_3d_lists: list[list[Any]] | list[Any] | None = None,
ax: "Axes | None" = None,
eye_colors: list[str] | None = None,
camera_colors: list[str] | None = None,
) -> None:
"""Plot camera views of eyes.
Shows what each camera sees - all eyes visible in that camera's field of view.
Args:
camera_images: CameraImage object or list of CameraImage objects (each contains all eyes seen by that camera)
cameras: Camera object or list of Camera objects
cr_3d_lists: list of lists of corneal reflection 3D positions for each eye, or single list
ax: Optional matplotlib axis
eye_colors: Optional list of colors for the eyes.
camera_colors: Optional list of colors for the cameras.
"""
# Convert single objects to lists
if not isinstance(camera_images, list):
camera_images = [camera_images]
if not isinstance(cameras, list):
cameras = [cameras]
if cr_3d_lists is not None and not isinstance(cr_3d_lists, list):
cr_3d_lists = [cr_3d_lists]
if len(camera_images) != len(cameras):
raise ValueError("Number of camera images must match number of cameras")
ax2 = ax
if ax2 is None:
_, ax2 = plt.subplots()
else:
ax2.cla()
config = create_plot_config()
if eye_colors is None:
eye_colors = config.colors.eyes
if camera_colors is None:
camera_colors = config.colors.cameras
all_resolutions = []
has_valid_data = False
for cam_idx, (camera_image, camera) in enumerate(zip(camera_images, cameras, strict=False)):
if camera_image is None:
continue
cam_color = camera_colors[cam_idx % len(camera_colors)]
all_resolutions.append(camera.camera_matrix.resolution)
if camera_image.pupil_boundaries:
for eye_idx, boundary in enumerate(camera_image.pupil_boundaries):
if boundary is not None and len(boundary) > 2:
has_valid_data = True
eye_color = eye_colors[eye_idx % len(eye_colors)]
pupil_x = [p.x for p in boundary] + [boundary[0].x]
pupil_y = [p.y for p in boundary] + [boundary[0].y]
center_x = np.mean([p.x for p in boundary])
center_y = np.mean([p.y for p in boundary])
scale_factor = 1.05
border_x = [center_x + (p.x - center_x) * scale_factor for p in boundary] + [
center_x + (boundary[0].x - center_x) * scale_factor
]
border_y = [center_y + (p.y - center_y) * scale_factor for p in boundary] + [
center_y + (boundary[0].y - center_y) * scale_factor
]
ax2.plot(
border_x,
border_y,
color=cam_color,
linewidth=config.elements.camera_border_width,
alpha=config.elements.camera_border_alpha,
linestyle=config.lines.solid,
)
ax2.plot(
pupil_x,
pupil_y,
color=eye_color,
linewidth=config.elements.pupil_boundary_width,
alpha=config.elements.pupil_boundary_alpha,
label=f"Camera {cam_idx + 1} - Pupil {eye_idx + 1}",
linestyle=config.lines.solid,
)
if camera_image.pupil_centers:
for eye_idx, pupil_center in enumerate(camera_image.pupil_centers):
if pupil_center is not None:
has_valid_data = True
eye_color = eye_colors[eye_idx % len(eye_colors)]
pupil_center_img = pupil_center.to_array()
ax2.scatter(
pupil_center_img[0],
pupil_center_img[1],
color=eye_color,
s=config.markers.small_details,
marker="*",
linewidth=config.lines.thick_lines,
label=f"Camera {cam_idx + 1} - Eye {eye_idx + 1} Center",
edgecolors=cam_color,
)
if cr_3d_lists:
# Get glint sizes from camera image if available
glint_sizes = camera_image.glint_sizes_px if camera_image.glint_sizes_px is not None else None
for eye_idx, cr_3d_list in enumerate(cr_3d_lists):
for cr_idx, cr_3d in enumerate(cr_3d_list):
if cr_3d is None:
continue
has_valid_data = True
projection_result = camera.project(cr_3d)
cr_img = projection_result.image_points
label = f"Camera {cam_idx + 1} - Eye {eye_idx + 1} CR {cr_idx + 1}"
# Draw as circle with physical size if available
glint_size = glint_sizes[cr_idx] if glint_sizes is not None and cr_idx < len(glint_sizes) else None
if glint_size is not None:
circle = mpatches.Circle(
(cr_img[0, 0], cr_img[1, 0]),
radius=glint_size / 2,
facecolor=config.colors.corneal_reflection,
edgecolor=cam_color,
linewidth=config.elements.corneal_reflection_width,
)
ax2.add_patch(circle)
# Proxy handle so the legend shows a scatter-style marker
proxy = mlines.Line2D(
[],
[],
marker="o",
color="none",
markerfacecolor=config.colors.corneal_reflection,
markeredgecolor=cam_color,
markeredgewidth=config.elements.corneal_reflection_width,
markersize=config.markers.corneal_reflections**0.5,
label=label,
)
ax2.add_line(proxy)
else:
ax2.scatter(
cr_img[0, 0],
cr_img[1, 0],
color=config.colors.corneal_reflection,
s=config.markers.corneal_reflections,
marker="o",
edgecolor=cam_color,
linewidth=config.elements.corneal_reflection_width,
label=label,
)
if not has_valid_data:
ax2.text(0, 0, "No camera data to display", ha="center", va="center", fontsize=config.fonts.subtitle)
return
if all_resolutions:
max_res_x = max(res.x for res in all_resolutions)
max_res_y = max(res.y for res in all_resolutions)
ax2.set_xlim(-max_res_x / 2, max_res_x / 2)
ax2.set_ylim(-max_res_y / 2, max_res_y / 2)
ax2.invert_yaxis()
ax2.invert_xaxis()
ax2.set_xlabel("X (pixels)")
ax2.set_ylabel("Y (pixels)")
ax2.set_title("Camera View")
ax2.grid(config.elements.grid_enabled, alpha=config.lines.grid_alpha)
if config.elements.equal_aspect:
ax2.set_aspect("equal")
handles, _ = ax2.get_legend_handles_labels()
if handles:
ax2.legend(fontsize=config.fonts.annotation, **config.layout.legend_outside_right)