1# Copyright 2015 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
15import its.image
16import its.caps
17import its.device
18import its.objects
19import os.path
20import cv2
21import numpy as np
22
23
24def main():
25    """ Test aspect ratio and check if images are cropped correctly under each
26    output size
27    Aspect ratio test runs on level3, full and limited devices. Crop test only
28    runs on full and level3 devices.
29    The test image is a black circle inside a black square. When raw capture is
30    available, set the height vs. width ratio of the circle in the full-frame
31    raw as ground truth. Then compare with images of request combinations of
32    different formats ("jpeg" and "yuv") and sizes.
33    If raw capture is unavailable, take a picture of the test image right in
34    front to eliminate shooting angle effect. the height vs. width ratio for
35    the circle should be close to 1. Considering shooting position error, aspect
36    ratio greater than 1.05 or smaller than 0.95 will fail the test.
37    """
38    NAME = os.path.basename(__file__).split(".")[0]
39    LARGE_SIZE = 2000   # Define the size of a large image
40    # pass/fail threshold of large size images for aspect ratio test
41    THRES_L_AR_TEST = 0.02
42    # pass/fail threshold of mini size images for aspect ratio test
43    THRES_XS_AR_TEST = 0.05
44    # pass/fail threshold of large size images for crop test
45    THRES_L_CP_TEST = 0.02
46    # pass/fail threshold of mini size images for crop test
47    THRES_XS_CP_TEST = 0.05
48    # Crop test will allow at least THRES_MIN_PIXEL offset
49    THRES_MIN_PIXEL = 4
50    PREVIEW_SIZE = (1920, 1080) # preview size
51    aspect_ratio_gt = 1  # ground truth
52    failed_ar = []  # streams failed the aspect ration test
53    failed_crop = [] # streams failed the crop test
54    format_list = [] # format list for multiple capture objects.
55    # Do multi-capture of "iter" and "cmpr". Iterate through all the
56    # available sizes of "iter", and only use the size specified for "cmpr"
57    # Do single-capture to cover untouched sizes in multi-capture when needed.
58    format_list.append({"iter": "yuv", "iter_max": None,
59                        "cmpr": "yuv", "cmpr_size": PREVIEW_SIZE})
60    format_list.append({"iter": "yuv", "iter_max": PREVIEW_SIZE,
61                        "cmpr": "jpeg", "cmpr_size": None})
62    format_list.append({"iter": "yuv", "iter_max": PREVIEW_SIZE,
63                        "cmpr": "raw", "cmpr_size": None})
64    format_list.append({"iter": "jpeg", "iter_max": None,
65                        "cmpr": "raw", "cmpr_size": None})
66    format_list.append({"iter": "jpeg", "iter_max": None,
67                        "cmpr": "yuv", "cmpr_size": PREVIEW_SIZE})
68    with its.device.ItsSession() as cam:
69        props = cam.get_camera_properties()
70        # Todo: test for radial distortion enabled devices has not yet been
71        # implemented
72        its.caps.skip_unless(not its.caps.radial_distortion_correction(props))
73        its.caps.skip_unless(its.caps.read_3a(props))
74        full_device = its.caps.full_or_better(props)
75        limited_device = its.caps.limited(props)
76        its.caps.skip_unless(full_device or limited_device)
77        level3_device = its.caps.level3(props)
78        raw_avlb = its.caps.raw16(props)
79        run_crop_test = (level3_device or full_device) and raw_avlb
80        if not run_crop_test:
81            print "Crop test skipped"
82        debug = its.caps.debug_mode()
83        # Converge 3A and get the estimates.
84        sens, exp, gains, xform, focus = cam.do_3a(get_results=True,
85                                                   lock_ae=True, lock_awb=True)
86        print "AE sensitivity %d, exposure %dms" % (sens, exp / 1000000.0)
87        print "AWB gains", gains
88        print "AWB transform", xform
89        print "AF distance", focus
90        req = its.objects.manual_capture_request(
91                sens, exp, focus, True, props)
92        xform_rat = its.objects.float_to_rational(xform)
93        req["android.colorCorrection.gains"] = gains
94        req["android.colorCorrection.transform"] = xform_rat
95
96        # If raw capture is available, use it as ground truth.
97        if raw_avlb:
98            # Capture full-frame raw. Use its aspect ratio and circle center
99            # location as ground truth for the other jepg or yuv images.
100            out_surface = {"format": "raw"}
101            cap_raw = cam.do_capture(req, out_surface)
102            print "Captured %s %dx%d" % ("raw", cap_raw["width"],
103                                         cap_raw["height"])
104            img_raw = its.image.convert_capture_to_rgb_image(cap_raw,
105                                                             props=props)
106            size_raw = img_raw.shape
107            img_name = "%s_%s_w%d_h%d.png" \
108                       % (NAME, "raw", size_raw[1], size_raw[0])
109            aspect_ratio_gt, cc_ct_gt, circle_size_raw = measure_aspect_ratio(
110                                                         img_raw, 1, img_name,
111                                                         debug)
112            # Normalize the circle size to 1/4 of the image size, so that
113            # circle size won"t affect the crop test result
114            factor_cp_thres = (min(size_raw[0:1])/4.0) / max(circle_size_raw)
115            thres_l_cp_test = THRES_L_CP_TEST * factor_cp_thres
116            thres_xs_cp_test = THRES_XS_CP_TEST * factor_cp_thres
117
118        # Take pictures of each settings with all the image sizes available.
119        for fmt in format_list:
120            fmt_iter = fmt["iter"]
121            fmt_cmpr = fmt["cmpr"]
122            dual_target = fmt_cmpr is not "none"
123            # Get the size of "cmpr"
124            if dual_target:
125                sizes = its.objects.get_available_output_sizes(
126                        fmt_cmpr, props, fmt["cmpr_size"])
127                if len(sizes) == 0: # device might not support RAW
128                    continue
129                size_cmpr = sizes[0]
130            for size_iter in its.objects.get_available_output_sizes(
131                    fmt_iter, props, fmt["iter_max"]):
132                w_iter = size_iter[0]
133                h_iter = size_iter[1]
134                # Skip testing same format/size combination
135                # ITS does not handle that properly now
136                if dual_target and \
137                        w_iter == size_cmpr[0] and \
138                        h_iter == size_cmpr[1] and \
139                        fmt_iter == fmt_cmpr:
140                    continue
141                out_surface = [{"width": w_iter,
142                                "height": h_iter,
143                                "format": fmt_iter}]
144                if dual_target:
145                    out_surface.append({"width": size_cmpr[0],
146                                        "height": size_cmpr[1],
147                                        "format": fmt_cmpr})
148                cap = cam.do_capture(req, out_surface)
149                if dual_target:
150                    frm_iter = cap[0]
151                else:
152                    frm_iter = cap
153                assert (frm_iter["format"] == fmt_iter)
154                assert (frm_iter["width"] == w_iter)
155                assert (frm_iter["height"] == h_iter)
156                print "Captured %s with %s %dx%d" \
157                        % (fmt_iter, fmt_cmpr, w_iter, h_iter)
158                img = its.image.convert_capture_to_rgb_image(frm_iter)
159                img_name = "%s_%s_with_%s_w%d_h%d.png" \
160                           % (NAME, fmt_iter, fmt_cmpr, w_iter, h_iter)
161                aspect_ratio, cc_ct, (cc_w, cc_h) = \
162                        measure_aspect_ratio(img, raw_avlb, img_name,
163                                             debug)
164                # check pass/fail for aspect ratio
165                # image size >= LARGE_SIZE: use THRES_L_AR_TEST
166                # image size == 0 (extreme case): THRES_XS_AR_TEST
167                # 0 < image size < LARGE_SIZE: scale between THRES_XS_AR_TEST
168                # and THRES_L_AR_TEST
169                thres_ar_test = max(THRES_L_AR_TEST,
170                        THRES_XS_AR_TEST + max(w_iter, h_iter) *
171                        (THRES_L_AR_TEST-THRES_XS_AR_TEST)/LARGE_SIZE)
172                thres_range_ar = (aspect_ratio_gt-thres_ar_test,
173                                  aspect_ratio_gt+thres_ar_test)
174                if aspect_ratio < thres_range_ar[0] \
175                        or aspect_ratio > thres_range_ar[1]:
176                    failed_ar.append({"fmt_iter": fmt_iter,
177                                      "fmt_cmpr": fmt_cmpr,
178                                      "w": w_iter, "h": h_iter,
179                                      "ar": aspect_ratio,
180                                      "valid_range": thres_range_ar})
181
182                # check pass/fail for crop
183                if run_crop_test:
184                    # image size >= LARGE_SIZE: use thres_l_cp_test
185                    # image size == 0 (extreme case): thres_xs_cp_test
186                    # 0 < image size < LARGE_SIZE: scale between
187                    # thres_xs_cp_test and thres_l_cp_test
188                    # Also, allow at least THRES_MIN_PIXEL off to
189                    # prevent threshold being too tight for very
190                    # small circle
191                    thres_hori_cp_test = max(thres_l_cp_test,
192                            thres_xs_cp_test + w_iter *
193                            (thres_l_cp_test-thres_xs_cp_test)/LARGE_SIZE)
194                    min_threshold_h = THRES_MIN_PIXEL / cc_w
195                    thres_hori_cp_test = max(thres_hori_cp_test,
196                            min_threshold_h)
197                    thres_range_h_cp = (cc_ct_gt["hori"]-thres_hori_cp_test,
198                                        cc_ct_gt["hori"]+thres_hori_cp_test)
199                    thres_vert_cp_test = max(thres_l_cp_test,
200                            thres_xs_cp_test + h_iter *
201                            (thres_l_cp_test-thres_xs_cp_test)/LARGE_SIZE)
202                    min_threshold_v = THRES_MIN_PIXEL / cc_h
203                    thres_vert_cp_test = max(thres_vert_cp_test,
204                            min_threshold_v)
205                    thres_range_v_cp = (cc_ct_gt["vert"]-thres_vert_cp_test,
206                                        cc_ct_gt["vert"]+thres_vert_cp_test)
207                    if cc_ct["hori"] < thres_range_h_cp[0] \
208                            or cc_ct["hori"] > thres_range_h_cp[1] \
209                            or cc_ct["vert"] < thres_range_v_cp[0] \
210                            or cc_ct["vert"] > thres_range_v_cp[1]:
211                        failed_crop.append({"fmt_iter": fmt_iter,
212                                            "fmt_cmpr": fmt_cmpr,
213                                            "w": w_iter, "h": h_iter,
214                                            "ct_hori": cc_ct["hori"],
215                                            "ct_vert": cc_ct["vert"],
216                                            "valid_range_h": thres_range_h_cp,
217                                            "valid_range_v": thres_range_v_cp})
218
219        # Print aspect ratio test results
220        failed_image_number_for_aspect_ratio_test = len(failed_ar)
221        if failed_image_number_for_aspect_ratio_test > 0:
222            print "\nAspect ratio test summary"
223            print "Images failed in the aspect ratio test:"
224            print "Aspect ratio value: width / height"
225        for fa in failed_ar:
226            print "%s with %s %dx%d: %.3f; valid range: %.3f ~ %.3f" % \
227                  (fa["fmt_iter"], fa["fmt_cmpr"], fa["w"], fa["h"], fa["ar"],
228                   fa["valid_range"][0], fa["valid_range"][1])
229
230        # Print crop test results
231        failed_image_number_for_crop_test = len(failed_crop)
232        if failed_image_number_for_crop_test > 0:
233            print "\nCrop test summary"
234            print "Images failed in the crop test:"
235            print "Circle center position, (horizontal x vertical), listed " \
236                  "below is relative to the image center."
237        for fc in failed_crop:
238            print "%s with %s %dx%d: %.3f x %.3f; " \
239                    "valid horizontal range: %.3f ~ %.3f; " \
240                    "valid vertical range: %.3f ~ %.3f" \
241                    % (fc["fmt_iter"], fc["fmt_cmpr"], fc["w"], fc["h"],
242                    fc["ct_hori"], fc["ct_vert"], fc["valid_range_h"][0],
243                    fc["valid_range_h"][1], fc["valid_range_v"][0],
244                    fc["valid_range_v"][1])
245
246        assert (failed_image_number_for_aspect_ratio_test == 0)
247        if level3_device:
248            assert (failed_image_number_for_crop_test == 0)
249
250
251def measure_aspect_ratio(img, raw_avlb, img_name, debug):
252    """ Measure the aspect ratio of the black circle in the test image.
253
254    Args:
255        img: Numpy float image array in RGB, with pixel values in [0,1].
256        raw_avlb: True: raw capture is available; False: raw capture is not
257             available.
258        img_name: string with image info of format and size.
259        debug: boolean for whether in debug mode.
260    Returns:
261        aspect_ratio: aspect ratio number in float.
262        cc_ct: circle center position relative to the center of image.
263        (circle_w, circle_h): tuple of the circle size
264    """
265    size = img.shape
266    img = img * 255
267    # Gray image
268    img_gray = 0.299 * img[:,:,2] + 0.587 * img[:,:,1] + 0.114 * img[:,:,0]
269
270    # otsu threshold to binarize the image
271    ret3, img_bw = cv2.threshold(np.uint8(img_gray), 0, 255,
272            cv2.THRESH_BINARY + cv2.THRESH_OTSU)
273
274    # connected component
275    contours, hierarchy = cv2.findContours(255-img_bw, cv2.RETR_TREE,
276            cv2.CHAIN_APPROX_SIMPLE)
277
278    # Check each component and find the black circle
279    min_cmpt = size[0] * size[1] * 0.005
280    max_cmpt = size[0] * size[1] * 0.35
281    num_circle = 0
282    aspect_ratio = 0
283    for ct, hrch in zip(contours, hierarchy[0]):
284        # The radius of the circle is 1/3 of the length of the square, meaning
285        # around 1/3 of the area of the square
286        # Parental component should exist and the area is acceptable.
287        # The coutour of a circle should have at least 5 points
288        child_area = cv2.contourArea(ct)
289        if hrch[3] == -1 or child_area < min_cmpt or child_area > max_cmpt or \
290                len(ct) < 15:
291            continue
292        # Check the shapes of current component and its parent
293        child_shape = component_shape(ct)
294        parent = hrch[3]
295        prt_shape = component_shape(contours[parent])
296        prt_area = cv2.contourArea(contours[parent])
297        dist_x = abs(child_shape["ctx"]-prt_shape["ctx"])
298        dist_y = abs(child_shape["cty"]-prt_shape["cty"])
299        # 1. 0.56*Parent"s width < Child"s width < 0.76*Parent"s width.
300        # 2. 0.56*Parent"s height < Child"s height < 0.76*Parent"s height.
301        # 3. Child"s width > 0.1*Image width
302        # 4. Child"s height > 0.1*Image height
303        # 5. 0.25*Parent"s area < Child"s area < 0.45*Parent"s area
304        # 6. Child is a black, and Parent is white
305        # 7. Center of Child and center of parent should overlap
306        if prt_shape["width"] * 0.56 < child_shape["width"] \
307                < prt_shape["width"] * 0.76 \
308                and prt_shape["height"] * 0.56 < child_shape["height"] \
309                < prt_shape["height"] * 0.76 \
310                and child_shape["width"] > 0.1 * size[1] \
311                and child_shape["height"] > 0.1 * size[0] \
312                and 0.30 * prt_area < child_area < 0.50 * prt_area \
313                and img_bw[child_shape["cty"]][child_shape["ctx"]] == 0 \
314                and img_bw[child_shape["top"]][child_shape["left"]] == 255 \
315                and dist_x < 0.1 * child_shape["width"] \
316                and dist_y < 0.1 * child_shape["height"]:
317            # If raw capture is not available, check the camera is placed right
318            # in front of the test page:
319            # 1. Distances between parent and child horizontally on both side,0
320            #    dist_left and dist_right, should be close.
321            # 2. Distances between parent and child vertically on both side,
322            #    dist_top and dist_bottom, should be close.
323            if not raw_avlb:
324                dist_left = child_shape["left"] - prt_shape["left"]
325                dist_right = prt_shape["right"] - child_shape["right"]
326                dist_top = child_shape["top"] - prt_shape["top"]
327                dist_bottom = prt_shape["bottom"] - child_shape["bottom"]
328                if abs(dist_left-dist_right) > 0.05 * child_shape["width"] or \
329                        abs(dist_top-dist_bottom) > \
330                        0.05 * child_shape["height"]:
331                    continue
332            # Calculate aspect ratio
333            aspect_ratio = float(child_shape["width"]) / \
334                           float(child_shape["height"])
335            circle_ctx = child_shape["ctx"]
336            circle_cty = child_shape["cty"]
337            circle_w = float(child_shape["width"])
338            circle_h = float(child_shape["height"])
339            cc_ct = {"hori": float(child_shape["ctx"]-size[1]/2) / circle_w,
340                     "vert": float(child_shape["cty"]-size[0]/2) / circle_h}
341            num_circle += 1
342            # If more than one circle found, break
343            if num_circle == 2:
344                break
345
346    if num_circle == 0:
347        its.image.write_image(img/255, img_name, True)
348        print "No black circle was detected. Please take pictures according " \
349              "to instruction carefully!\n"
350        assert (num_circle == 1)
351
352    if num_circle > 1:
353        its.image.write_image(img/255, img_name, True)
354        print "More than one black circle was detected. Background of scene " \
355              "may be too complex.\n"
356        assert (num_circle == 1)
357
358    # draw circle center and image center, and save the image
359    line_width = max(1, max(size)/500)
360    move_text_dist = line_width * 3
361    cv2.line(img, (circle_ctx, circle_cty), (size[1]/2, size[0]/2),
362             (255, 0, 0), line_width)
363    if circle_cty > size[0]/2:
364        move_text_down_circle = 4
365        move_text_down_image = -1
366    else:
367        move_text_down_circle = -1
368        move_text_down_image = 4
369    if circle_ctx > size[1]/2:
370        move_text_right_circle = 2
371        move_text_right_image = -1
372    else:
373        move_text_right_circle = -1
374        move_text_right_image = 2
375    # circle center
376    text_circle_x = move_text_dist * move_text_right_circle + circle_ctx
377    text_circle_y = move_text_dist * move_text_down_circle + circle_cty
378    cv2.circle(img, (circle_ctx, circle_cty), line_width*2, (255, 0, 0), -1)
379    cv2.putText(img, "circle center", (text_circle_x, text_circle_y),
380                cv2.FONT_HERSHEY_SIMPLEX, line_width/2.0, (255, 0, 0),
381                line_width)
382    # image center
383    text_imgct_x = move_text_dist * move_text_right_image + size[1]/2
384    text_imgct_y = move_text_dist * move_text_down_image + size[0]/2
385    cv2.circle(img, (size[1]/2, size[0]/2), line_width*2, (255, 0, 0), -1)
386    cv2.putText(img, "image center", (text_imgct_x, text_imgct_y),
387                cv2.FONT_HERSHEY_SIMPLEX, line_width/2.0, (255, 0, 0),
388                line_width)
389    if debug:
390        its.image.write_image(img/255, img_name, True)
391
392    print "Aspect ratio: %.3f" % aspect_ratio
393    print "Circle center position regarding to image center: %.3fx%.3f" % \
394            (cc_ct["vert"], cc_ct["hori"])
395    return aspect_ratio, cc_ct, (circle_w, circle_h)
396
397
398def component_shape(contour):
399    """ Measure the shape for a connected component in the aspect ratio test
400
401    Args:
402        contour: return from cv2.findContours. A list of pixel coordinates of
403        the contour.
404
405    Returns:
406        The most left, right, top, bottom pixel location, height, width, and
407        the center pixel location of the contour.
408    """
409    shape = {"left": np.inf, "right": 0, "top": np.inf, "bottom": 0,
410             "width": 0, "height": 0, "ctx": 0, "cty": 0}
411    for pt in contour:
412        if pt[0][0] < shape["left"]:
413            shape["left"] = pt[0][0]
414        if pt[0][0] > shape["right"]:
415            shape["right"] = pt[0][0]
416        if pt[0][1] < shape["top"]:
417            shape["top"] = pt[0][1]
418        if pt[0][1] > shape["bottom"]:
419            shape["bottom"] = pt[0][1]
420    shape["width"] = shape["right"] - shape["left"] + 1
421    shape["height"] = shape["bottom"] - shape["top"] + 1
422    shape["ctx"] = (shape["left"]+shape["right"])/2
423    shape["cty"] = (shape["top"]+shape["bottom"])/2
424    return shape
425
426
427if __name__ == "__main__":
428    main()
429