from typing import Collection, List, Tuple
import cv2
import numpy as np
from pydantic import Field, validator
from iris.io.class_configs import Algorithm
from iris.io.dataclasses import EyeOrientation, GeometryPolygons, IRImage, NoiseMask, NormalizedIris
from iris.io.errors import NormalizationError
from iris.nodes.normalization.common import (
correct_orientation,
generate_iris_mask,
get_pixel_or_default,
interpolate_pixel_intensity,
to_uint8,
)
[docs]class PerspectiveNormalization(Algorithm):
"""Implementation of a normalization algorithm which uses perspective transformation to map image pixels.
Algorithm steps:
1) Create a grid of trapezoids around iris in original image based on following algorithm parameters: res_in_phi, res_in_r, intermediate_radiuses.
2) Create a grid of corresponding to each trapezoid rectangles in normalized image.
3) For each corresponding trapezoid, rectangle pair compute perspective matrix to estimate normalized image pixel location in an original image location.
4) Map each normalized image pixel to original image pixel based on estimated perspective matrix and perform bilinear interpolation if necessary.
"""
[docs] class Parameters(Algorithm.Parameters):
"""Parameters class for PerspectiveNormalization."""
res_in_phi: int = Field(..., gt=0)
res_in_r: int = Field(..., gt=0)
skip_boundary_points: int = Field(..., gt=0)
intermediate_radiuses: Collection[float]
oversat_threshold: int = Field(..., gt=0)
__parameters_type__ = Parameters
def __init__(
self,
res_in_phi: int = 1024,
res_in_r: int = 128,
skip_boundary_points: int = 10,
intermediate_radiuses: Collection[float] = np.linspace(0.0, 1.0, 10),
oversat_threshold: int = 254,
) -> None:
"""Assign parameters.
Args:
res_in_phi (int): Normalized image phi resolution. Defaults to 1024.
res_in_r (int): Normalized image r resolution. Defaults to 128.
skip_boundary_points (int, optional): Take every nth point from estimated boundaries when generating correspondences.
Defaults to 10.
intermediate_radiuses (t.Iterable[float], optional): Intermediate rings radiuses used to generate additional points for estimating transformations.
Defaults to np.linspace(0.0, 1.0, 10).
oversat_threshold (int, optional): threshold for masking over-satuated pixels. Defaults to 254.
"""
super().__init__(
res_in_phi=res_in_phi,
res_in_r=res_in_r,
skip_boundary_points=skip_boundary_points,
intermediate_radiuses=intermediate_radiuses,
oversat_threshold=oversat_threshold,
)
[docs] def run(
self,
image: IRImage,
noise_mask: NoiseMask,
extrapolated_contours: GeometryPolygons,
eye_orientation: EyeOrientation,
) -> NormalizedIris:
"""Normalize iris using perspective transformation estimated for every region of an image separately.
Args:
image (IRImage): Input image to normalize.
noise_mask (NoiseMask): Noise mask.
extrapolated_contours (GeometryPolygons): Extrapolated contours.
eye_orientation (EyeOrientation): Eye orientation angle.
Returns:
NormalizedIris: NormalizedIris object containing normalized image and iris mask.
Raises:
NormalizationError: Raised if amount of iris and pupil points is different.
"""
if len(extrapolated_contours.pupil_array) != len(extrapolated_contours.iris_array):
raise NormalizationError("Extrapolated amount of iris and pupil points must be the same.")
pupil_points, iris_points = correct_orientation(
extrapolated_contours.pupil_array,
extrapolated_contours.iris_array,
eye_orientation.angle,
)
iris_mask = generate_iris_mask(extrapolated_contours, noise_mask.mask)
iris_mask[image.img_data >= self.params.oversat_threshold] = False
src_points, dst_points = self._generate_correspondences(pupil_points, iris_points)
normalized_iris = NormalizedIris(
normalized_image=np.zeros((self.params.res_in_r, self.params.res_in_phi), dtype=np.uint8),
normalized_mask=np.zeros((self.params.res_in_r, self.params.res_in_phi), dtype=bool),
)
self._run_core(image, iris_mask, src_points, dst_points, normalized_iris)
return normalized_iris
def _run_core(
self,
image: IRImage,
iris_mask: np.ndarray,
src_points: np.ndarray,
dst_points: np.ndarray,
normalized_iris: NormalizedIris,
) -> None:
for angle_point_idx in range(src_points.shape[1] - 1):
for ring_idx in range(src_points.shape[0] - 1):
current_src, current_dst = self._correspondence_rois_coords(
angle_idx=angle_point_idx,
ring_idx=ring_idx,
src_points=src_points,
dst_points=dst_points,
)
xmin, ymin, xmax, ymax = self._bbox_coords(current_dst)
normalized_image_roi, normalized_mask_roi = self._normalize_roi(
original_image=image.img_data,
iris_mask=iris_mask,
src_points=current_src.astype(np.float32),
dst_points=current_dst.astype(np.float32),
normalize_roi_output_shape=(ymax - ymin, xmax - xmin),
)
normalized_iris.normalized_image[ymin:ymax, xmin:xmax] = to_uint8(normalized_image_roi)
normalized_iris.normalized_mask[ymin:ymax, xmin:xmax] = normalized_mask_roi
def _generate_correspondences(
self, pupil_points: np.ndarray, iris_points: np.ndarray
) -> Tuple[np.ndarray, np.ndarray]:
"""Generate correspondences between points in original image and normalized image.
Args:
pupil_points (np.ndarray): Pupil bounding points. NumPy array of shape (num_points = 360, xy_coords = 2).
iris_points (np.ndarray): Iris bounding points. NumPy array of shape (num_points = 360, xy_coords = 2).
Returns:
Tuple[np.ndarray, np.ndarray]: Tuple with generated correspondences.
"""
pupil_points = pupil_points[:: self.params.skip_boundary_points]
iris_points = iris_points[:: self.params.skip_boundary_points]
src_points = []
for radius in self.params.intermediate_radiuses:
ring = pupil_points + radius * (iris_points - pupil_points)
ring = np.vstack([ring, ring[0]])
src_points.append(ring)
src_points = np.array(src_points)
num_rings, num_ring_points = src_points.shape[:2]
dst_xs, dst_ys = np.meshgrid(
np.linspace(0, self.params.res_in_phi, num_ring_points).astype(int),
np.linspace(0, self.params.res_in_r, num_rings).astype(int),
)
dst_points = np.array([dst_xs, dst_ys]).transpose((1, 2, 0))
return src_points, dst_points
def _normalize_roi(
self,
original_image: np.ndarray,
iris_mask: np.ndarray,
src_points: np.ndarray,
dst_points: np.ndarray,
normalize_roi_output_shape: Tuple[float, float],
) -> Tuple[np.ndarray, np.ndarray]:
"""Normalize a single ROI of an image.
Args:
original_image (np.ndarray): Entire input image to normalize.
iris_mask (np.ndarray): Iris class segmentation mask.
src_points (np.ndarray): ROI's original input image points.
dst_points (np.ndarray): ROI's normalized image points.
normalize_roi_output_shape (t.Tuple[float, float]): Output shape of normalized ROI.
Returns:
Tuple[np.ndarray, np.ndarray]: Tuple with normalized image and mask ROIs.
"""
xmin, ymin, xmax, ymax = self._bbox_coords(dst_points)
normalize_image_xs = np.arange(xmin, xmax)
normalize_image_ys = np.arange(ymin, ymax)
normalize_image_points = np.meshgrid(normalize_image_xs, normalize_image_ys)
normalize_image_points = self.cartesian2homogeneous(normalize_image_points)
perspective_mat = cv2.getPerspectiveTransform(dst_points, src_points)
mapped_points = np.matmul(perspective_mat, normalize_image_points)
mapped_points = self.homogeneous2cartesian(mapped_points)
normalized_image_roi = np.zeros(normalize_roi_output_shape, dtype=np.float32)
normalized_mask_roi = np.zeros(normalize_roi_output_shape, dtype=bool)
for image_xy, normalized_xy in zip(mapped_points.T, normalize_image_points.T[..., :2]):
norm_x, norm_y = normalized_xy.astype(int)
shifted_y, shifted_x = norm_y - ymin, norm_x - xmin
normalized_image_roi[shifted_y, shifted_x] = interpolate_pixel_intensity(
original_image, pixel_coords=image_xy
)
normalized_mask_roi[shifted_y, shifted_x] = get_pixel_or_default(
iris_mask, image_xy[0], image_xy[1], default=False
)
return normalized_image_roi / 255.0, normalized_mask_roi
def _bbox_coords(self, norm_dst_points: np.ndarray) -> Tuple[int, int, int, int]:
"""Extract the bounding box of currently processed normalized image ROI.
Args:
norm_dst_points (np.ndarray): Normalized image ROI coordinates.
Returns:
Tuple[int, int, int, int]: Bounding box coordinates in form (xmin, ymin, xmax, ymax).
"""
xmin, ymin = norm_dst_points[0].astype(int)
xmax, ymax = norm_dst_points[-1].astype(int)
return (xmin, ymin, xmax, ymax)
def _correspondence_rois_coords(
self,
angle_idx: int,
ring_idx: int,
src_points: np.ndarray,
dst_points: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray]:
"""Generate a single correspondence ROIs between original image and normalized one based on angle index and ring index.
Args:
angle_idx (int): Boundary point angle index.
ring_idx (int): Intermediate ring index.
src_points (np.ndarray): All mapping points from an original image.
NumPy array of shape (
num_intermediate_rings = self.intermediate_radiuses,
num_boundary_points = 360 // self.skip_boundary_points,
xy_coords = 2
).
dst_points (np.ndarray): All mapping points from an normalized image.
NumPy array of shape (
num_intermediate_rings = self.intermediate_radiuses,
num_boundary_points = 360 // self.skip_boundary_points,
xy_coords = 2
).
Returns:
Tuple[np.ndarray, np.ndarray]: Tuple with extracted from src_points and dst_points ROIs.
"""
src_roi = src_points[ring_idx : ring_idx + 2, angle_idx : angle_idx + 2]
dst_roi = dst_points[ring_idx : ring_idx + 2, angle_idx : angle_idx + 2]
return src_roi.reshape(4, 2), dst_roi.reshape(4, 2)
[docs] @staticmethod
def cartesian2homogeneous(points: List[np.ndarray]) -> np.ndarray:
"""Convert points in cartesian coordinates to homogeneous coordinates.
Args:
points (List[np.ndarray]): Points in cartesian coordinates. Array should be in format: [[x values], [y values]].
Returns:
np.ndarray: Points in homogeneous coordinates. Returned array will have format: [[x values], [y values], [1 ... 1]].
"""
x_coords, y_coords = points
x_coords = x_coords.reshape(-1, 1)
y_coords = y_coords.reshape(-1, 1)
homogeneous_coords = np.hstack([x_coords, y_coords, np.ones((len(x_coords), 1))])
return homogeneous_coords.T
[docs] @staticmethod
def homogeneous2cartesian(points: np.ndarray) -> np.ndarray:
"""Convert points in homogeneous coordinates to cartesian coordinates.
Args:
points (np.ndarray): Points in homogeneous coordinates. Array should be in format: [[x values], [y values], [perspective scale values]].
Returns:
np.ndarray: Points in cartesian coordinates. Returned array will have format: [[x values], [y values]].
"""
points /= points[-1]
points = points[:2]
return points