blob: 6d9ba45753c477939adcb17d95a8f74272b112e7 [file]
# Copyright 2024 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utility functions to detect a set of patterns in the chart image."""
import enum
import logging
import os
import cv2
import image_processing_utils
import numpy as np
_PATTERN_FILE_PATHS = (
'qr_code.png',
)
_FLANN_INDEX_KDTREE = 1
_FLANN_COUNT_KDTREE = 10
# We only need 4 feature matches to compute a homography. However, use a larger
# number to avoid calculating unstable homographies.
_MIN_FEATURE_MATCHES_BEFORE_RANSAC = 35
_MIN_FEATURE_MATCHES_AFTER_RANSAC = 25
# This multiplier causes an image with max dimension of 1280 pixels to use a
# reprojection threshold of 5 pixels.
_REPROJECTION_THRESHOLD_MULTIPLIER = 5.0 / 1280
_MIN_REPROJECTION_THRESHOLD = 5.0
# These thresholds are customized for SIFT descriptors.
_DISTANCE_THRESHOLD = 300
_RATIO_THRESHOLD = 0.85
_TEST_IMG_DIR = os.path.join(os.environ['CAMERA_ITS_TOP'], 'test_images')
_TEST_CHART_FILE_PATH = os.path.join(_TEST_IMG_DIR, 'ip-test-chart-gen2.jpg')
_PRECALCULATED_QR_CODE_TO_TEST_CHART_HOMOGRAPHY = np.array([
[6.779971102933098, 0.013987338118243198, 3607.3750756216737],
[-0.020449324384403368, 6.811438682306467, 4868.505503109079],
[-0.0000028107426441194394, 0.0000028798060528285107, 1.0],
])
_MAX_INTENSITY = 255
class _TestChartConstants:
"""Constants related to the test chart original image file at `_TEST_CHART_FILE_PATH`.
"""
FULL_CHART_WIDTH = 9600
FULL_CHART_HEIGHT = 12000
class CenterQrCode:
SIDE_LENGTH = 2308
TOP_LEFT_CORNER = (3645, 4824)
class DynamicRangePatches:
PATCH_SIDE_LENGTH = 600 # length of each side of a single patch
TOP_LEFT_CORNER = (3292, 3878)
# Each group refers to a row or column of adjacent patches in test chart.
PATCHES_PER_GROUP = 5
# Since color checker cells don't have a consistent size and gap (slight
# differences between each two cells), we use an approximate middle point to
# find the cell positions more accurately by comparing the color of the middle
# point in all four directions. See _find_color_checker_cell(middle_point,
# test_chart_image) for more details.
class ColorCheckerCells:
ROW1_LEFTMOST_CELL_MIDDLE_POINT = (3560, 8512)
CELL_SIDE_LENGTH = 440 # can be 441 in some cells
GAP_OFFSET = 60 # can be in a range of 51-70
ROW_COUNT = 4
COLUMN_COUNT = 6
@enum.unique
class TestChartFeature(enum.Enum):
FULL_CHART = 'full_chart'
CENTER_QR_CODE = 'center_qr_code'
COLOR_CHECKER_CELLS = 'color_checker_cells'
DYNAMIC_RANGE_PATCHES = 'dynamic_range_patches'
DEAD_LEAF_PATCH = 'dead_leaf_patch'
class _TestChartFeatureUnit(object):
"""Base class for information regarding a single unit of some test chart feature (e.g. a single patch of the dynamic range feature).
Attributes:
feature_type: The type of the feature.
image: Optional BGRA color space image in numpy matrix format.
"""
def __init__(self, feature_type: TestChartFeature, image: np.ndarray = None):
self.feature_type = feature_type
self.image = image
class _PolygonalFeatureUnit(_TestChartFeatureUnit):
"""Base class for information regarding a `_TestChartFeatureUnit` which can be enclosed in a polygon.
For ex: A single square cell of the color checker cells.
Attributes:
feature_type: The type of the feature.
corner_points: Co-ordinates of a polygon that encloses the feature.
image: Optional BGRA color space image in numpy matrix format.
"""
def __init__(
self,
feature_type: TestChartFeature,
corner_points: list[list[int, int]],
image: np.ndarray = None,
):
super().__init__(feature_type=feature_type, image=image)
self.corner_points = corner_points
def __repr__(self):
return 'corner_points: %s' % (str(self.corner_points),)
class _MonoColorPolygonalFeatureUnit(_PolygonalFeatureUnit):
"""A `_PolygonalFeatureUnit` that consists of a single color on test chart.
Attributes:
feature_type: The type of the feature.
corner_points: Co-ordinates of a polygon that encloses the feature.
test_chart_color_bgr: BGR space color value of the feature unit in
reference test chart image.
image: Optional BGRA color space image in numpy matrix format.
"""
def __init__(
self,
feature_type: TestChartFeature,
corner_points: list[list[int, int]],
test_chart_color_bgr: np.ndarray,
image: np.ndarray = None,
):
super().__init__(
feature_type=feature_type, corner_points=corner_points, image=image
)
self.test_chart_color_bgr = test_chart_color_bgr
class CenterQrCode(_PolygonalFeatureUnit):
"""A `_PolygonalFeatureUnit` containing information regarding center QR code test chart feature.
Attributes:
corner_points: Co-ordinates of the four corner points.
image: Optional BGRA color space image in numpy matrix format.
"""
def __init__(
self,
corner_points: list[
list[int, int], list[int, int], list[int, int], list[int, int]
],
image: np.ndarray = None,
):
super().__init__(
feature_type=TestChartFeature.CENTER_QR_CODE,
corner_points=corner_points,
image=image
)
def __repr__(self):
return 'feature_type: %s, corner_points: %s' % (
str(self.feature_type),
str(self.corner_points),
)
class DynamicRangePatch(_MonoColorPolygonalFeatureUnit):
"""A `_MonoColorPolygonalFeatureUnit` containing information regarding a single cell of the dynamic range patch.
Attributes:
corner_points: Co-ordinates of the four corner points in a patch.
test_chart_color_bgr: BGR space color value of the equivalent patch in
reference test chart image.
image: Optional BGRA color space image in numpy matrix format.
"""
def __init__(
self,
corner_points: list[
list[int, int], list[int, int], list[int, int], list[int, int]
],
test_chart_color_bgr: np.ndarray,
image: np.ndarray = None,
):
super().__init__(
feature_type=TestChartFeature.DYNAMIC_RANGE_PATCHES,
corner_points=corner_points,
test_chart_color_bgr=test_chart_color_bgr,
image=image,
)
def __repr__(self):
return 'feature_type: %s, corner_points: %s, test_chart_color_bgr: %s' % (
str(self.feature_type),
str(self.corner_points),
str(self.test_chart_color_bgr),
)
class ColorCheckerCell(_MonoColorPolygonalFeatureUnit):
"""Information regarding a single square cell in the color checker test chart feature.
Attributes:
corner_points: Co-ordinates of the four corner points in a patch.
test_chart_color_bgr: BGR space color value of the equivalent patch in
reference test chart image.
image: Optional BGRA color space image in numpy matrix format.
"""
def __init__(
self,
corner_points: list[
list[int, int], list[int, int], list[int, int], list[int, int]
],
test_chart_color_bgr: np.ndarray,
image: np.ndarray = None,
):
super().__init__(
feature_type=TestChartFeature.COLOR_CHECKER_CELLS,
corner_points=corner_points,
test_chart_color_bgr=test_chart_color_bgr,
image=image,
)
def __repr__(self):
return 'feature_type: %s, corner_points: %s, test_chart_color_bgr: %s' % (
str(self.feature_type),
str(self.corner_points),
str(self.test_chart_color_bgr),
)
def _process_input_image(
image: str | np.ndarray,
) -> (np.ndarray, str):
"""Processes the input image argument and returns numpy array image and original file name (if possible) after verification.
"""
image_path = None
if isinstance(image, str):
if not os.path.exists(image):
logging.debug('Missing file %s', image)
return None
image_path = image
image = cv2.imread(image, cv2.IMREAD_COLOR)
_assert_image_type(image, cv2.IMREAD_COLOR)
return image, image_path
def _assert_image_type(image, colorspace):
if not isinstance(image, np.ndarray):
raise AssertionError('Image must be a NumPy array.')
if colorspace == cv2.IMREAD_COLOR:
if image.ndim != 3:
raise AssertionError('Color image must have 3 dimensions.')
if image.shape[2] != 3:
raise AssertionError('Color image must have 3 channels.')
elif colorspace == cv2.IMREAD_GRAYSCALE:
if image.ndim != 2:
raise AssertionError('Grayscale image must have 2 dimensions.')
def get_transformed_point(
image: str | np.ndarray,
point: tuple[int, int],
transform_matrix: np.ndarray = None,
) -> tuple[int, int]:
"""Gets the transformed position of a test chart point in the provided image.
This method transforms test_chart_gen2.jpg to the provided image and finds the
transformed position of the provided point.
Args:
image: The image which test chart is transformed to. Must be a file path
string or 3 channel BGR color image data in numpy matrix format.
point: Pixel position of a point in test chart
transform_matrix: Optional transformation matrix which is used instead of
the image to transform the provided point. Otherwise,
`find_test_chart_transformation(image)` is used.
Returns:
Pixel position of same point in query image as a tuple of two integers,
or None if a transformation matrix could not be found.
"""
image, _ = _process_input_image(image)
if transform_matrix is None:
transform_matrix = find_test_chart_transformation(image)
if transform_matrix is None:
logging.debug('Center QR code pattern not detected in the image')
return None
transformed_homogenous_point = np.dot(
transform_matrix, [point[0], point[1], 1]
)
transformed_cartesian_points = (
transformed_homogenous_point[:2] / transformed_homogenous_point[2]
)
return (
int(np.round(transformed_cartesian_points[0])),
int(np.round(transformed_cartesian_points[1])),
)
def _get_dynamic_range_patch_top_right_corner(
top_left_corner: tuple[int, int],
) -> tuple[int, int]:
return (
top_left_corner[0]
+ _TestChartConstants.DynamicRangePatches.PATCH_SIDE_LENGTH,
top_left_corner[1],
)
def _get_dynamic_range_patch_bottom_left_corner(
top_left_corner: tuple[int, int],
) -> tuple[int, int]:
return (
top_left_corner[0],
top_left_corner[1]
+ _TestChartConstants.DynamicRangePatches.PATCH_SIDE_LENGTH,
)
def _get_dynamic_range_patch_bottom_right_corner(
top_left_corner: tuple[int, int]
) -> tuple[int, int]:
return (
top_left_corner[0]
+ _TestChartConstants.DynamicRangePatches.PATCH_SIDE_LENGTH,
top_left_corner[1]
+ _TestChartConstants.DynamicRangePatches.PATCH_SIDE_LENGTH,
)
def _create_dynamic_range_patch(
top_left_corner: tuple[int, int], test_chart_image: np.ndarray
) -> DynamicRangePatch:
bottom_right_corner = _get_dynamic_range_patch_bottom_right_corner(
top_left_corner
)
return DynamicRangePatch(
corner_points=[
top_left_corner,
_get_dynamic_range_patch_top_right_corner(top_left_corner),
bottom_right_corner,
_get_dynamic_range_patch_bottom_left_corner(top_left_corner),
],
test_chart_color_bgr=test_chart_image[
int((top_left_corner[1] + bottom_right_corner[1]) / 2),
int((top_left_corner[0] + bottom_right_corner[0]) / 2),
],
)
def _get_test_chart_dynamic_range_patches() -> list[DynamicRangePatch]:
"""Gets the dynamic range patch positions in test chart.
The patches are listed in clockwise order starting from the top-left
one in the test chart.
Returns:
A list of the four corners (in serial) of each patch, or None in case of any
error.
"""
patches = []
side_length = _TestChartConstants.DynamicRangePatches.PATCH_SIDE_LENGTH
# Error if the provided file does not exist.
test_chart_file_path = _TEST_CHART_FILE_PATH
if not os.path.exists(test_chart_file_path):
logging.debug('Missing file %s', test_chart_file_path)
return None
test_chart_image = cv2.imread(test_chart_file_path)
# top row of patches
top_left_corner = _TestChartConstants.DynamicRangePatches.TOP_LEFT_CORNER
for _ in range(_TestChartConstants.DynamicRangePatches.PATCHES_PER_GROUP):
patches.append(
_create_dynamic_range_patch(top_left_corner, test_chart_image)
)
top_left_corner = (top_left_corner[0] + side_length, top_left_corner[1])
# right column of patches
top_left_corner = (
_TestChartConstants.DynamicRangePatches.TOP_LEFT_CORNER[0]
+ _TestChartConstants.DynamicRangePatches.PATCHES_PER_GROUP * side_length,
_TestChartConstants.DynamicRangePatches.TOP_LEFT_CORNER[1] + side_length,
)
for _ in range(_TestChartConstants.DynamicRangePatches.PATCHES_PER_GROUP):
patches.append(
_create_dynamic_range_patch(top_left_corner, test_chart_image)
)
top_left_corner = (top_left_corner[0], top_left_corner[1] + side_length)
# bottom row of patches
top_left_corner = (
_TestChartConstants.DynamicRangePatches.TOP_LEFT_CORNER[0],
_TestChartConstants.DynamicRangePatches.TOP_LEFT_CORNER[1]
+ (_TestChartConstants.DynamicRangePatches.PATCHES_PER_GROUP + 1)
* side_length,
)
rev_patches = []
for _ in range(_TestChartConstants.DynamicRangePatches.PATCHES_PER_GROUP):
rev_patches.insert(
0, _create_dynamic_range_patch(top_left_corner, test_chart_image)
)
top_left_corner = (top_left_corner[0] + side_length, top_left_corner[1])
patches.extend(rev_patches)
# left column of patches
top_left_corner = (
_TestChartConstants.DynamicRangePatches.TOP_LEFT_CORNER[0] - side_length,
_TestChartConstants.DynamicRangePatches.TOP_LEFT_CORNER[1] + side_length,
)
rev_patches = []
for _ in range(_TestChartConstants.DynamicRangePatches.PATCHES_PER_GROUP):
rev_patches.insert(
0,
_create_dynamic_range_patch(top_left_corner, test_chart_image)
)
top_left_corner = (top_left_corner[0], top_left_corner[1] + side_length)
patches.extend(rev_patches)
return patches
def _filter_by_ratio_and_distance_test(keypoints_1, keypoints_2, matches):
"""Filters feature matches with a ratio test and a distance test.
See Lowe's SIFT paper for an explanation of the ratio test. The implementation
here is adapted from third_party/OpenCVX/v3_4_0/samples/python/find_obj.py
Args:
keypoints_1: first set of keypoints
keypoints_2: second set of keypoints
matches: matches between the two sets of keypoints
Returns:
filtered first set of keypoints
filtered second key of keypoints
filtered matching keypoint pairs
"""
mkeypoints_1, mkeypoints_2 = [], []
for match in matches:
if len(match) != 2:
continue
if match[0].distance >= _DISTANCE_THRESHOLD:
continue
if match[0].distance >= match[1].distance * _RATIO_THRESHOLD:
continue
match = match[0]
mkeypoints_1.append(keypoints_1[match.queryIdx])
mkeypoints_2.append(keypoints_2[match.trainIdx])
filtered_keypoints1 = np.float32([kp.pt for kp in mkeypoints_1])
filtered_keypoints2 = np.float32([kp.pt for kp in mkeypoints_2])
filtered_pairs = list(zip(mkeypoints_1, mkeypoints_2))
return filtered_keypoints1, filtered_keypoints2, list(filtered_pairs)
def detect_pattern(query_image: str | np.ndarray):
"""Detects patterns in a query image.
Searches for a fixed set of patterns in the query image. If one of the
patterns is found in the query image, a homography between the query image
and the matching pattern image is returned.
Args:
query_image: Query image data, either in the format of a string representing
the path to the query image file or 3 channel BGR color image data in
numpy matrix format (same return format as OpenCV `imread(filename)`
invocation with default parameters).
Returns:
(pattern image path, homography as 2-d numpy array, horizontal flip status)
if the query image matched a pattern image, or None otherwise
"""
# Initialize SIFT detector and FLANN matcher.
detector = cv2.SIFT_create(enable_precise_upscale=True)
flann_params = dict(algorithm=_FLANN_INDEX_KDTREE, trees=_FLANN_COUNT_KDTREE)
matcher = cv2.FlannBasedMatcher(flann_params, {})
# Load query image and extract features.
if isinstance(query_image, str):
logging.debug('Query image: %s', query_image)
query_image = cv2.imread(query_image)
_assert_image_type(query_image, cv2.IMREAD_COLOR)
query_points, query_descriptors = detector.detectAndCompute(query_image, None)
logging.debug('%d features in query image', len(query_points))
if len(query_points) < _MIN_FEATURE_MATCHES_BEFORE_RANSAC:
logging.debug('Not enough query features.')
return None
for pattern_file_path in _PATTERN_FILE_PATHS:
# Load pattern image.
path = os.path.join(image_processing_utils.TEST_IMG_DIR, pattern_file_path)
pattern_image = cv2.imread(path)
logging.debug('Pattern image: %s', pattern_file_path)
# Sometimes, the pattern is flipped horizontally in the query image. SIFT
# features are not flip-invariant. So, try the original pattern image and
# a horizontally flipped version, and use the version which achieves a
# larger number of feature matches against the query image.
max_filtered_match_count = 0
for flip_horizontal in [False, True]:
logging.debug('Flip horizontal: %d', flip_horizontal)
if flip_horizontal:
transformed_pattern_image = cv2.flip(pattern_image, 1)
else:
transformed_pattern_image = pattern_image
pattern_points, pattern_descriptors = detector.detectAndCompute(
transformed_pattern_image, None)
logging.debug('%d features in pattern image', len(pattern_points))
# Match features.
raw_matches = matcher.knnMatch(
pattern_descriptors, trainDescriptors=query_descriptors, k=2)
filtered_pattern_points, filtered_query_points, _ = (
_filter_by_ratio_and_distance_test(pattern_points, query_points,
raw_matches))
filtered_match_count = len(filtered_query_points)
logging.debug('%d features matches after ratio and distance tests',
filtered_match_count)
if filtered_match_count >= max_filtered_match_count:
max_filtered_match_count = filtered_match_count
max_filtered_pattern_points = filtered_pattern_points
max_filtered_query_points = filtered_query_points
max_filtered_flip_status = flip_horizontal
if max_filtered_match_count < _MIN_FEATURE_MATCHES_BEFORE_RANSAC:
logging.debug('Not enough feature matches before RANSAC.')
continue
# Adjust reprojection threshold based on the size of the query image.
query_height, query_width = query_image.shape[:2]
query_max_dimension = max(query_height, query_width)
reprojection_threshold = (
query_max_dimension * _REPROJECTION_THRESHOLD_MULTIPLIER)
reprojection_threshold = max(_MIN_REPROJECTION_THRESHOLD,
reprojection_threshold)
# Compute homography.
logging.debug('RANSAC reprojection threshold: %f', reprojection_threshold)
homography, status = cv2.findHomography(max_filtered_pattern_points,
max_filtered_query_points,
cv2.RANSAC, reprojection_threshold)
ransac_match_count = np.sum(np.ndarray.flatten(status))
logging.debug('%d feature matches after RANSAC', ransac_match_count)
if ransac_match_count < _MIN_FEATURE_MATCHES_AFTER_RANSAC:
logging.debug('Not enough feature matches after RANSAC.')
continue
logging.debug('Homography:')
logging.debug('%.3f, %.3f, %.3f', homography[0][0], homography[0][1],
homography[0][2])
logging.debug('%.3f, %.3f, %.3f', homography[1][0], homography[1][1],
homography[1][2])
logging.debug('%.3f, %.3f, %.3f', homography[2][0], homography[2][1],
homography[2][2])
return (pattern_file_path, homography, max_filtered_flip_status)
logging.debug('Query image did not match any patterns.')
return None
def find_center_qr_code_homography(
dst_image: str | np.ndarray,
can_return_precalculated_value: bool = True,
) -> (np.ndarray, bool):
"""Finds the homography matrix transforming the center QR code test chart feature to the provided image.
Args:
dst_image: The image which the center QR code is transformed to. Must be a
file path string or 3 channel BGR color image data in numpy matrix format.
can_return_precalculated_value: Whether precalculated value can be returned
if possible, or need to recalculate again. This is used only when
`dst_image` is a `str` type (i.e. the path of image is provided).
Returns:
A tuple of homography transformation matrix as 2-d numpy array if the query
image matched a QR code pattern and whether horizontal flip was required for
the matching. `None` is provided in case of no matching.
"""
if isinstance(dst_image, str):
if not os.path.exists(dst_image):
logging.debug('Missing file %s', dst_image)
return None
if (
can_return_precalculated_value
and dst_image == _TEST_CHART_FILE_PATH
):
# Use previously calculated homography matrix to save time, must be
# updated each time the qr_code.png or test_chart_gen2.jpg file is changed
return (_PRECALCULATED_QR_CODE_TO_TEST_CHART_HOMOGRAPHY, False)
dst_image = cv2.imread(dst_image, cv2.IMREAD_COLOR)
_assert_image_type(dst_image, cv2.IMREAD_COLOR)
_, homography, flip_status = detect_pattern(dst_image)
return (homography, flip_status)
def _get_test_chart_horizontal_mirror_transformation() -> np.ndarray:
"""Gets the horizontal mirror transformation matrix for test chart image.
Returns:
The transformation matrix.
"""
chart_width = _TestChartConstants.FULL_CHART_WIDTH
chart_height = _TestChartConstants.FULL_CHART_HEIGHT
src = np.array(
[
[0, 0],
[chart_width - 1, 0],
[chart_width - 1, chart_height - 1],
[0, chart_height - 1],
],
dtype='float32',
)
dst = np.array(
[
[chart_width - 1, 0],
[0, 0],
[0, chart_height - 1],
[chart_width - 1, chart_height - 1],
],
dtype='float32',
)
return cv2.getPerspectiveTransform(src, dst)
def find_test_chart_transformation(
dst_image: str | np.ndarray,
) -> np.ndarray:
"""Finds the `TestChartTransformation` that maps test_chart_gen2.jpg positions to the provided destination image.
The center QR code is used as reference to find the mapping between test chart
and query image. If the QR code can not be found either image, this method
will return None.
Args:
dst_image: The image which test chart is transformed to. Must be a file path
string or 3 channel BGR color image data in numpy matrix format.
Returns:
Homography transformation matrix as 2-d numpy array if the query image
matched a QR code pattern, or None otherwise
"""
dst_image, _ = _process_input_image(dst_image)
# Find homography matrix mapping center QR code to dst_image
dst_image_transform_info = find_center_qr_code_homography(dst_image)
# Error if no QR code pattern is detected.
if dst_image_transform_info is None:
logging.debug('No pattern detected for %s', dst_image)
return None
qr_to_dst_homography, is_horizontally_flipped = dst_image_transform_info
# Find homography matrix mapping center QR code to test chart
qr_to_test_chart_homography, _ = find_center_qr_code_homography(
_TEST_CHART_FILE_PATH,
)
# Error if no QR code pattern is detected.
if qr_to_test_chart_homography is None:
logging.debug('No pattern detected for %s', _TEST_CHART_FILE_PATH)
return None
# Combine the homography matrices to get a final matrix transforming whole
# test chart image to dst_image
test_chart_to_qr_homography = np.linalg.inv(qr_to_test_chart_homography)
if is_horizontally_flipped:
# Since dst_image is horizontally flipped, test chart also needs to be
# flipped first.
test_chart_to_qr_homography = (
test_chart_to_qr_homography
@ _get_test_chart_horizontal_mirror_transformation()
)
test_chart_to_dst_transform_matrix = (
qr_to_dst_homography @ test_chart_to_qr_homography
)
return test_chart_to_dst_transform_matrix
def _get_test_chart_center_qr_code() -> CenterQrCode:
return CenterQrCode(
corner_points=[
_TestChartConstants.CenterQrCode.TOP_LEFT_CORNER,
(
_TestChartConstants.CenterQrCode.TOP_LEFT_CORNER[0]
+ _TestChartConstants.CenterQrCode.SIDE_LENGTH,
_TestChartConstants.CenterQrCode.TOP_LEFT_CORNER[1],
),
(
_TestChartConstants.CenterQrCode.TOP_LEFT_CORNER[0]
+ _TestChartConstants.CenterQrCode.SIDE_LENGTH,
_TestChartConstants.CenterQrCode.TOP_LEFT_CORNER[1]
+ _TestChartConstants.CenterQrCode.SIDE_LENGTH,
),
(
_TestChartConstants.CenterQrCode.TOP_LEFT_CORNER[0],
_TestChartConstants.CenterQrCode.TOP_LEFT_CORNER[1]
+ _TestChartConstants.CenterQrCode.SIDE_LENGTH,
),
]
)
def _get_test_chart_color_checker_cells() -> list[ColorCheckerCell]:
"""Gets the color cells patch positions in test chart.
The cells are listed in row-major order starting from the top-left one in the
test chart.
Returns:
A list of `ColorCheckerCell` where every cell is a list containing the
four corner positions in query image and the original color in test chart,
or None in case of any error.
"""
cells = []
side_length = _TestChartConstants.ColorCheckerCells.CELL_SIDE_LENGTH
gap_offset = _TestChartConstants.ColorCheckerCells.GAP_OFFSET
total_gap_between_mid_points = side_length + gap_offset
# Error if the provided file does not exist.
test_chart_file_path = _TEST_CHART_FILE_PATH
if not os.path.exists(test_chart_file_path):
logging.debug('Missing file %s', test_chart_file_path)
return None
test_chart_image = cv2.imread(test_chart_file_path)
for row in range(_TestChartConstants.ColorCheckerCells.ROW_COUNT):
for col in range(_TestChartConstants.ColorCheckerCells.COLUMN_COUNT):
middle_point = (
_TestChartConstants.ColorCheckerCells.ROW1_LEFTMOST_CELL_MIDDLE_POINT[
0
]
+ col * total_gap_between_mid_points,
_TestChartConstants.ColorCheckerCells.ROW1_LEFTMOST_CELL_MIDDLE_POINT[
1
]
+ row * total_gap_between_mid_points,
)
cells.append(_find_color_checker_cell(middle_point, test_chart_image))
return cells
def _extract_polygon_from_image(
image: str | np.ndarray,
corner_points: list[list[int, int]]
):
"""Extract out the polygon area from an image.
Args:
image: Must be a file path string or 3 channel BGR color image data in numpy
matrix format.
corner_points: Co-ordinates of a polygon corner points. The polygon
requirements should be the same as that of `cv2.fillPoly`.
Returns:
BGRA color space image of the same size as input image where the polygon
area in input image is kept intact while any pixel not within the polygon
will be fully transparent.
"""
image, _ = _process_input_image(image)
image_mask = np.zeros(image.shape[0:2]).astype(np.uint8)
cv2.fillPoly(
img=image_mask,
pts=[np.array(corner_points).astype(np.int32)],
color=(_MAX_INTENSITY),
)
image_mask = (image_mask != 0).astype(bool)
image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
image[:, :, 3] = np.where(
image_mask, image[:, :, 3], 0
)
return image
def get_test_chart_features_aligned_to_image(
image: str | np.ndarray,
transform_matrix: np.ndarray = None,
test_chart_features: list[TestChartFeature] = None,
) -> np.ndarray:
"""Gets the test chart features aligned to the provided image.
Args:
image: Must be a file path string or 3 channel BGR color image data in numpy
matrix format.
transform_matrix: Used to transform the points in test chart to query image,
`find_transform_matrix_mapping_from_test_chart` method will be used to
calculate it if None provided.
test_chart_features: List of `TestChartFeature`, will be replaced with
[TestChartFeature.FULL_CHART] as default if None provided.
Returns:
BGRA color space image with only the test chart features in query image, any
pixel not within feature will be fully transparent. Returns None in case
of any error.
"""
logging.debug('Finding feature %s', test_chart_features)
image, image_path = _process_input_image(image)
if test_chart_features is None:
test_chart_features = [TestChartFeature.FULL_CHART]
if transform_matrix is None:
transform_matrix = find_test_chart_transformation(image)
if transform_matrix is None:
if image_path is not None:
logging.debug('No pattern detected for %s', image_path)
else:
logging.debug('No pattern detected')
return None
test_chart_image = cv2.imread(
_TEST_CHART_FILE_PATH
)
test_chart_mask = np.zeros(test_chart_image.shape[0:2]).astype(np.uint8)
for feature in test_chart_features:
match feature:
case TestChartFeature.FULL_CHART:
test_chart_mask.fill(_MAX_INTENSITY)
case TestChartFeature.CENTER_QR_CODE:
cv2.fillPoly(
img=test_chart_mask,
pts=[
np.array(_get_test_chart_center_qr_code().corner_points).astype(
np.int32
)
],
color=(_MAX_INTENSITY),
)
test_chart_mask = (test_chart_mask != 0).astype(bool)
test_chart_image = cv2.cvtColor(test_chart_image, cv2.COLOR_BGR2BGRA)
test_chart_image[:, :, 3] = np.where(
test_chart_mask, test_chart_image[:, :, 3], 0
)
aligned_test_chart_image = cv2.warpPerspective(
src=test_chart_image,
M=transform_matrix,
dsize=(image.shape[1], image.shape[0]),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0, 0),
)
return aligned_test_chart_image
def get_test_chart_features_from_image(
image: str | np.ndarray,
transform_matrix: np.ndarray = None,
test_chart_features: list[TestChartFeature] = None,
feature_area_mask: np.ndarray = None,
) -> np.ndarray:
"""Gets test chart features from the provided image.
Args:
image: Must be a file path string or 3 channel BGR color image data in numpy
matrix format.
transform_matrix: Used to transform the points in test chart to query image,
`find_transform_matrix_mapping_from_test_chart` method will be used to
calculate it if None provided.
test_chart_features: List of chart_features. TestChartFeature.FULL_CHART as
default if None provided.
feature_area_mask: Boolean mask of feature area, will be calculated based on
test_chart_features if None.
Returns:
BGRA color space image with only the test chart features in query image, any
pixel not within feature will be fully transparent. Returns None in case
of any error.
"""
image, _ = _process_input_image(image)
if test_chart_features is None:
test_chart_features = [TestChartFeature.FULL_CHART]
if feature_area_mask is None:
aligned_test_chart_image = get_test_chart_features_aligned_to_image(
image, transform_matrix, test_chart_features
)
feature_area_mask = aligned_test_chart_image[:, :, 3] != 0
image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
image[:, :, 3] = np.where(feature_area_mask, image[:, :, 3], 0)
return image
def find_dynamic_range_patches(
image: str | np.ndarray,
transform_matrix: np.ndarray = None,
) -> list[DynamicRangePatch]:
"""Finds the dynamic range patch positions of given image.
The patches are listed in clockwise order starting from the top-left
one in the test chart.
Args:
image: Must be a file path string or 3 channel BGR color image data in numpy
matrix format.
transform_matrix: Used to transform the points in test chart to query image,
`find_transform_matrix_mapping_from_test_chart` method will be used to
calculate it if None provided.
Returns:
A list of `DynamicRangePatch` where every patch is a list containing the
four corner positions in query image and the original color in test chart.
"""
image, image_path = _process_input_image(image)
if transform_matrix is None:
transform_matrix = find_test_chart_transformation(image)
if transform_matrix is None:
if image_path is not None:
logging.debug('No pattern detected for %s', image_path)
else:
logging.debug('No pattern detected')
return None
transformed_dynamic_range_patches = []
for patch in _get_test_chart_dynamic_range_patches():
transformed_points = [
get_transformed_point(image, point, transform_matrix)
for point in patch.corner_points
]
transformed_patch = DynamicRangePatch(
corner_points=transformed_points,
test_chart_color_bgr=patch.test_chart_color_bgr,
image=_extract_polygon_from_image(image, transformed_points)
)
transformed_dynamic_range_patches.append(transformed_patch)
return transformed_dynamic_range_patches
def _find_color_checker_cell(
middle_point: list[int, int],
test_chart_image: np.ndarray
) -> ColorCheckerCell:
"""Finds the `ColorCheckerCell` by calculating all corner points and color.
Args:
middle_point: The middle point of the cell.
test_chart_image: The image where the cell is located.
Returns:
A `ColorCheckerCell` instance containing the four corner positions in test
chart (clockwise order from top-left) and the original color in test chart.
"""
left_x = right_x = middle_point[0]
top_y = bottom_y = middle_point[1]
cell_color = test_chart_image[(middle_point[1], middle_point[0])]
# Find left_x
while np.array_equal(
cell_color, test_chart_image[(middle_point[1], left_x - 1)]
):
left_x -= 1
# Find right_x
while np.array_equal(
cell_color, test_chart_image[(middle_point[1], right_x + 1)]
):
right_x += 1
# Find top_y
while np.array_equal(
cell_color, test_chart_image[(top_y - 1, middle_point[0])]
):
top_y -= 1
# Find bottom_y
while np.array_equal(
cell_color, test_chart_image[(bottom_y + 1, middle_point[0])]
):
bottom_y += 1
return ColorCheckerCell(
corner_points=[
(left_x, top_y),
(right_x, top_y),
(right_x, bottom_y),
(left_x, bottom_y),
],
test_chart_color_bgr=cell_color,
)
def find_color_checker_cells(
image: str | np.ndarray,
transform_matrix: np.ndarray = None,
) -> list[ColorCheckerCell]:
"""Finds the color checker cell positions and related info of given image.
The cells are listed in row-major order starting from the top-left one in the
test chart.
Args:
image: Must be a file path string or 3 channel BGR color image data in numpy
matrix format.
transform_matrix: Used to transform the points in test chart to query image,
`find_transform_matrix_mapping_from_test_chart` method will be used to
calculate it if None provided.
Returns:
A list of `ColorCheckerCell` where every cell is a list containing the
four corner positions in query image and the original color in test chart.
"""
image, image_path = _process_input_image(image)
if transform_matrix is None:
transform_matrix = find_test_chart_transformation(image)
if transform_matrix is None:
if image_path is not None:
logging.debug('No pattern detected for %s', image_path)
else:
logging.debug('No pattern detected')
return None
transformed_color_checker_cells = []
for cell in _get_test_chart_color_checker_cells():
transformed_points = [
get_transformed_point(image, point, transform_matrix)
for point in cell.corner_points
]
transformed_cell = ColorCheckerCell(
corner_points=transformed_points,
test_chart_color_bgr=cell.test_chart_color_bgr,
image=_extract_polygon_from_image(image, transformed_points)
)
transformed_color_checker_cells.append(transformed_cell)
return transformed_color_checker_cells