1 # Copyright 2016 The Android Open Source Project 2 # 3 # Licensed under the Apache License, Version 2.0 (the "License"); 4 # you may not use this file except in compliance with the License. 5 # You may obtain a copy of the License at 6 # 7 # http://www.apache.org/licenses/LICENSE-2.0 8 # 9 # Unless required by applicable law or agreed to in writing, software 10 # distributed under the License is distributed on an "AS IS" BASIS, 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 # See the License for the specific language governing permissions and 13 # limitations under the License. 14 15 import os 16 import unittest 17 18 import cv2 19 import its.caps 20 import its.device 21 import its.error 22 import its.image 23 import numpy 24 25 VGA_HEIGHT = 480 26 VGA_WIDTH = 640 27 28 29 def scale_img(img, scale=1.0): 30 """Scale and image based on a real number scale factor.""" 31 dim = (int(img.shape[1]*scale), int(img.shape[0]*scale)) 32 return cv2.resize(img.copy(), dim, interpolation=cv2.INTER_AREA) 33 34 35 def gray_scale_img(img): 36 """Return gray scale version of image.""" 37 if len(img.shape) == 2: 38 img_gray = img.copy() 39 elif len(img.shape) == 3: 40 if img.shape[2] == 1: 41 img_gray = img[:, :, 0].copy() 42 else: 43 img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) 44 return img_gray 45 46 47 class Chart(object): 48 """Definition for chart object. 49 50 Defines PNG reference file, chart size and distance, and scaling range. 51 """ 52 53 def __init__(self, chart_file, height, distance, scale_start, scale_stop, 54 scale_step): 55 """Initial constructor for class. 56 57 Args: 58 chart_file: str; absolute path to png file of chart 59 height: float; height in cm of displayed chart 60 distance: float; distance in cm from camera of displayed chart 61 scale_start: float; start value for scaling for chart search 62 scale_stop: float; stop value for scaling for chart search 63 scale_step: float; step value for scaling for chart search 64 """ 65 self._file = chart_file 66 self._height = height 67 self._distance = distance 68 self._scale_start = scale_start 69 self._scale_stop = scale_stop 70 self._scale_step = scale_step 71 self.xnorm, self.ynorm, self.wnorm, self.hnorm, self.scale = its.image.chart_located_per_argv() 72 if not self.xnorm: 73 with its.device.ItsSession() as cam: 74 props = cam.get_camera_properties() 75 if its.caps.read_3a(props): 76 self.locate(cam, props) 77 else: 78 print 'Chart locator skipped.' 79 self._set_scale_factors_to_one() 80 81 def _set_scale_factors_to_one(self): 82 """Set scale factors to 1.0 for skipped tests.""" 83 self.wnorm = 1.0 84 self.hnorm = 1.0 85 self.xnorm = 0.0 86 self.ynorm = 0.0 87 self.scale = 1.0 88 89 def _calc_scale_factors(self, cam, props, fmt, s, e, fd): 90 """Take an image with s, e, & fd to find the chart location. 91 92 Args: 93 cam: An open device session. 94 props: Properties of cam 95 fmt: Image format for the capture 96 s: Sensitivity for the AF request as defined in 97 android.sensor.sensitivity 98 e: Exposure time for the AF request as defined in 99 android.sensor.exposureTime 100 fd: float; autofocus lens position 101 Returns: 102 template: numpy array; chart template for locator 103 img_3a: numpy array; RGB image for chart location 104 scale_factor: float; scaling factor for chart search 105 """ 106 req = its.objects.manual_capture_request(s, e) 107 req['android.lens.focusDistance'] = fd 108 cap_chart = its.image.stationary_lens_cap(cam, req, fmt) 109 img_3a = its.image.convert_capture_to_rgb_image(cap_chart, props) 110 img_3a = its.image.rotate_img_per_argv(img_3a) 111 its.image.write_image(img_3a, 'af_scene.jpg') 112 template = cv2.imread(self._file, cv2.IMREAD_ANYDEPTH) 113 focal_l = cap_chart['metadata']['android.lens.focalLength'] 114 pixel_pitch = (props['android.sensor.info.physicalSize']['height'] / 115 img_3a.shape[0]) 116 print ' Chart distance: %.2fcm' % self._distance 117 print ' Chart height: %.2fcm' % self._height 118 print ' Focal length: %.2fmm' % focal_l 119 print ' Pixel pitch: %.2fum' % (pixel_pitch*1E3) 120 print ' Template height: %dpixels' % template.shape[0] 121 chart_pixel_h = self._height * focal_l / (self._distance * pixel_pitch) 122 scale_factor = template.shape[0] / chart_pixel_h 123 print 'Chart/image scale factor = %.2f' % scale_factor 124 return template, img_3a, scale_factor 125 126 def locate(self, cam, props): 127 """Find the chart in the image, and append location to chart object. 128 129 The values appended are: 130 xnorm: float; [0, 1] left loc of chart in scene 131 ynorm: float; [0, 1] top loc of chart in scene 132 wnorm: float; [0, 1] width of chart in scene 133 hnorm: float; [0, 1] height of chart in scene 134 scale: float; scale factor to extract chart 135 136 Args: 137 cam: An open device session 138 props: Camera properties 139 """ 140 if its.caps.read_3a(props): 141 s, e, _, _, fd = cam.do_3a(get_results=True) 142 fmt = {'format': 'yuv', 'width': VGA_WIDTH, 'height': VGA_HEIGHT} 143 chart, scene, s_factor = self._calc_scale_factors(cam, props, fmt, 144 s, e, fd) 145 else: 146 print 'Chart locator skipped.' 147 self._set_scale_factors_to_one() 148 return 149 scale_start = self._scale_start * s_factor 150 scale_stop = self._scale_stop * s_factor 151 scale_step = self._scale_step * s_factor 152 self.scale = s_factor 153 max_match = [] 154 # check for normalized image 155 if numpy.amax(scene) <= 1.0: 156 scene = (scene * 255.0).astype(numpy.uint8) 157 scene_gray = gray_scale_img(scene) 158 print 'Finding chart in scene...' 159 for scale in numpy.arange(scale_start, scale_stop, scale_step): 160 scene_scaled = scale_img(scene_gray, scale) 161 result = cv2.matchTemplate(scene_scaled, chart, cv2.TM_CCOEFF) 162 _, opt_val, _, top_left_scaled = cv2.minMaxLoc(result) 163 # print out scale and match 164 print ' scale factor: %.3f, opt val: %.f' % (scale, opt_val) 165 max_match.append((opt_val, top_left_scaled)) 166 167 # determine if optimization results are valid 168 opt_values = [x[0] for x in max_match] 169 if 2.0*min(opt_values) > max(opt_values): 170 estring = ('Warning: unable to find chart in scene!\n' 171 'Check camera distance and self-reported ' 172 'pixel pitch, focal length and hyperfocal distance.') 173 print estring 174 self._set_scale_factors_to_one() 175 else: 176 if (max(opt_values) == opt_values[0] or 177 max(opt_values) == opt_values[len(opt_values)-1]): 178 estring = ('Warning: chart is at extreme range of locator ' 179 'check.\n') 180 print estring 181 # find max and draw bbox 182 match_index = max_match.index(max(max_match, key=lambda x: x[0])) 183 self.scale = scale_start + scale_step * match_index 184 print 'Optimum scale factor: %.3f' % self.scale 185 top_left_scaled = max_match[match_index][1] 186 h, w = chart.shape 187 bottom_right_scaled = (top_left_scaled[0] + w, 188 top_left_scaled[1] + h) 189 top_left = (int(top_left_scaled[0]/self.scale), 190 int(top_left_scaled[1]/self.scale)) 191 bottom_right = (int(bottom_right_scaled[0]/self.scale), 192 int(bottom_right_scaled[1]/self.scale)) 193 self.wnorm = float((bottom_right[0]) - top_left[0]) / scene.shape[1] 194 self.hnorm = float((bottom_right[1]) - top_left[1]) / scene.shape[0] 195 self.xnorm = float(top_left[0]) / scene.shape[1] 196 self.ynorm = float(top_left[1]) / scene.shape[0] 197 198 199 def get_angle(input_img): 200 """Computes anglular inclination of chessboard in input_img. 201 202 Angle estimation algoritm description: 203 Input: 2D grayscale image of chessboard. 204 Output: Angle of rotation of chessboard perpendicular to 205 chessboard. Assumes chessboard and camera are parallel to 206 each other. 207 208 1) Use adaptive threshold to make image binary 209 2) Find countours 210 3) Filter out small contours 211 4) Filter out all non-square contours 212 5) Compute most common square shape. 213 The assumption here is that the most common square instances 214 are the chessboard squares. We've shown that with our current 215 tuning, we can robustly identify the squares on the sensor fusion 216 chessboard. 217 6) Return median angle of most common square shape. 218 219 USAGE NOTE: This function has been tuned to work for the chessboard used in 220 the sensor_fusion tests. See images in test_images/rotated_chessboard/ for 221 sample captures. If this function is used with other chessboards, it may not 222 work as expected. 223 224 TODO: Make algorithm more robust so it works on any type of 225 chessboard. 226 227 Args: 228 input_img (2D numpy.ndarray): Grayscale image stored as a 2D 229 numpy array. 230 231 Returns: 232 Median angle of squares in degrees identified in the image. 233 """ 234 # Tuning parameters 235 min_square_area = (float)(input_img.shape[1] * 0.05) 236 237 # Creates copy of image to avoid modifying original. 238 img = numpy.array(input_img, copy=True) 239 240 # Scale pixel values from 0-1 to 0-255 241 img *= 255 242 img = img.astype(numpy.uint8) 243 244 thresh = cv2.adaptiveThreshold( 245 img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 201, 2) 246 247 # Find all contours 248 contours = [] 249 cv2_version = cv2.__version__ 250 if cv2_version.startswith('2.4.'): 251 contours, _ = cv2.findContours( 252 thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 253 elif cv2_version.startswith('3.2.'): 254 _, contours, _ = cv2.findContours( 255 thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 256 257 # Filter contours to squares only. 258 square_contours = [] 259 260 for contour in contours: 261 rect = cv2.minAreaRect(contour) 262 _, (width, height), angle = rect 263 264 # Skip non-squares (with 0.1 tolerance) 265 tolerance = 0.1 266 if width < height * (1 - tolerance) or width > height * (1 + tolerance): 267 continue 268 269 # Remove very small contours. 270 # These are usually just tiny dots due to noise. 271 area = cv2.contourArea(contour) 272 if area < min_square_area: 273 continue 274 275 if cv2_version.startswith('2.4.'): 276 box = numpy.int0(cv2.cv.BoxPoints(rect)) 277 elif cv2_version.startswith('3.2.'): 278 box = numpy.int0(cv2.boxPoints(rect)) 279 square_contours.append(contour) 280 281 areas = [] 282 for contour in square_contours: 283 area = cv2.contourArea(contour) 284 areas.append(area) 285 286 median_area = numpy.median(areas) 287 288 filtered_squares = [] 289 filtered_angles = [] 290 for square in square_contours: 291 area = cv2.contourArea(square) 292 if area < median_area * 0.90 or area > median_area * 1.10: 293 continue 294 295 filtered_squares.append(square) 296 _, (width, height), angle = cv2.minAreaRect(square) 297 filtered_angles.append(angle) 298 299 if len(filtered_angles) < 10: 300 return None 301 302 return numpy.median(filtered_angles) 303 304 305 class __UnitTest(unittest.TestCase): 306 """Run a suite of unit tests on this module. 307 """ 308 309 def test_compute_image_sharpness(self): 310 """Unit test for compute_img_sharpness. 311 312 Test by using PNG of ISO12233 chart and blurring intentionally. 313 'sharpness' should drop off by sqrt(2) for 2x blur of image. 314 315 We do one level of blur as PNG image is not perfect. 316 """ 317 yuv_full_scale = 1023.0 318 chart_file = os.path.join(os.environ['CAMERA_ITS_TOP'], 'pymodules', 319 'its', 'test_images', 'ISO12233.png') 320 chart = cv2.imread(chart_file, cv2.IMREAD_ANYDEPTH) 321 white_level = numpy.amax(chart).astype(float) 322 sharpness = {} 323 for j in [2, 4, 8]: 324 blur = cv2.blur(chart, (j, j)) 325 blur = blur[:, :, numpy.newaxis] 326 sharpness[j] = (yuv_full_scale * 327 its.image.compute_image_sharpness(blur / 328 white_level)) 329 self.assertTrue(numpy.isclose(sharpness[2]/sharpness[4], 330 numpy.sqrt(2), atol=0.1)) 331 self.assertTrue(numpy.isclose(sharpness[4]/sharpness[8], 332 numpy.sqrt(2), atol=0.1)) 333 334 def test_get_angle_identify_unrotated_chessboard_angle(self): 335 basedir = os.path.join( 336 os.path.dirname(__file__), 'test_images/rotated_chessboards/') 337 338 normal_img_path = os.path.join(basedir, 'normal.jpg') 339 wide_img_path = os.path.join(basedir, 'wide.jpg') 340 341 normal_img = cv2.cvtColor( 342 cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY) 343 wide_img = cv2.cvtColor( 344 cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY) 345 346 assert get_angle(normal_img) == 0 347 assert get_angle(wide_img) == 0 348 349 def test_get_angle_identify_rotated_chessboard_angle(self): 350 basedir = os.path.join( 351 os.path.dirname(__file__), 'test_images/rotated_chessboards/') 352 353 # Array of the image files and angles containing rotated chessboards. 354 test_cases = [ 355 ('_15_ccw', 15), 356 ('_30_ccw', 30), 357 ('_45_ccw', 45), 358 ('_60_ccw', 60), 359 ('_75_ccw', 75), 360 ('_90_ccw', 90) 361 ] 362 363 # For each rotated image pair (normal, wide). Check if angle is 364 # identified as expected. 365 for suffix, angle in test_cases: 366 # Define image paths 367 normal_img_path = os.path.join( 368 basedir, 'normal{}.jpg'.format(suffix)) 369 wide_img_path = os.path.join( 370 basedir, 'wide{}.jpg'.format(suffix)) 371 372 # Load and color convert images 373 normal_img = cv2.cvtColor( 374 cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY) 375 wide_img = cv2.cvtColor( 376 cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY) 377 378 # Assert angle is as expected up to 2.0 degrees of accuracy. 379 assert numpy.isclose( 380 abs(get_angle(normal_img)), angle, 2.0) 381 assert numpy.isclose( 382 abs(get_angle(wide_img)), angle, 2.0) 383 384 385 if __name__ == '__main__': 386 unittest.main() 387