# Copyright 2016 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. """Image processing utilities using openCV.""" import logging import math import os import unittest import numpy import cv2 import camera_properties_utils import capture_request_utils import image_processing_utils ANGLE_CHECK_TOL = 1 # degrees ANGLE_NUM_MIN = 10 # Minimum number of angles for find_angle() to be valid TEST_IMG_DIR = os.path.join(os.environ['CAMERA_ITS_TOP'], 'test_images') CHART_FILE = os.path.join(TEST_IMG_DIR, 'ISO12233.png') CHART_HEIGHT = 13.5 # cm CHART_DISTANCE_RFOV = 31.0 # cm CHART_DISTANCE_WFOV = 22.0 # cm CHART_SCALE_START = 0.65 CHART_SCALE_STOP = 1.35 CHART_SCALE_STEP = 0.025 CIRCLE_AR_ATOL = 0.1 # circle aspect ratio tolerance CIRCLISH_ATOL = 0.10 # contour area vs ideal circle area & aspect ratio TOL CIRCLISH_LOW_RES_ATOL = 0.15 # loosen for low res images CIRCLE_MIN_PTS = 20 CIRCLE_RADIUS_NUMPTS_THRESH = 2 # contour num_pts/radius: empirically ~3x CV2_RED = (255, 0, 0) # color in cv2 to draw lines FOV_THRESH_TELE25 = 25 FOV_THRESH_TELE40 = 40 FOV_THRESH_TELE = 60 FOV_THRESH_WFOV = 90 LOW_RES_IMG_THRESH = 320 * 240 RGB_GRAY_WEIGHTS = (0.299, 0.587, 0.114) # RGB to Gray conversion matrix SCALE_RFOV_IN_WFOV_BOX = 0.67 SCALE_TELE_IN_WFOV_BOX = 0.5 SCALE_TELE_IN_RFOV_BOX = 0.67 SCALE_TELE40_IN_RFOV_BOX = 0.5 SCALE_TELE25_IN_RFOV_BOX = 0.33 SQUARE_AREA_MIN_REL = 0.05 # Minimum size for square relative to image area SQUARE_TOL = 0.1 # Square W vs H mismatch RTOL VGA_HEIGHT = 480 VGA_WIDTH = 640 def find_all_contours(img): cv2_version = cv2.__version__ if cv2_version.startswith('3.'): # OpenCV 3.x _, contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) else: # OpenCV 2.x and 4.x contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) return contours def calc_chart_scaling(chart_distance, camera_fov): """Returns charts scaling factor. Args: chart_distance: float; distance in cm from camera of displayed chart camera_fov: float; camera field of view. Returns: chart_scaling: float; scaling factor for chart """ chart_scaling = 1.0 camera_fov = float(camera_fov) if (FOV_THRESH_TELE < camera_fov < FOV_THRESH_WFOV and numpy.isclose(chart_distance, CHART_DISTANCE_WFOV, rtol=0.1)): chart_scaling = SCALE_RFOV_IN_WFOV_BOX elif (camera_fov <= FOV_THRESH_TELE and numpy.isclose(chart_distance, CHART_DISTANCE_WFOV, rtol=0.1)): chart_scaling = SCALE_TELE_IN_WFOV_BOX elif (camera_fov <= FOV_THRESH_TELE25 and numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)): chart_scaling = SCALE_TELE25_IN_RFOV_BOX elif (camera_fov <= FOV_THRESH_TELE40 and numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)): chart_scaling = SCALE_TELE40_IN_RFOV_BOX elif (camera_fov <= FOV_THRESH_TELE and numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)): chart_scaling = SCALE_TELE_IN_RFOV_BOX return chart_scaling def scale_img(img, scale=1.0): """Scale image based on a real number scale factor.""" dim = (int(img.shape[1] * scale), int(img.shape[0] * scale)) return cv2.resize(img.copy(), dim, interpolation=cv2.INTER_AREA) def gray_scale_img(img): """Return gray scale version of image.""" if len(img.shape) == 2: img_gray = img.copy() elif len(img.shape) == 3: if img.shape[2] == 1: img_gray = img[:, :, 0].copy() else: img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) return img_gray class Chart(object): """Definition for chart object. Defines PNG reference file, chart, size, distance and scaling range. """ def __init__( self, cam, props, log_path, chart_loc=None, chart_file=None, height=None, distance=None, scale_start=None, scale_stop=None, scale_step=None): """Initial constructor for class. Args: cam: open ITS session props: camera properties object log_path: log path to store the captured images. chart_loc: chart locator arg. chart_file: str; absolute path to png file of chart height: float; height in cm of displayed chart distance: float; distance in cm from camera of displayed chart scale_start: float; start value for scaling for chart search scale_stop: float; stop value for scaling for chart search scale_step: float; step value for scaling for chart search """ self._file = chart_file or CHART_FILE self._height = height or CHART_HEIGHT self._distance = distance or CHART_DISTANCE_RFOV self._scale_start = scale_start or CHART_SCALE_START self._scale_stop = scale_stop or CHART_SCALE_STOP self._scale_step = scale_step or CHART_SCALE_STEP self.xnorm, self.ynorm, self.wnorm, self.hnorm, self.scale = ( image_processing_utils.chart_located_per_argv(chart_loc)) if not self.xnorm: if camera_properties_utils.read_3a(props): self.locate(cam, props, log_path) else: logging.debug('Chart locator skipped.') self._set_scale_factors_to_one() def _set_scale_factors_to_one(self): """Set scale factors to 1.0 for skipped tests.""" self.wnorm = 1.0 self.hnorm = 1.0 self.xnorm = 0.0 self.ynorm = 0.0 self.scale = 1.0 def _calc_scale_factors(self, cam, props, fmt, s, e, fd, log_path): """Take an image with s, e, & fd to find the chart location. Args: cam: An open its session. props: Properties of cam fmt: Image format for the capture s: Sensitivity for the AF request as defined in android.sensor.sensitivity e: Exposure time for the AF request as defined in android.sensor.exposureTime fd: float; autofocus lens position log_path: log path to save the captured images. Returns: template: numpy array; chart template for locator img_3a: numpy array; RGB image for chart location scale_factor: float; scaling factor for chart search """ req = capture_request_utils.manual_capture_request(s, e) req['android.lens.focusDistance'] = fd cap_chart = image_processing_utils.stationary_lens_cap(cam, req, fmt) img_3a = image_processing_utils.convert_capture_to_rgb_image( cap_chart, props) img_3a = image_processing_utils.rotate_img_per_argv(img_3a) af_scene_name = os.path.join(log_path, 'af_scene.jpg') image_processing_utils.write_image(img_3a, af_scene_name) template = cv2.imread(self._file, cv2.IMREAD_ANYDEPTH) focal_l = cap_chart['metadata']['android.lens.focalLength'] pixel_pitch = ( props['android.sensor.info.physicalSize']['height'] / img_3a.shape[0]) logging.debug('Chart distance: %.2fcm', self._distance) logging.debug('Chart height: %.2fcm', self._height) logging.debug('Focal length: %.2fmm', focal_l) logging.debug('Pixel pitch: %.2fum', pixel_pitch * 1E3) logging.debug('Template height: %dpixels', template.shape[0]) chart_pixel_h = self._height * focal_l / (self._distance * pixel_pitch) scale_factor = template.shape[0] / chart_pixel_h logging.debug('Chart/image scale factor = %.2f', scale_factor) return template, img_3a, scale_factor def locate(self, cam, props, log_path): """Find the chart in the image, and append location to chart object. Args: cam: Open its session. props: Camera properties object. log_path: log path to store the captured images. The values appended are: xnorm: float; [0, 1] left loc of chart in scene ynorm: float; [0, 1] top loc of chart in scene wnorm: float; [0, 1] width of chart in scene hnorm: float; [0, 1] height of chart in scene scale: float; scale factor to extract chart """ if camera_properties_utils.read_3a(props): s, e, _, _, fd = cam.do_3a(get_results=True) fmt = {'format': 'yuv', 'width': VGA_WIDTH, 'height': VGA_HEIGHT} chart, scene, s_factor = self._calc_scale_factors(cam, props, fmt, s, e, fd, log_path) else: logging.debug('Chart locator skipped.') self._set_scale_factors_to_one() return scale_start = self._scale_start * s_factor scale_stop = self._scale_stop * s_factor scale_step = self._scale_step * s_factor self.scale = s_factor max_match = [] # check for normalized image if numpy.amax(scene) <= 1.0: scene = (scene * 255.0).astype(numpy.uint8) scene_gray = gray_scale_img(scene) logging.debug('Finding chart in scene...') for scale in numpy.arange(scale_start, scale_stop, scale_step): scene_scaled = scale_img(scene_gray, scale) if (scene_scaled.shape[0] < chart.shape[0] or scene_scaled.shape[1] < chart.shape[1]): continue result = cv2.matchTemplate(scene_scaled, chart, cv2.TM_CCOEFF) _, opt_val, _, top_left_scaled = cv2.minMaxLoc(result) logging.debug(' scale factor: %.3f, opt val: %.f', scale, opt_val) max_match.append((opt_val, top_left_scaled)) # determine if optimization results are valid opt_values = [x[0] for x in max_match] if 2.0 * min(opt_values) > max(opt_values): estring = ('Warning: unable to find chart in scene!\n' 'Check camera distance and self-reported ' 'pixel pitch, focal length and hyperfocal distance.') logging.warning(estring) self._set_scale_factors_to_one() else: if (max(opt_values) == opt_values[0] or max(opt_values) == opt_values[len(opt_values) - 1]): estring = ('Warning: Chart is at extreme range of locator.') logging.warning(estring) # find max and draw bbox match_index = max_match.index(max(max_match, key=lambda x: x[0])) self.scale = scale_start + scale_step * match_index logging.debug('Optimum scale factor: %.3f', self.scale) top_left_scaled = max_match[match_index][1] h, w = chart.shape bottom_right_scaled = (top_left_scaled[0] + w, top_left_scaled[1] + h) top_left = ((top_left_scaled[0] // self.scale), (top_left_scaled[1] // self.scale)) bottom_right = ((bottom_right_scaled[0] // self.scale), (bottom_right_scaled[1] // self.scale)) self.wnorm = ((bottom_right[0]) - top_left[0]) / scene.shape[1] self.hnorm = ((bottom_right[1]) - top_left[1]) / scene.shape[0] self.xnorm = (top_left[0]) / scene.shape[1] self.ynorm = (top_left[1]) / scene.shape[0] def component_shape(contour): """Measure the shape of a connected component. Args: contour: return from cv2.findContours. A list of pixel coordinates of the contour. Returns: The most left, right, top, bottom pixel location, height, width, and the center pixel location of the contour. """ shape = {'left': numpy.inf, 'right': 0, 'top': numpy.inf, 'bottom': 0, 'width': 0, 'height': 0, 'ctx': 0, 'cty': 0} for pt in contour: if pt[0][0] < shape['left']: shape['left'] = pt[0][0] if pt[0][0] > shape['right']: shape['right'] = pt[0][0] if pt[0][1] < shape['top']: shape['top'] = pt[0][1] if pt[0][1] > shape['bottom']: shape['bottom'] = pt[0][1] shape['width'] = shape['right'] - shape['left'] + 1 shape['height'] = shape['bottom'] - shape['top'] + 1 shape['ctx'] = (shape['left'] + shape['right']) // 2 shape['cty'] = (shape['top'] + shape['bottom']) // 2 return shape def find_circle(img, img_name, min_area, color): """Find the circle in the test image. Args: img: numpy image array in RGB, with pixel values in [0,255]. img_name: string with image info of format and size. min_area: float of minimum area of circle to find color: int of [0 or 255] 0 is black, 255 is white Returns: circle = {'x', 'y', 'r', 'w', 'h', 'x_offset', 'y_offset'} """ circle = {} img_size = img.shape if img_size[0]*img_size[1] >= LOW_RES_IMG_THRESH: circlish_atol = CIRCLISH_ATOL else: circlish_atol = CIRCLISH_LOW_RES_ATOL # convert to gray-scale image img_gray = numpy.dot(img[..., :3], RGB_GRAY_WEIGHTS) # otsu threshold to binarize the image _, img_bw = cv2.threshold(numpy.uint8(img_gray), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # find contours contours = find_all_contours(255-img_bw) # Check each contour and find the circle bigger than min_area num_circles = 0 logging.debug('Initial number of contours: %d', len(contours)) for contour in contours: area = cv2.contourArea(contour) num_pts = len(contour) if (area > img_size[0]*img_size[1]*min_area and num_pts >= CIRCLE_MIN_PTS): shape = component_shape(contour) radius = (shape['width'] + shape['height']) / 4 colour = img_bw[shape['cty']][shape['ctx']] circlish = (math.pi * radius**2) / area aspect_ratio = shape['width'] / shape['height'] logging.debug('Potential circle found. radius: %.2f, color: %d, ' 'circlish: %.3f, ar: %.3f, pts: %d', radius, colour, circlish, aspect_ratio, num_pts) if (colour == color and numpy.isclose(1.0, circlish, atol=circlish_atol) and numpy.isclose(1.0, aspect_ratio, atol=CIRCLE_AR_ATOL) and num_pts/radius >= CIRCLE_RADIUS_NUMPTS_THRESH): # Populate circle dictionary circle['x'] = shape['ctx'] circle['y'] = shape['cty'] circle['r'] = (shape['width'] + shape['height']) / 4 circle['w'] = float(shape['width']) circle['h'] = float(shape['height']) circle['x_offset'] = (shape['ctx'] - img_size[1]//2) / circle['w'] circle['y_offset'] = (shape['cty'] - img_size[0]//2) / circle['h'] logging.debug('Num pts: %d', num_pts) logging.debug('Aspect ratio: %.3f', aspect_ratio) logging.debug('Circlish value: %.3f', circlish) logging.debug('Location: %.1f x %.1f', circle['x'], circle['y']) logging.debug('Radius: %.3f', circle['r']) logging.debug('Circle center position wrt to image center:%.3fx%.3f', circle['x_offset'], circle['y_offset']) num_circles += 1 # if more than one circle found, break if num_circles == 2: break if num_circles == 0: image_processing_utils.write_image(img/255, img_name, True) raise AssertionError('No black circle detected. ' 'Please take pictures according to instructions.') if num_circles > 1: image_processing_utils.write_image(img/255, img_name, True) raise AssertionError('More than 1 black circle detected. ' 'Background of scene may be too complex.') return circle def append_circle_center_to_img(circle, img, img_name): """Append circle center and image center to image and save image. Draws line from circle center to image center and then labels end-points. Adjusts text positioning depending on circle center wrt image center. Moves text position left/right half of up/down movement for visual aesthetics. Args: circle: dict with circle location vals. img: numpy float image array in RGB, with pixel values in [0,255]. img_name: string with image info of format and size. """ line_width_scaling_factor = 500 text_move_scaling_factor = 3 img_size = img.shape img_center_x = img_size[1]//2 img_center_y = img_size[0]//2 # draw line from circle to image center line_width = int(max(1, max(img_size)//line_width_scaling_factor)) font_size = line_width // 2 move_text_dist = line_width * text_move_scaling_factor cv2.line(img, (circle['x'], circle['y']), (img_center_x, img_center_y), CV2_RED, line_width) # adjust text location move_text_right_circle = -1 move_text_right_image = 2 if circle['x'] > img_center_x: move_text_right_circle = 2 move_text_right_image = -1 move_text_down_circle = -1 move_text_down_image = 4 if circle['y'] > img_center_y: move_text_down_circle = 4 move_text_down_image = -1 # add circles to end points and label radius_pt = line_width * 2 # makes a dot 2x line width filled_pt = -1 # cv2 value for a filled circle # circle center cv2.circle(img, (circle['x'], circle['y']), radius_pt, CV2_RED, filled_pt) text_circle_x = move_text_dist * move_text_right_circle + circle['x'] text_circle_y = move_text_dist * move_text_down_circle + circle['y'] cv2.putText(img, 'circle center', (text_circle_x, text_circle_y), cv2.FONT_HERSHEY_SIMPLEX, font_size, CV2_RED, line_width) # image center cv2.circle(img, (img_center_x, img_center_y), radius_pt, CV2_RED, filled_pt) text_imgct_x = move_text_dist * move_text_right_image + img_center_x text_imgct_y = move_text_dist * move_text_down_image + img_center_y cv2.putText(img, 'image center', (text_imgct_x, text_imgct_y), cv2.FONT_HERSHEY_SIMPLEX, font_size, CV2_RED, line_width) image_processing_utils.write_image(img/255, img_name, True) # [0, 1] values def get_angle(input_img): """Computes anglular inclination of chessboard in input_img. Args: input_img (2D numpy.ndarray): Grayscale image stored as a 2D numpy array. Returns: Median angle of squares in degrees identified in the image. Angle estimation algorithm description: Input: 2D grayscale image of chessboard. Output: Angle of rotation of chessboard perpendicular to chessboard. Assumes chessboard and camera are parallel to each other. 1) Use adaptive threshold to make image binary 2) Find countours 3) Filter out small contours 4) Filter out all non-square contours 5) Compute most common square shape. The assumption here is that the most common square instances are the chessboard squares. We've shown that with our current tuning, we can robustly identify the squares on the sensor fusion chessboard. 6) Return median angle of most common square shape. USAGE NOTE: This function has been tuned to work for the chessboard used in the sensor_fusion tests. See images in test_images/rotated_chessboard/ for sample captures. If this function is used with other chessboards, it may not work as expected. """ # Tuning parameters square_area_min = (float)(input_img.shape[1] * SQUARE_AREA_MIN_REL) # Creates copy of image to avoid modifying original. img = numpy.array(input_img, copy=True) # Scale pixel values from 0-1 to 0-255 img *= 255 img = img.astype(numpy.uint8) img_thresh = cv2.adaptiveThreshold( img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 201, 2) # Find all contours. contours = find_all_contours(img_thresh) # Filter contours to squares only. square_contours = [] for contour in contours: rect = cv2.minAreaRect(contour) _, (width, height), angle = rect # Skip non-squares if not numpy.isclose(width, height, rtol=SQUARE_TOL): continue # Remove very small contours: usually just tiny dots due to noise. area = cv2.contourArea(contour) if area < square_area_min: continue square_contours.append(contour) areas = [] for contour in square_contours: area = cv2.contourArea(contour) areas.append(area) median_area = numpy.median(areas) filtered_squares = [] filtered_angles = [] for square in square_contours: area = cv2.contourArea(square) if not numpy.isclose(area, median_area, rtol=SQUARE_TOL): continue filtered_squares.append(square) _, (width, height), angle = cv2.minAreaRect(square) filtered_angles.append(angle) if len(filtered_angles) < ANGLE_NUM_MIN: logging.debug( 'A frame had too few angles to be processed. ' 'Num of angles: %d, MIN: %d', len(filtered_angles), ANGLE_NUM_MIN) return None return numpy.median(filtered_angles) class Cv2ImageProcessingUtilsTests(unittest.TestCase): """Unit tests for this module.""" def test_get_angle_identify_unrotated_chessboard_angle(self): normal_img_path = os.path.join( TEST_IMG_DIR, 'rotated_chessboards/normal.jpg') wide_img_path = os.path.join( TEST_IMG_DIR, 'rotated_chessboards/wide.jpg') normal_img = cv2.cvtColor(cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY) wide_img = cv2.cvtColor(cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY) normal_angle = get_angle(normal_img) wide_angle = get_angle(wide_img) e_msg = f'Angle: 0, Regular: {normal_angle}, Wide: {wide_angle}' self.assertEqual(get_angle(normal_img), 0, e_msg) self.assertEqual(get_angle(wide_img), 0, e_msg) def test_get_angle_identify_rotated_chessboard_angle(self): # Array of the image files and angles containing rotated chessboards. test_cases = [ ('_15_ccw', 15), ('_30_ccw', 30), ('_45_ccw', 45), ('_60_ccw', 60), ('_75_ccw', 75), ('_90_ccw', 90) ] # For each rotated image pair (normal, wide), check angle against expected. for suffix, angle in test_cases: # Define image paths. normal_img_path = os.path.join( TEST_IMG_DIR, f'rotated_chessboards/normal{suffix}.jpg') wide_img_path = os.path.join( TEST_IMG_DIR, f'rotated_chessboards/wide{suffix}.jpg') # Load and color-convert images. normal_img = cv2.cvtColor(cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY) wide_img = cv2.cvtColor(cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY) # Assert angle as expected. normal_angle = get_angle(normal_img) wide_angle = get_angle(wide_img) e_msg = f'Angle: {angle}, Regular: {normal_angle}, Wide: {wide_angle}' self.assertTrue( numpy.isclose(abs(normal_angle), angle, ANGLE_CHECK_TOL), e_msg) self.assertTrue( numpy.isclose(abs(wide_angle), angle, ANGLE_CHECK_TOL), e_msg) if __name__ == '__main__': unittest.main()