blob: 2004846a3c2ee746e71fb01c86602cff0b209b61 [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
25VGA_HEIGHT = 480
26VGA_WIDTH = 640
27
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070028
29def 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
Clemenz Portmann51d765f2017-07-14 14:56:45 -070034
Clemenz Portmannc47c8022017-04-04 09:10:30 -070035def 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
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070047class 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,
Clemenz Portmann9f852a02018-10-31 19:52:25 -070054 scale_step, camera_id=None):
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070055 """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
Clemenz Portmann9f852a02018-10-31 19:52:25 -070064 camera_id: int; camera used for extractor
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070065 """
66 self._file = chart_file
67 self._height = height
68 self._distance = distance
69 self._scale_start = scale_start
70 self._scale_stop = scale_stop
71 self._scale_step = scale_step
Clemenz Portmannc47c8022017-04-04 09:10:30 -070072 self.xnorm, self.ynorm, self.wnorm, self.hnorm, self.scale = its.image.chart_located_per_argv()
Clemenz Portmann51d765f2017-07-14 14:56:45 -070073 if not self.xnorm:
Clemenz Portmann9f852a02018-10-31 19:52:25 -070074 with its.device.ItsSession(camera_id) as cam:
Clemenz Portmann96bed402017-12-15 09:59:45 -080075 props = cam.get_camera_properties()
76 if its.caps.read_3a(props):
77 self.locate(cam, props)
78 else:
79 print 'Chart locator skipped.'
80 self._set_scale_factors_to_one()
81
82 def _set_scale_factors_to_one(self):
83 """Set scale factors to 1.0 for skipped tests."""
84 self.wnorm = 1.0
85 self.hnorm = 1.0
86 self.xnorm = 0.0
87 self.ynorm = 0.0
88 self.scale = 1.0
Yin-Chia Yehb2a38652016-10-14 16:41:06 -070089
90 def _calc_scale_factors(self, cam, props, fmt, s, e, fd):
91 """Take an image with s, e, & fd to find the chart location.
92
93 Args:
94 cam: An open device session.
95 props: Properties of cam
96 fmt: Image format for the capture
97 s: Sensitivity for the AF request as defined in
98 android.sensor.sensitivity
99 e: Exposure time for the AF request as defined in
100 android.sensor.exposureTime
101 fd: float; autofocus lens position
102 Returns:
103 template: numpy array; chart template for locator
104 img_3a: numpy array; RGB image for chart location
105 scale_factor: float; scaling factor for chart search
106 """
107 req = its.objects.manual_capture_request(s, e)
108 req['android.lens.focusDistance'] = fd
109 cap_chart = its.image.stationary_lens_cap(cam, req, fmt)
110 img_3a = its.image.convert_capture_to_rgb_image(cap_chart, props)
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700111 img_3a = its.image.rotate_img_per_argv(img_3a)
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700112 its.image.write_image(img_3a, 'af_scene.jpg')
113 template = cv2.imread(self._file, cv2.IMREAD_ANYDEPTH)
114 focal_l = cap_chart['metadata']['android.lens.focalLength']
115 pixel_pitch = (props['android.sensor.info.physicalSize']['height'] /
116 img_3a.shape[0])
117 print ' Chart distance: %.2fcm' % self._distance
118 print ' Chart height: %.2fcm' % self._height
119 print ' Focal length: %.2fmm' % focal_l
120 print ' Pixel pitch: %.2fum' % (pixel_pitch*1E3)
121 print ' Template height: %dpixels' % template.shape[0]
122 chart_pixel_h = self._height * focal_l / (self._distance * pixel_pitch)
123 scale_factor = template.shape[0] / chart_pixel_h
124 print 'Chart/image scale factor = %.2f' % scale_factor
125 return template, img_3a, scale_factor
126
Clemenz Portmann96bed402017-12-15 09:59:45 -0800127 def locate(self, cam, props):
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700128 """Find the chart in the image, and append location to chart object.
129
130 The values appended are:
131 xnorm: float; [0, 1] left loc of chart in scene
132 ynorm: float; [0, 1] top loc of chart in scene
133 wnorm: float; [0, 1] width of chart in scene
134 hnorm: float; [0, 1] height of chart in scene
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700135 scale: float; scale factor to extract chart
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700136
137 Args:
138 cam: An open device session
Clemenz Portmann96bed402017-12-15 09:59:45 -0800139 props: Camera properties
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700140 """
Clemenz Portmann96bed402017-12-15 09:59:45 -0800141 if its.caps.read_3a(props):
142 s, e, _, _, fd = cam.do_3a(get_results=True)
143 fmt = {'format': 'yuv', 'width': VGA_WIDTH, 'height': VGA_HEIGHT}
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700144 chart, scene, s_factor = self._calc_scale_factors(cam, props, fmt,
145 s, e, fd)
146 else:
Clemenz Portmann96bed402017-12-15 09:59:45 -0800147 print 'Chart locator skipped.'
148 self._set_scale_factors_to_one()
149 return
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700150 scale_start = self._scale_start * s_factor
151 scale_stop = self._scale_stop * s_factor
152 scale_step = self._scale_step * s_factor
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700153 self.scale = s_factor
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700154 max_match = []
155 # check for normalized image
156 if numpy.amax(scene) <= 1.0:
157 scene = (scene * 255.0).astype(numpy.uint8)
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700158 scene_gray = gray_scale_img(scene)
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700159 print 'Finding chart in scene...'
160 for scale in numpy.arange(scale_start, scale_stop, scale_step):
161 scene_scaled = scale_img(scene_gray, scale)
Clemenz Portmann8ecc0052018-08-13 10:13:26 -0700162 if (scene_scaled.shape[0] < chart.shape[0] or
163 scene_scaled.shape[1] < chart.shape[1]):
164 continue
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700165 result = cv2.matchTemplate(scene_scaled, chart, cv2.TM_CCOEFF)
166 _, opt_val, _, top_left_scaled = cv2.minMaxLoc(result)
167 # print out scale and match
168 print ' scale factor: %.3f, opt val: %.f' % (scale, opt_val)
169 max_match.append((opt_val, top_left_scaled))
170
171 # determine if optimization results are valid
172 opt_values = [x[0] for x in max_match]
173 if 2.0*min(opt_values) > max(opt_values):
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700174 estring = ('Warning: unable to find chart in scene!\n'
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700175 'Check camera distance and self-reported '
176 'pixel pitch, focal length and hyperfocal distance.')
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700177 print estring
Clemenz Portmann96bed402017-12-15 09:59:45 -0800178 self._set_scale_factors_to_one()
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700179 else:
180 if (max(opt_values) == opt_values[0] or
181 max(opt_values) == opt_values[len(opt_values)-1]):
182 estring = ('Warning: chart is at extreme range of locator '
183 'check.\n')
184 print estring
185 # find max and draw bbox
186 match_index = max_match.index(max(max_match, key=lambda x: x[0]))
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700187 self.scale = scale_start + scale_step * match_index
188 print 'Optimum scale factor: %.3f' % self.scale
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700189 top_left_scaled = max_match[match_index][1]
190 h, w = chart.shape
191 bottom_right_scaled = (top_left_scaled[0] + w,
192 top_left_scaled[1] + h)
Clemenz Portmannc47c8022017-04-04 09:10:30 -0700193 top_left = (int(top_left_scaled[0]/self.scale),
194 int(top_left_scaled[1]/self.scale))
195 bottom_right = (int(bottom_right_scaled[0]/self.scale),
196 int(bottom_right_scaled[1]/self.scale))
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700197 self.wnorm = float((bottom_right[0]) - top_left[0]) / scene.shape[1]
198 self.hnorm = float((bottom_right[1]) - top_left[1]) / scene.shape[0]
199 self.xnorm = float(top_left[0]) / scene.shape[1]
200 self.ynorm = float(top_left[1]) / scene.shape[0]
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700201
202
Will Guedes0f11cfb2018-04-09 08:47:31 -0500203def get_angle(input_img):
204 """Computes anglular inclination of chessboard in input_img.
205
206 Angle estimation algoritm description:
207 Input: 2D grayscale image of chessboard.
208 Output: Angle of rotation of chessboard perpendicular to
209 chessboard. Assumes chessboard and camera are parallel to
210 each other.
211
212 1) Use adaptive threshold to make image binary
213 2) Find countours
214 3) Filter out small contours
215 4) Filter out all non-square contours
216 5) Compute most common square shape.
217 The assumption here is that the most common square instances
218 are the chessboard squares. We've shown that with our current
219 tuning, we can robustly identify the squares on the sensor fusion
220 chessboard.
221 6) Return median angle of most common square shape.
222
223 USAGE NOTE: This function has been tuned to work for the chessboard used in
224 the sensor_fusion tests. See images in test_images/rotated_chessboard/ for
225 sample captures. If this function is used with other chessboards, it may not
226 work as expected.
227
228 TODO: Make algorithm more robust so it works on any type of
229 chessboard.
230
231 Args:
232 input_img (2D numpy.ndarray): Grayscale image stored as a 2D
233 numpy array.
234
235 Returns:
236 Median angle of squares in degrees identified in the image.
237 """
238 # Tuning parameters
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700239 min_square_area = (float)(input_img.shape[1] * 0.05)
Will Guedes0f11cfb2018-04-09 08:47:31 -0500240
241 # Creates copy of image to avoid modifying original.
242 img = numpy.array(input_img, copy=True)
243
244 # Scale pixel values from 0-1 to 0-255
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700245 img *= 255
Will Guedes0f11cfb2018-04-09 08:47:31 -0500246 img = img.astype(numpy.uint8)
247
248 thresh = cv2.adaptiveThreshold(
249 img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 201, 2)
250
251 # Find all contours
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700252 contours = []
253 cv2_version = cv2.__version__
254 if cv2_version.startswith('2.4.'):
255 contours, _ = cv2.findContours(
256 thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
257 elif cv2_version.startswith('3.2.'):
258 _, contours, _ = cv2.findContours(
259 thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
Will Guedes0f11cfb2018-04-09 08:47:31 -0500260
261 # Filter contours to squares only.
262 square_contours = []
263
264 for contour in contours:
265 rect = cv2.minAreaRect(contour)
266 _, (width, height), angle = rect
267
268 # Skip non-squares (with 0.1 tolerance)
269 tolerance = 0.1
270 if width < height * (1 - tolerance) or width > height * (1 + tolerance):
271 continue
272
273 # Remove very small contours.
274 # These are usually just tiny dots due to noise.
275 area = cv2.contourArea(contour)
276 if area < min_square_area:
277 continue
278
Clemenz Portmann51bdde52018-05-11 12:01:33 -0700279 if cv2_version.startswith('2.4.'):
280 box = numpy.int0(cv2.cv.BoxPoints(rect))
281 elif cv2_version.startswith('3.2.'):
282 box = numpy.int0(cv2.boxPoints(rect))
Will Guedes0f11cfb2018-04-09 08:47:31 -0500283 square_contours.append(contour)
284
285 areas = []
286 for contour in square_contours:
287 area = cv2.contourArea(contour)
288 areas.append(area)
289
290 median_area = numpy.median(areas)
291
292 filtered_squares = []
293 filtered_angles = []
294 for square in square_contours:
295 area = cv2.contourArea(square)
296 if area < median_area * 0.90 or area > median_area * 1.10:
297 continue
298
299 filtered_squares.append(square)
300 _, (width, height), angle = cv2.minAreaRect(square)
301 filtered_angles.append(angle)
302
303 if len(filtered_angles) < 10:
304 return None
305
306 return numpy.median(filtered_angles)
307
308
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700309class __UnitTest(unittest.TestCase):
310 """Run a suite of unit tests on this module.
311 """
312
313 def test_compute_image_sharpness(self):
314 """Unit test for compute_img_sharpness.
315
316 Test by using PNG of ISO12233 chart and blurring intentionally.
317 'sharpness' should drop off by sqrt(2) for 2x blur of image.
318
319 We do one level of blur as PNG image is not perfect.
320 """
321 yuv_full_scale = 1023.0
322 chart_file = os.path.join(os.environ['CAMERA_ITS_TOP'], 'pymodules',
323 'its', 'test_images', 'ISO12233.png')
324 chart = cv2.imread(chart_file, cv2.IMREAD_ANYDEPTH)
325 white_level = numpy.amax(chart).astype(float)
326 sharpness = {}
327 for j in [2, 4, 8]:
328 blur = cv2.blur(chart, (j, j))
329 blur = blur[:, :, numpy.newaxis]
330 sharpness[j] = (yuv_full_scale *
Clemenz Portmann51d765f2017-07-14 14:56:45 -0700331 its.image.compute_image_sharpness(blur /
332 white_level))
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700333 self.assertTrue(numpy.isclose(sharpness[2]/sharpness[4],
334 numpy.sqrt(2), atol=0.1))
335 self.assertTrue(numpy.isclose(sharpness[4]/sharpness[8],
336 numpy.sqrt(2), atol=0.1))
337
Will Guedes0f11cfb2018-04-09 08:47:31 -0500338 def test_get_angle_identify_unrotated_chessboard_angle(self):
339 basedir = os.path.join(
340 os.path.dirname(__file__), 'test_images/rotated_chessboards/')
341
342 normal_img_path = os.path.join(basedir, 'normal.jpg')
343 wide_img_path = os.path.join(basedir, 'wide.jpg')
344
345 normal_img = cv2.cvtColor(
346 cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY)
347 wide_img = cv2.cvtColor(
348 cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY)
349
350 assert get_angle(normal_img) == 0
351 assert get_angle(wide_img) == 0
352
353 def test_get_angle_identify_rotated_chessboard_angle(self):
354 basedir = os.path.join(
355 os.path.dirname(__file__), 'test_images/rotated_chessboards/')
356
357 # Array of the image files and angles containing rotated chessboards.
358 test_cases = [
359 ('_15_ccw', 15),
360 ('_30_ccw', 30),
361 ('_45_ccw', 45),
362 ('_60_ccw', 60),
363 ('_75_ccw', 75),
364 ('_90_ccw', 90)
365 ]
366
367 # For each rotated image pair (normal, wide). Check if angle is
368 # identified as expected.
369 for suffix, angle in test_cases:
370 # Define image paths
371 normal_img_path = os.path.join(
372 basedir, 'normal{}.jpg'.format(suffix))
373 wide_img_path = os.path.join(
374 basedir, 'wide{}.jpg'.format(suffix))
375
376 # Load and color convert images
377 normal_img = cv2.cvtColor(
378 cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY)
379 wide_img = cv2.cvtColor(
380 cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY)
381
382 # Assert angle is as expected up to 2.0 degrees of accuracy.
383 assert numpy.isclose(
384 abs(get_angle(normal_img)), angle, 2.0)
385 assert numpy.isclose(
386 abs(get_angle(wide_img)), angle, 2.0)
387
Yin-Chia Yehb2a38652016-10-14 16:41:06 -0700388
389if __name__ == '__main__':
390 unittest.main()