blob: 09265e946ca01d39e4bb97ff85ceca7f6a955ffb [file] [log] [blame]
Yin-Chia Yehb2a38652016-10-14 16:41:06 -07001# 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
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070015import os
Clemenz Portmann51d765f2017-07-14 14:56:45 -070016import unittest
17
18import cv2
Clemenz Portmann96bed402017-12-15 09:59:45 -080019import its.caps
Clemenz Portmann51d765f2017-07-14 14:56:45 -070020import its.device
21import its.error
Will Guedes0f11cfb2018-04-09 08:47:31 -050022import its.image
Clemenz Portmann51d765f2017-07-14 14:56:45 -070023import numpy
24
Clemenz Portmann98d96312018-08-10 16:47:25 -070025CHART_FILE = os.path.join(os.environ['CAMERA_ITS_TOP'], 'pymodules', 'its',
26 'test_images', 'ISO12233.png')
27CHART_HEIGHT = 13.5 # cm
Clemenz Portmann35ca9e22020-02-03 13:17:47 -080028CHART_DISTANCE_RFOV = 31.0 # cm
Clemenz Portmannd4408952018-10-12 08:51:49 -070029CHART_DISTANCE_WFOV = 22.0 # cm
Clemenz Portmann98d96312018-08-10 16:47:25 -070030CHART_SCALE_START = 0.65
31CHART_SCALE_STOP = 1.35
32CHART_SCALE_STEP = 0.025
33
song dake62b40ee2020-09-29 09:58:08 +080034FOV_THRESH_SUPER_TELE = 40
Clemenz Portmannd4408952018-10-12 08:51:49 -070035FOV_THRESH_TELE = 60
leslieshawad758902020-09-01 19:45:16 -070036FOV_THRESH_WFOV = 91
Clemenz Portmannd4408952018-10-12 08:51:49 -070037
38SCALE_RFOV_IN_WFOV_BOX = 0.67
39SCALE_TELE_IN_RFOV_BOX = 0.67
40SCALE_TELE_IN_WFOV_BOX = 0.5
Taizo Matsumasa43bd4412021-03-05 09:34:20 +090041SCALE_SUPER_TELE_IN_RFOV_BOX = 0.5
Clemenz Portmannd4408952018-10-12 08:51:49 -070042
Clemenz Portmann51d765f2017-07-14 14:56:45 -070043VGA_HEIGHT = 480
44VGA_WIDTH = 640
45
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070046
Clemenz Portmannd4408952018-10-12 08:51:49 -070047def calc_chart_scaling(chart_distance, camera_fov):
48 chart_scaling = 1.0
49 camera_fov = float(camera_fov)
50 if (FOV_THRESH_TELE < camera_fov < FOV_THRESH_WFOV and
51 numpy.isclose(chart_distance, CHART_DISTANCE_WFOV, rtol=0.1)):
52 chart_scaling = SCALE_RFOV_IN_WFOV_BOX
53 elif (camera_fov <= FOV_THRESH_TELE and
54 numpy.isclose(chart_distance, CHART_DISTANCE_WFOV, rtol=0.1)):
55 chart_scaling = SCALE_TELE_IN_WFOV_BOX
Taizo Matsumasa43bd4412021-03-05 09:34:20 +090056 elif (camera_fov <= FOV_THRESH_SUPER_TELE and
57 numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)):
58 chart_scaling = SCALE_SUPER_TELE_IN_RFOV_BOX
Clemenz Portmannd4408952018-10-12 08:51:49 -070059 elif (camera_fov <= FOV_THRESH_TELE and
60 numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)):
61 chart_scaling = SCALE_TELE_IN_RFOV_BOX
62 return chart_scaling
63
64
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070065def scale_img(img, scale=1.0):
66 """Scale and image based on a real number scale factor."""
67 dim = (int(img.shape[1]*scale), int(img.shape[0]*scale))
68 return cv2.resize(img.copy(), dim, interpolation=cv2.INTER_AREA)
69
Clemenz Portmann51d765f2017-07-14 14:56:45 -070070
Clemenz Portmannc47c8022017-04-04 09:10:30 -070071def gray_scale_img(img):
72 """Return gray scale version of image."""
73 if len(img.shape) == 2:
74 img_gray = img.copy()
75 elif len(img.shape) == 3:
76 if img.shape[2] == 1:
77 img_gray = img[:, :, 0].copy()
78 else:
79 img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
80 return img_gray
81
82
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070083class Chart(object):
84 """Definition for chart object.
85
86 Defines PNG reference file, chart size and distance, and scaling range.
87 """
88
Clemenz Portmann98d96312018-08-10 16:47:25 -070089 def __init__(self, chart_file=None, height=None, distance=None,
Clemenz Portmannc2a1d872018-10-30 19:32:35 -070090 scale_start=None, scale_stop=None, scale_step=None,
91 camera_id=None):
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070092 """Initial constructor for class.
93
94 Args:
95 chart_file: str; absolute path to png file of chart
96 height: float; height in cm of displayed chart
97 distance: float; distance in cm from camera of displayed chart
98 scale_start: float; start value for scaling for chart search
99 scale_stop: float; stop value for scaling for chart search
100 scale_step: float; step value for scaling for chart search
Clemenz Portmannc2a1d872018-10-30 19:32:35 -0700101 camera_id: int; camera used for extractor
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700102 """
Clemenz Portmann98d96312018-08-10 16:47:25 -0700103 self._file = chart_file or CHART_FILE
104 self._height = height or CHART_HEIGHT
Clemenz Portmannd4408952018-10-12 08:51:49 -0700105 self._distance = distance or CHART_DISTANCE_RFOV
Clemenz Portmann98d96312018-08-10 16:47:25 -0700106 self._scale_start = scale_start or CHART_SCALE_START
107 self._scale_stop = scale_stop or CHART_SCALE_STOP
108 self._scale_step = scale_step or CHART_SCALE_STEP
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700109 self.xnorm, self.ynorm, self.wnorm, self.hnorm, self.scale = its.image.chart_located_per_argv()
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700110 if not self.xnorm:
Clemenz Portmannc2a1d872018-10-30 19:32:35 -0700111 with its.device.ItsSession(camera_id) as cam:
Clemenz Portmann96bed402017-12-15 09:59:45 -0800112 props = cam.get_camera_properties()
Clemenz Portmann11787e52020-12-11 14:29:00 -0800113 props = cam.override_with_hidden_physical_camera_props(props)
Clemenz Portmann96bed402017-12-15 09:59:45 -0800114 if its.caps.read_3a(props):
115 self.locate(cam, props)
116 else:
117 print 'Chart locator skipped.'
118 self._set_scale_factors_to_one()
119
120 def _set_scale_factors_to_one(self):
121 """Set scale factors to 1.0 for skipped tests."""
122 self.wnorm = 1.0
123 self.hnorm = 1.0
124 self.xnorm = 0.0
125 self.ynorm = 0.0
126 self.scale = 1.0
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700127
128 def _calc_scale_factors(self, cam, props, fmt, s, e, fd):
129 """Take an image with s, e, & fd to find the chart location.
130
131 Args:
132 cam: An open device session.
133 props: Properties of cam
134 fmt: Image format for the capture
135 s: Sensitivity for the AF request as defined in
136 android.sensor.sensitivity
137 e: Exposure time for the AF request as defined in
138 android.sensor.exposureTime
139 fd: float; autofocus lens position
140 Returns:
141 template: numpy array; chart template for locator
142 img_3a: numpy array; RGB image for chart location
143 scale_factor: float; scaling factor for chart search
144 """
145 req = its.objects.manual_capture_request(s, e)
146 req['android.lens.focusDistance'] = fd
147 cap_chart = its.image.stationary_lens_cap(cam, req, fmt)
148 img_3a = its.image.convert_capture_to_rgb_image(cap_chart, props)
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700149 img_3a = its.image.rotate_img_per_argv(img_3a)
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700150 its.image.write_image(img_3a, 'af_scene.jpg')
151 template = cv2.imread(self._file, cv2.IMREAD_ANYDEPTH)
152 focal_l = cap_chart['metadata']['android.lens.focalLength']
153 pixel_pitch = (props['android.sensor.info.physicalSize']['height'] /
154 img_3a.shape[0])
155 print ' Chart distance: %.2fcm' % self._distance
156 print ' Chart height: %.2fcm' % self._height
157 print ' Focal length: %.2fmm' % focal_l
158 print ' Pixel pitch: %.2fum' % (pixel_pitch*1E3)
159 print ' Template height: %dpixels' % template.shape[0]
160 chart_pixel_h = self._height * focal_l / (self._distance * pixel_pitch)
161 scale_factor = template.shape[0] / chart_pixel_h
162 print 'Chart/image scale factor = %.2f' % scale_factor
163 return template, img_3a, scale_factor
164
Clemenz Portmann96bed402017-12-15 09:59:45 -0800165 def locate(self, cam, props):
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700166 """Find the chart in the image, and append location to chart object.
167
168 The values appended are:
169 xnorm: float; [0, 1] left loc of chart in scene
170 ynorm: float; [0, 1] top loc of chart in scene
171 wnorm: float; [0, 1] width of chart in scene
172 hnorm: float; [0, 1] height of chart in scene
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700173 scale: float; scale factor to extract chart
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700174
175 Args:
176 cam: An open device session
Clemenz Portmann96bed402017-12-15 09:59:45 -0800177 props: Camera properties
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700178 """
Clemenz Portmann96bed402017-12-15 09:59:45 -0800179 if its.caps.read_3a(props):
180 s, e, _, _, fd = cam.do_3a(get_results=True)
181 fmt = {'format': 'yuv', 'width': VGA_WIDTH, 'height': VGA_HEIGHT}
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700182 chart, scene, s_factor = self._calc_scale_factors(cam, props, fmt,
183 s, e, fd)
184 else:
Clemenz Portmann96bed402017-12-15 09:59:45 -0800185 print 'Chart locator skipped.'
186 self._set_scale_factors_to_one()
187 return
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700188 scale_start = self._scale_start * s_factor
189 scale_stop = self._scale_stop * s_factor
190 scale_step = self._scale_step * s_factor
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700191 self.scale = s_factor
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700192 max_match = []
193 # check for normalized image
194 if numpy.amax(scene) <= 1.0:
195 scene = (scene * 255.0).astype(numpy.uint8)
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700196 scene_gray = gray_scale_img(scene)
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700197 print 'Finding chart in scene...'
198 for scale in numpy.arange(scale_start, scale_stop, scale_step):
199 scene_scaled = scale_img(scene_gray, scale)
Clemenz Portmann8ecc0052018-08-13 10:13:26 -0700200 if (scene_scaled.shape[0] < chart.shape[0] or
Clemenz Portmannd4408952018-10-12 08:51:49 -0700201 scene_scaled.shape[1] < chart.shape[1]):
Clemenz Portmann8ecc0052018-08-13 10:13:26 -0700202 continue
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700203 result = cv2.matchTemplate(scene_scaled, chart, cv2.TM_CCOEFF)
204 _, opt_val, _, top_left_scaled = cv2.minMaxLoc(result)
205 # print out scale and match
206 print ' scale factor: %.3f, opt val: %.f' % (scale, opt_val)
207 max_match.append((opt_val, top_left_scaled))
208
209 # determine if optimization results are valid
210 opt_values = [x[0] for x in max_match]
211 if 2.0*min(opt_values) > max(opt_values):
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700212 estring = ('Warning: unable to find chart in scene!\n'
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700213 'Check camera distance and self-reported '
214 'pixel pitch, focal length and hyperfocal distance.')
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700215 print estring
Clemenz Portmann96bed402017-12-15 09:59:45 -0800216 self._set_scale_factors_to_one()
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700217 else:
218 if (max(opt_values) == opt_values[0] or
219 max(opt_values) == opt_values[len(opt_values)-1]):
220 estring = ('Warning: chart is at extreme range of locator '
221 'check.\n')
222 print estring
223 # find max and draw bbox
224 match_index = max_match.index(max(max_match, key=lambda x: x[0]))
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700225 self.scale = scale_start + scale_step * match_index
226 print 'Optimum scale factor: %.3f' % self.scale
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700227 top_left_scaled = max_match[match_index][1]
228 h, w = chart.shape
229 bottom_right_scaled = (top_left_scaled[0] + w,
230 top_left_scaled[1] + h)
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700231 top_left = (int(top_left_scaled[0]/self.scale),
232 int(top_left_scaled[1]/self.scale))
233 bottom_right = (int(bottom_right_scaled[0]/self.scale),
234 int(bottom_right_scaled[1]/self.scale))
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700235 self.wnorm = float((bottom_right[0]) - top_left[0]) / scene.shape[1]
236 self.hnorm = float((bottom_right[1]) - top_left[1]) / scene.shape[0]
237 self.xnorm = float(top_left[0]) / scene.shape[1]
238 self.ynorm = float(top_left[1]) / scene.shape[0]
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700239
240
Will Guedes0f11cfb2018-04-09 08:47:31 -0500241def get_angle(input_img):
242 """Computes anglular inclination of chessboard in input_img.
243
244 Angle estimation algoritm description:
245 Input: 2D grayscale image of chessboard.
246 Output: Angle of rotation of chessboard perpendicular to
247 chessboard. Assumes chessboard and camera are parallel to
248 each other.
249
250 1) Use adaptive threshold to make image binary
251 2) Find countours
252 3) Filter out small contours
253 4) Filter out all non-square contours
254 5) Compute most common square shape.
255 The assumption here is that the most common square instances
256 are the chessboard squares. We've shown that with our current
257 tuning, we can robustly identify the squares on the sensor fusion
258 chessboard.
259 6) Return median angle of most common square shape.
260
261 USAGE NOTE: This function has been tuned to work for the chessboard used in
262 the sensor_fusion tests. See images in test_images/rotated_chessboard/ for
263 sample captures. If this function is used with other chessboards, it may not
264 work as expected.
265
266 TODO: Make algorithm more robust so it works on any type of
267 chessboard.
268
269 Args:
270 input_img (2D numpy.ndarray): Grayscale image stored as a 2D
271 numpy array.
272
273 Returns:
274 Median angle of squares in degrees identified in the image.
275 """
276 # Tuning parameters
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700277 min_square_area = (float)(input_img.shape[1] * 0.05)
Will Guedes0f11cfb2018-04-09 08:47:31 -0500278
279 # Creates copy of image to avoid modifying original.
280 img = numpy.array(input_img, copy=True)
281
282 # Scale pixel values from 0-1 to 0-255
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700283 img *= 255
Will Guedes0f11cfb2018-04-09 08:47:31 -0500284 img = img.astype(numpy.uint8)
285
286 thresh = cv2.adaptiveThreshold(
287 img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 201, 2)
288
289 # Find all contours
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700290 contours = []
291 cv2_version = cv2.__version__
Yin-Chia Yehb8d153c2020-07-12 08:34:25 -0700292 if cv2_version.startswith('3.'): # OpenCV 3.x
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700293 _, contours, _ = cv2.findContours(
294 thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
Yin-Chia Yehb8d153c2020-07-12 08:34:25 -0700295 else: # OpenCV 2.x and 4.x
296 contours, _ = cv2.findContours(
297 thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
Will Guedes0f11cfb2018-04-09 08:47:31 -0500298
299 # Filter contours to squares only.
300 square_contours = []
301
302 for contour in contours:
303 rect = cv2.minAreaRect(contour)
304 _, (width, height), angle = rect
305
306 # Skip non-squares (with 0.1 tolerance)
307 tolerance = 0.1
308 if width < height * (1 - tolerance) or width > height * (1 + tolerance):
309 continue
310
311 # Remove very small contours.
312 # These are usually just tiny dots due to noise.
313 area = cv2.contourArea(contour)
314 if area < min_square_area:
315 continue
316
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700317 if cv2_version.startswith('2.4.'):
318 box = numpy.int0(cv2.cv.BoxPoints(rect))
319 elif cv2_version.startswith('3.2.'):
320 box = numpy.int0(cv2.boxPoints(rect))
Will Guedes0f11cfb2018-04-09 08:47:31 -0500321 square_contours.append(contour)
322
323 areas = []
324 for contour in square_contours:
325 area = cv2.contourArea(contour)
326 areas.append(area)
327
328 median_area = numpy.median(areas)
329
330 filtered_squares = []
331 filtered_angles = []
332 for square in square_contours:
333 area = cv2.contourArea(square)
334 if area < median_area * 0.90 or area > median_area * 1.10:
335 continue
336
337 filtered_squares.append(square)
338 _, (width, height), angle = cv2.minAreaRect(square)
339 filtered_angles.append(angle)
340
341 if len(filtered_angles) < 10:
342 return None
343
344 return numpy.median(filtered_angles)
345
346
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700347class __UnitTest(unittest.TestCase):
348 """Run a suite of unit tests on this module.
349 """
350
351 def test_compute_image_sharpness(self):
352 """Unit test for compute_img_sharpness.
353
354 Test by using PNG of ISO12233 chart and blurring intentionally.
355 'sharpness' should drop off by sqrt(2) for 2x blur of image.
356
357 We do one level of blur as PNG image is not perfect.
358 """
359 yuv_full_scale = 1023.0
360 chart_file = os.path.join(os.environ['CAMERA_ITS_TOP'], 'pymodules',
361 'its', 'test_images', 'ISO12233.png')
362 chart = cv2.imread(chart_file, cv2.IMREAD_ANYDEPTH)
363 white_level = numpy.amax(chart).astype(float)
364 sharpness = {}
365 for j in [2, 4, 8]:
366 blur = cv2.blur(chart, (j, j))
367 blur = blur[:, :, numpy.newaxis]
368 sharpness[j] = (yuv_full_scale *
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700369 its.image.compute_image_sharpness(blur /
370 white_level))
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700371 self.assertTrue(numpy.isclose(sharpness[2]/sharpness[4],
372 numpy.sqrt(2), atol=0.1))
373 self.assertTrue(numpy.isclose(sharpness[4]/sharpness[8],
374 numpy.sqrt(2), atol=0.1))
375
Will Guedes0f11cfb2018-04-09 08:47:31 -0500376 def test_get_angle_identify_unrotated_chessboard_angle(self):
377 basedir = os.path.join(
378 os.path.dirname(__file__), 'test_images/rotated_chessboards/')
379
380 normal_img_path = os.path.join(basedir, 'normal.jpg')
381 wide_img_path = os.path.join(basedir, 'wide.jpg')
382
383 normal_img = cv2.cvtColor(
384 cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY)
385 wide_img = cv2.cvtColor(
386 cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY)
387
388 assert get_angle(normal_img) == 0
389 assert get_angle(wide_img) == 0
390
391 def test_get_angle_identify_rotated_chessboard_angle(self):
392 basedir = os.path.join(
393 os.path.dirname(__file__), 'test_images/rotated_chessboards/')
394
395 # Array of the image files and angles containing rotated chessboards.
396 test_cases = [
397 ('_15_ccw', 15),
398 ('_30_ccw', 30),
399 ('_45_ccw', 45),
400 ('_60_ccw', 60),
401 ('_75_ccw', 75),
402 ('_90_ccw', 90)
403 ]
404
405 # For each rotated image pair (normal, wide). Check if angle is
406 # identified as expected.
407 for suffix, angle in test_cases:
408 # Define image paths
409 normal_img_path = os.path.join(
410 basedir, 'normal{}.jpg'.format(suffix))
411 wide_img_path = os.path.join(
412 basedir, 'wide{}.jpg'.format(suffix))
413
414 # Load and color convert images
415 normal_img = cv2.cvtColor(
416 cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY)
417 wide_img = cv2.cvtColor(
418 cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY)
419
420 # Assert angle is as expected up to 2.0 degrees of accuracy.
421 assert numpy.isclose(
422 abs(get_angle(normal_img)), angle, 2.0)
423 assert numpy.isclose(
424 abs(get_angle(wide_img)), angle, 2.0)
425
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700426
Clemenz Portmannde9c2c42020-02-06 09:56:35 -0800427def component_shape(contour):
428 """Measure the shape of a connected component.
429
430 Args:
431 contour: return from cv2.findContours. A list of pixel coordinates of
432 the contour.
433
434 Returns:
435 The most left, right, top, bottom pixel location, height, width, and
436 the center pixel location of the contour.
437 """
438 shape = {'left': numpy.inf, 'right': 0, 'top': numpy.inf, 'bottom': 0,
439 'width': 0, 'height': 0, 'ctx': 0, 'cty': 0}
440 for pt in contour:
441 if pt[0][0] < shape['left']:
442 shape['left'] = pt[0][0]
443 if pt[0][0] > shape['right']:
444 shape['right'] = pt[0][0]
445 if pt[0][1] < shape['top']:
446 shape['top'] = pt[0][1]
447 if pt[0][1] > shape['bottom']:
448 shape['bottom'] = pt[0][1]
449 shape['width'] = shape['right'] - shape['left'] + 1
450 shape['height'] = shape['bottom'] - shape['top'] + 1
451 shape['ctx'] = (shape['left'] + shape['right']) / 2
452 shape['cty'] = (shape['top'] + shape['bottom']) / 2
453 return shape
454
455
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700456if __name__ == '__main__':
457 unittest.main()