camera_intrinsics_estimation.py
"""
(*)~---------------------------------------------------------------------------
Pupil - eye tracking platform
Copyright (C) Pupil Labs
Distributed under the terms of the GNU
Lesser General Public License (LGPL v3.0).
See COPYING and COPYING.LESSER for license details.
---------------------------------------------------------------------------~(*)
"""
import cv2
import gl_utils
import glfw
import numpy as np
import OpenGL.GL as gl
from camera_models import Fisheye_Dist_Camera, Radial_Dist_Camera
from gl_utils import (
GLFWErrorReporting,
adjust_gl_view,
basic_gl_setup,
clear_gl_screen,
draw_circle_filled_func_builder,
make_coord_system_norm_based,
)
from pyglui import ui
from pyglui.cygl.utils import RGBA, draw_gl_texture, draw_polyline
from pyglui.pyfontstash import fontstash
from pyglui.ui import get_opensans_font_path
GLFWErrorReporting.set_default()
# logging
import logging
from hotkey import Hotkey
from plugin import Plugin
logger = logging.getLogger(__name__)
# window calbacks
def on_resize(window, w, h):
active_window = glfw.get_current_context()
glfw.make_context_current(window)
adjust_gl_view(w, h)
glfw.make_context_current(active_window)
class Camera_Intrinsics_Estimation(Plugin):
"""Camera_Intrinsics_Calibration
This method is not a gaze calibration.
This method is used to calculate camera intrinsics.
"""
icon_chr = chr(0xEC06)
icon_font = "pupil_icons"
def __init__(self, g_pool, fullscreen=False, monitor_idx=0):
super().__init__(g_pool)
self.collect_new = False
self.calculated = False
self.obj_grid = _gen_pattern_grid((4, 11))
self.img_points = []
self.obj_points = []
self.count = 10
self.display_grid = _make_grid()
self._window = None
self.menu = None
self.button = None
self.clicks_to_close = 5
self.window_should_close = False
self.monitor_idx = monitor_idx
self.fullscreen = fullscreen
self.dist_mode = "Fisheye"
self.glfont = fontstash.Context()
self.glfont.add_font("opensans", get_opensans_font_path())
self.glfont.set_size(32)
self.glfont.set_color_float((0.2, 0.5, 0.9, 1.0))
self.glfont.set_align_string(v_align="center")
self.undist_img = None
self.show_undistortion = False
self.show_undistortion_switch = None
if (
hasattr(self.g_pool.capture, "intrinsics")
and self.g_pool.capture.intrinsics
):
logger.info(
"Click show undistortion to verify camera intrinsics calibration."
)
logger.info(
"Hint: Straight lines in the real world should be straigt in the image."
)
else:
logger.info(
"No camera intrinsics calibration is currently set for this camera!"
)
self._draw_circle_filled = draw_circle_filled_func_builder()
def init_ui(self):
self.add_menu()
self.menu.label = "Camera Intrinsics Estimation"
def get_monitors_idx_list():
monitors = [glfw.get_monitor_name(m) for m in glfw.get_monitors()]
return range(len(monitors)), monitors
if self.monitor_idx not in get_monitors_idx_list()[0]:
logger.warning(
f"Monitor at index {self.monitor_idx} no longer availalbe. "
"Using default instead."
)
self.monitor_idx = 0
self.menu.append(
ui.Info_Text(
"Estimate Camera intrinsics of the world camera. Using an 11x9 asymmetrical circle grid. Click 'i' to capture a pattern."
)
)
self.menu.append(ui.Button("show Pattern", self.open_window))
self.menu.append(
# TODO: potential race condition through selection_getter. Should ensure
# that current selection will always be present in the list returned by the
# selection_getter. Highly unlikely though as this needs to happen between
# having clicked the Selector and the next redraw.
# See https://github.com/pupil-labs/pyglui/pull/112/commits/587818e9556f14bfedd8ff8d093107358745c29b
ui.Selector(
"monitor_idx",
self,
selection_getter=get_monitors_idx_list,
label="Monitor",
)
)
dist_modes = ["Fisheye", "Radial"]
self.menu.append(
ui.Selector(
"dist_mode", self, selection=dist_modes, label="Distortion Model"
)
)
self.menu.append(ui.Switch("fullscreen", self, label="Use Fullscreen"))
self.show_undistortion_switch = ui.Switch(
"show_undistortion", self, label="show undistorted image"
)
self.menu.append(self.show_undistortion_switch)
self.show_undistortion_switch.read_only = not (
hasattr(self.g_pool.capture, "intrinsics")
and self.g_pool.capture.intrinsics
)
self.button = ui.Thumb(
"collect_new",
self,
setter=self.advance,
label="I",
hotkey=Hotkey.CAMERA_INTRINSIC_ESTIMATOR_COLLECT_NEW_CAPTURE_HOTKEY(),
)
self.button.on_color[:] = (0.3, 0.2, 1.0, 0.9)
self.g_pool.quickbar.insert(0, self.button)
def deinit_ui(self):
self.remove_menu()
if self.button:
self.g_pool.quickbar.remove(self.button)
self.button = None
def do_open(self):
if not self._window:
self.window_should_open = True
def get_count(self):
return self.count
def advance(self, _):
if self.count == 10:
logger.info("Capture 10 calibration patterns.")
self.button.status_text = f"{self.count:d} to go"
self.calculated = False
self.img_points = []
self.obj_points = []
self.collect_new = True
def open_window(self):
if not self._window:
if self.fullscreen:
try:
monitor = glfw.get_monitors()[self.monitor_idx]
except Exception:
logger.warning(
"Monitor at index %s no longer availalbe using default" % idx
)
self.monitor_idx = 0
monitor = glfw.get_monitors()[self.monitor_idx]
mode = glfw.get_video_mode(monitor)
height, width = mode.size.height, mode.size.width
else:
monitor = None
height, width = 640, 480
self._window = glfw.create_window(
height,
width,
"Calibration",
monitor,
glfw.get_current_context(),
)
if not self.fullscreen:
# move to y = 31 for windows os
glfw.set_window_pos(self._window, 200, 31)
# Register callbacks
glfw.set_framebuffer_size_callback(self._window, on_resize)
glfw.set_key_callback(self._window, self.on_window_key)
glfw.set_window_close_callback(self._window, self.on_close)
glfw.set_mouse_button_callback(self._window, self.on_window_mouse_button)
on_resize(self._window, *glfw.get_framebuffer_size(self._window))
# gl_state settings
active_window = glfw.get_current_context()
glfw.make_context_current(self._window)
basic_gl_setup()
glfw.make_context_current(active_window)
self.clicks_to_close = 5
def on_window_key(self, window, key, scancode, action, mods):
if action == glfw.PRESS:
if key == glfw.KEY_ESCAPE:
self.on_close()
def on_window_mouse_button(self, window, button, action, mods):
if action == glfw.PRESS:
self.clicks_to_close -= 1
if self.clicks_to_close == 0:
self.on_close()
def on_close(self, window=None):
self.window_should_close = True
def close_window(self):
self.window_should_close = False
if self._window:
glfw.destroy_window(self._window)
self._window = None
def calculate(self):
self.calculated = True
self.count = 10
img_shape = self.g_pool.capture.frame_size
# Compute calibration
try:
if self.dist_mode == "Fisheye":
calibration_flags = (
cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC
+ cv2.fisheye.CALIB_CHECK_COND
+ cv2.fisheye.CALIB_FIX_SKEW
)
max_iter = 30
eps = 1e-6
camera_matrix = np.zeros((3, 3))
dist_coefs = np.zeros((4, 1))
rvecs = [
np.zeros((1, 1, 3), dtype=np.float64) for i in range(self.count)
]
tvecs = [
np.zeros((1, 1, 3), dtype=np.float64) for i in range(self.count)
]
objPoints = [x.reshape(1, -1, 3) for x in self.obj_points]
imgPoints = self.img_points
rms, _, _, _, _ = cv2.fisheye.calibrate(
objPoints,
imgPoints,
img_shape,
camera_matrix,
dist_coefs,
rvecs,
tvecs,
calibration_flags,
(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, max_iter, eps),
)
camera_model = Fisheye_Dist_Camera(
self.g_pool.capture.name, img_shape, camera_matrix, dist_coefs
)
elif self.dist_mode == "Radial":
rms, camera_matrix, dist_coefs, rvecs, tvecs = cv2.calibrateCamera(
np.array(self.obj_points),
np.array(self.img_points),
self.g_pool.capture.frame_size,
None,
None,
)
camera_model = Radial_Dist_Camera(
self.g_pool.capture.name, img_shape, camera_matrix, dist_coefs
)
else:
raise ValueError(f"Unkown distortion model: {self.dist_mode}")
except ValueError as e:
raise e
except Exception as e:
logger.warning("Camera calibration failed to converge!")
logger.warning(
"Please try again with a better coverage of the cameras FOV!"
)
return
logger.info(f"Calibrated Camera, RMS:{rms}")
camera_model.save(self.g_pool.user_dir)
self.g_pool.capture.intrinsics = camera_model
self.show_undistortion_switch.read_only = False
def recent_events(self, events):
frame = events.get("frame")
if not frame:
return
if self.collect_new:
img = frame.img
try:
status, grid_points = cv2.findCirclesGrid(
img, (4, 11), flags=cv2.CALIB_CB_ASYMMETRIC_GRID
)
except cv2.error:
logger.exception(
f"Exception in cv2.findCirclesGrid() using shape={img.shape!r} "
f"dtype={img.dtype!r}"
)
return
if status:
self.img_points.append(grid_points)
self.obj_points.append(self.obj_grid)
self.collect_new = False
self.count -= 1
self.button.status_text = f"{self.count:d} to go"
if self.count <= 0 and not self.calculated:
self.calculate()
self.button.status_text = ""
if self.window_should_close:
self.close_window()
if self.show_undistortion:
assert self.g_pool.capture.intrinsics
# This function is not yet compatible with the fisheye camera model and would have to be manually implemented.
# adjusted_k,roi = cv2.getOptimalNewCameraMatrix(cameraMatrix= np.array(self.camera_intrinsics[0]), distCoeffs=np.array(self.camera_intrinsics[1]), imageSize=self.camera_intrinsics[2], alpha=0.5,newImgSize=self.camera_intrinsics[2],centerPrincipalPoint=1)
self.undist_img = self.g_pool.capture.intrinsics.undistort(frame.img)
def gl_display(self):
for grid_points in self.img_points:
# we dont need that extra encapsulation that opencv likes so much
calib_bounds = cv2.convexHull(grid_points)[:, 0]
draw_polyline(
calib_bounds, 1, RGBA(0.0, 0.0, 1.0, 0.5), line_type=gl.GL_LINE_LOOP
)
if self._window:
self.gl_display_in_window()
if self.show_undistortion and self.undist_img is not None:
gl.glPushMatrix()
make_coord_system_norm_based()
draw_gl_texture(self.undist_img)
gl.glPopMatrix()
def gl_display_in_window(self):
active_window = glfw.get_current_context()
glfw.make_context_current(self._window)
clear_gl_screen()
gl.glMatrixMode(gl.GL_PROJECTION)
gl.glLoadIdentity()
p_window_size = glfw.get_window_size(self._window)
r = p_window_size[0] / 15.0
# compensate for radius of marker
gl.glOrtho(-r, p_window_size[0] + r, p_window_size[1] + r, -r, -1, 1)
# Switch back to Model View Matrix
gl.glMatrixMode(gl.GL_MODELVIEW)
gl.glLoadIdentity()
# hacky way of scaling and fitting in different window rations/sizes
grid = _make_grid() * min((p_window_size[0], p_window_size[1] * 5.5 / 4.0))
# center the pattern
grid -= np.mean(grid)
grid += (p_window_size[0] / 2 - r, p_window_size[1] / 2 + r)
for pt in grid:
self._draw_circle_filled(
tuple(pt),
size=r / 2,
color=RGBA(0.0, 0.0, 0.0, 1),
)
if self.clicks_to_close < 5:
self.glfont.set_size(int(p_window_size[0] / 30.0))
self.glfont.draw_text(
p_window_size[0] / 2.0,
p_window_size[1] / 4.0,
f"Touch {self.clicks_to_close} more times to close window.",
)
glfw.swap_buffers(self._window)
glfw.make_context_current(active_window)
def get_init_dict(self):
return {"monitor_idx": self.monitor_idx}
def cleanup(self):
"""gets called when the plugin get terminated.
This happens either voluntarily or forced.
if you have a gui or glfw window destroy it here.
"""
if self._window:
self.close_window()
def _gen_pattern_grid(size=(4, 11)):
pattern_grid = []
for i in range(size[1]):
for j in range(size[0]):
pattern_grid.append([(2 * j) + i % 2, i, 0])
return np.asarray(pattern_grid, dtype="f4")
def _make_grid(dim=(11, 4)):
"""
this function generates the structure for an asymmetrical circle grid
domain (0-1)
"""
x, y = range(dim[0]), range(dim[1])
p = np.array([[[s, i] for s in x] for i in y], dtype=np.float32)
p[:, 1::2, 1] += 0.5
p = np.reshape(p, (-1, 2), "F")
# scale height = 1
x_scale = 1.0 / (np.amax(p[:, 0]) - np.amin(p[:, 0]))
y_scale = 1.0 / (np.amax(p[:, 1]) - np.amin(p[:, 1]))
p *= x_scale, x_scale / 0.5
return p