blob: 657f20c206de9a96cd29c2325c592481f3079635 [file] [log] [blame]
# Copyright 2015 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.
"""Validate aspect ratio, crop and FoV vs format."""
import logging
import math
import os.path
from mobly import test_runner
import numpy as np
import cv2
import its_base_test
import camera_properties_utils
import capture_request_utils
import image_processing_utils
import its_session_utils
import opencv_processing_utils
_ANDROID11_API_LEVEL = 30
_CIRCLE_COLOR = 0 # [0: black, 255: white].
_CIRCLE_MIN_AREA = 0.01 # 1% of image size.
_FOV_PERCENT_RTOL = 0.15 # Relative tolerance on circle FoV % to expected.
_LARGE_SIZE = 2000 # Size of a large image (compared against max(w, h)).
_NAME = os.path.splitext(os.path.basename(__file__))[0]
_PREVIEW_SIZE = (1920, 1080)
_THRESH_AR_L = 0.02 # Aspect ratio test threshold of large images.
_THRESH_AR_S = 0.075 # Aspect ratio test threshold of mini images.
_THRESH_CROP_L = 0.02 # Crop test threshold of large images.
_THRESH_CROP_S = 0.075 # Crop test threshold of mini images.
_THRESH_MIN_PIXEL = 4 # Crop test allowed offset.
# Before API level 30, only resolutions with the following listed aspect ratio
# are checked. Device launched after API level 30 will need to pass the test
# for all advertised resolutions. Device launched before API level 30 just
# needs to pass the test for all resolutions within these aspect ratios.
_AR_CHECKED_PRE_API_30 = ('4:3', '16:9', '18:9')
_AR_DIFF_ATOL = 0.01
def _check_skip_conditions(first_api_level, props):
"""Check the skip conditions based on first API level."""
if first_api_level < _ANDROID11_API_LEVEL: # Original constraint.
camera_properties_utils.skip_unless(camera_properties_utils.read_3a(props))
else: # Loosen from read_3a to enable LIMITED coverage.
camera_properties_utils.skip_unless(
camera_properties_utils.ae_lock(props) and
camera_properties_utils.awb_lock(props))
def _check_basic_correctness(cap, fmt_iter, w_iter, h_iter):
"""Check the capture for basic correctness."""
if cap['format'] != fmt_iter:
raise AssertionError
if cap['width'] != w_iter:
raise AssertionError
if cap['height'] != h_iter:
raise AssertionError
def _create_format_list():
"""Create format list for multiple capture objects.
Do multi-capture of 'iter' and 'cmpr'. Iterate through all the available
sizes of 'iter', and only use the size specified for 'cmpr'.
The 'cmpr' capture is only used so that we have multiple capture target
instead of just one, which should help catching more potential issues.
The test doesn't look into the output of 'cmpr' images at all.
The 'iter_max' or 'cmpr_size' key defines the maximal size being iterated
or selected for the 'iter' and 'cmpr' stream accordingly. None means no
upper bound is specified.
Args:
None
Returns:
format_list
"""
format_list = []
format_list.append({'iter': 'yuv', 'iter_max': None,
'cmpr': 'yuv', 'cmpr_size': _PREVIEW_SIZE})
format_list.append({'iter': 'yuv', 'iter_max': _PREVIEW_SIZE,
'cmpr': 'jpeg', 'cmpr_size': None})
format_list.append({'iter': 'yuv', 'iter_max': _PREVIEW_SIZE,
'cmpr': 'raw', 'cmpr_size': None})
format_list.append({'iter': 'jpeg', 'iter_max': None,
'cmpr': 'raw', 'cmpr_size': None})
format_list.append({'iter': 'jpeg', 'iter_max': None,
'cmpr': 'yuv', 'cmpr_size': _PREVIEW_SIZE})
return format_list
def _print_failed_test_results(failed_ar, failed_fov, failed_crop,
first_api_level, level_3):
"""Print failed test results."""
if failed_ar:
logging.error('Aspect ratio test summary')
logging.error('Images failed in the aspect ratio test:')
logging.error('Aspect ratio value: width / height')
for fa in failed_ar:
logging.error('%s', fa)
if failed_fov:
logging.error('FoV test summary')
logging.error('Images failed in the FoV test:')
for fov in failed_fov:
logging.error('%s', str(fov))
if failed_crop:
logging.error('Crop test summary')
logging.error('Images failed in the crop test:')
logging.error('Circle center (H x V) relative to the image center.')
for fc in failed_crop:
logging.error('%s', fc)
if failed_ar:
raise RuntimeError
if failed_fov:
raise RuntimeError
if first_api_level > _ANDROID11_API_LEVEL:
if failed_crop: # failed_crop = [] if run_crop_test = False.
raise RuntimeError
else:
if failed_crop and level_3:
raise RuntimeError
def _is_checked_aspect_ratio(first_api_level, w, h):
"""Determine if format aspect ratio is a checked on based of first_API."""
if first_api_level >= _ANDROID11_API_LEVEL:
return True
for ar_check in _AR_CHECKED_PRE_API_30:
match_ar_list = [float(x) for x in ar_check.split(':')]
match_ar = match_ar_list[0] / match_ar_list[1]
if np.isclose(float(w) / h, match_ar, atol=_AR_DIFF_ATOL):
return True
return False
def _calc_expected_circle_image_ratio(ref_fov, img_w, img_h):
"""Determine the circle image area ratio in percentage for a given image size.
Cropping happens either horizontally or vertically. In both cases crop results
in the visble area reduced by a ratio r (r < 1) and the circle will in turn
occupy ref_pct/r (percent) on the target image size.
Args:
ref_fov: dict with {fmt, % coverage, w, h, circle_w, circle_h}
img_w: the image width
img_h: the image height
Returns:
chk_percent: the expected circle image area ratio in percentage
"""
ar_ref = ref_fov['w'] / ref_fov['h']
ar_target = img_w / img_h
r = ar_ref / ar_target
if r < 1.0:
r = 1.0 / r
return ref_fov['percent'] * r
def _find_raw_fov_reference(cam, req, props, log_path):
"""Determine the circle coverage of the image in RAW reference image.
Captures a full-frame RAW and uses its aspect ratio and circle center
location as ground truth for the other jpeg or yuv images.
The intrinsics and distortion coefficients are meant for full-sized RAW,
so convert_capture_to_rgb_image returns a 2x downsampled version, so resizes
RGB back to full size.
If the device supports lens distortion correction, applies the coefficients on
the RAW image so it can be compared to YUV/JPEG outputs which are subject
to the same correction via ISP.
Finds circle size and location for reference values in calculations for other
formats.
Args:
cam: camera object
req: camera request
props: camera properties
log_path: location to save data
Returns:
ref_fov: dict with [fmt, % coverage, w, h, circle_w, circle_h]
cc_ct_gt: circle center position relative to the center of image.
aspect_ratio_gt: aspect ratio of the detected circle in float.
"""
logging.debug('Creating references for fov_coverage from RAW')
out_surface = {'format': 'raw'}
cap_raw = cam.do_capture(req, out_surface)
logging.debug('Captured RAW %dx%d', cap_raw['width'], cap_raw['height'])
img_raw = image_processing_utils.convert_capture_to_rgb_image(
cap_raw, props=props)
# Resize back up to full scale.
img_raw = cv2.resize(img_raw, (0, 0), fx=2.0, fy=2.0)
if (camera_properties_utils.distortion_correction(props) and
camera_properties_utils.intrinsic_calibration(props)):
logging.debug('Applying intrinsic calibration and distortion params')
fd = float(cap_raw['metadata']['android.lens.focalLength'])
k = camera_properties_utils.get_intrinsic_calibration(props, True, fd)
opencv_dist = camera_properties_utils.get_distortion_matrix(props)
img_raw = cv2.undistort(img_raw, k, opencv_dist)
# Get image size.
size_raw = img_raw.shape
w_raw = size_raw[1]
h_raw = size_raw[0]
img_name = '%s_%s_w%d_h%d.png' % (
os.path.join(log_path, _NAME), 'raw', w_raw, h_raw)
image_processing_utils.write_image(img_raw, img_name, True)
# Find circle.
img_raw *= 255 # cv2 needs images between [0,255].
circle_raw = opencv_processing_utils.find_circle(
img_raw, img_name, _CIRCLE_MIN_AREA, _CIRCLE_COLOR)
opencv_processing_utils.append_circle_center_to_img(circle_raw, img_raw,
img_name)
# Determine final return values.
aspect_ratio_gt = circle_raw['w'] / circle_raw['h']
cc_ct_gt = {'hori': circle_raw['x_offset'], 'vert': circle_raw['y_offset']}
raw_fov_percent = _calc_circle_image_ratio(circle_raw['r'], w_raw, h_raw)
ref_fov = {}
ref_fov['fmt'] = 'RAW'
ref_fov['percent'] = raw_fov_percent
ref_fov['w'] = w_raw
ref_fov['h'] = h_raw
ref_fov['circle_w'] = circle_raw['w']
ref_fov['circle_h'] = circle_raw['h']
logging.debug('Using RAW reference: %s', str(ref_fov))
return ref_fov, cc_ct_gt, aspect_ratio_gt
def _find_jpeg_fov_reference(cam, req, props, log_path):
"""Determine the circle coverage of the image in JPEG reference image.
Similar to _find_raw_fov_reference() and used when RAW is not available.
Args:
cam: camera object
req: camera request
props: camera properties
log_path: location to save data
Returns:
ref_fov: dict with [fmt, % coverage, w, h, circle_w, circle_h]
cc_ct_gt: circle center position relative to the center of image.
"""
ref_fov = {}
fmt = capture_request_utils.get_largest_jpeg_format(props)
# Capture and determine circle area in image.
cap = cam.do_capture(req, fmt)
w = cap['width']
h = cap['height']
img = image_processing_utils.convert_capture_to_rgb_image(cap, props)
img *= 255 # cv2 works with [0,255] images.
logging.debug('Captured JPEG %dx%d', w, h)
img_name = '%s_jpeg_w%d_h%d.png' % (os.path.join(log_path, _NAME), w, h)
circle_jpg = opencv_processing_utils.find_circle(
img, img_name, _CIRCLE_MIN_AREA, _CIRCLE_COLOR)
opencv_processing_utils.append_circle_center_to_img(circle_jpg, img,
img_name)
# Determine final return values.
cc_ct_gt = {'hori': circle_jpg['x_offset'], 'vert': circle_jpg['y_offset']}
fov_percent = _calc_circle_image_ratio(circle_jpg['r'], w, h)
ref_fov = {}
ref_fov['fmt'] = 'JPEG'
ref_fov['percent'] = fov_percent
ref_fov['w'] = w
ref_fov['h'] = h
ref_fov['circle_w'] = circle_jpg['w']
ref_fov['circle_h'] = circle_jpg['h']
logging.debug('Using JPEG reference: %s', str(ref_fov))
return ref_fov, cc_ct_gt
def _calc_circle_image_ratio(radius, img_w, img_h):
"""Calculate the percent of area the input circle covers in input image.
Args:
radius: radius of circle
img_w: int width of image
img_h: int height of image
Returns:
fov_percent: float % of image covered by circle
"""
return 100 * math.pi * math.pow(radius, 2) / (img_w * img_h)
def _check_fov(circle, ref_fov, w, h, first_api_level):
"""Check the FoV for correct size."""
fov_percent = _calc_circle_image_ratio(circle['r'], w, h)
chk_percent = _calc_expected_circle_image_ratio(ref_fov, w, h)
chk_enabled = _is_checked_aspect_ratio(first_api_level, w, h)
if chk_enabled and not np.isclose(fov_percent, chk_percent,
rtol=_FOV_PERCENT_RTOL):
e_msg = 'FoV %%: %.2f, Ref FoV %%: %.2f, ' % (fov_percent, chk_percent)
e_msg += 'TOL=%.f%%, img: %dx%d, ref: %dx%d' % (
_FOV_PERCENT_RTOL*100, w, h, ref_fov['w'], ref_fov['h'])
return e_msg
def _check_ar(circle, ar_gt, w, h, fmt_iter, fmt_cmpr):
"""Check the aspect ratio of the circle.
size is the larger of w or h.
if size >= LARGE_SIZE: use THRESH_AR_L
elif size == 0 (extreme case): THRESH_AR_S
elif 0 < image size < LARGE_SIZE: scale between THRESH_AR_S & THRESH_AR_L
Args:
circle: dict with circle parameters
ar_gt: aspect ratio ground truth to compare against
w: width of image
h: height of image
fmt_iter: format of primary capture
fmt_cmpr: format of secondary capture
Returns:
error string if check fails
"""
thresh_ar = max(_THRESH_AR_L, _THRESH_AR_S +
max(w, h) * (_THRESH_AR_L-_THRESH_AR_S) / _LARGE_SIZE)
ar = circle['w'] / circle['h']
if not np.isclose(ar, ar_gt, atol=thresh_ar):
e_msg = (f'{fmt_iter} with {fmt_cmpr} {w}x{h}: aspect_ratio {ar:.3f}, '
f'thresh {thresh_ar:.3f}')
return e_msg
def _check_crop(circle, cc_gt, w, h, fmt_iter, fmt_cmpr, crop_thresh_factor):
"""Check cropping.
if size >= LARGE_SIZE: use thresh_crop_l
elif size == 0 (extreme case): thresh_crop_s
elif 0 < size < LARGE_SIZE: scale between thresh_crop_s & thresh_crop_l
Also allow at least THRESH_MIN_PIXEL to prevent threshold being too tight
for very small circle.
Args:
circle: dict of circle values
cc_gt: circle center {'hori', 'vert'} ground truth (ref'd to img center)
w: width of image
h: height of image
fmt_iter: format of primary capture
fmt_cmpr: format of secondary capture
crop_thresh_factor: scaling factor for crop thresholds
Returns:
error string if check fails
"""
thresh_crop_l = _THRESH_CROP_L * crop_thresh_factor
thresh_crop_s = _THRESH_CROP_S * crop_thresh_factor
thresh_crop_hori = max(
[thresh_crop_l,
thresh_crop_s + w * (thresh_crop_l - thresh_crop_s) / _LARGE_SIZE,
_THRESH_MIN_PIXEL / circle['w']])
thresh_crop_vert = max(
[thresh_crop_l,
thresh_crop_s + h * (thresh_crop_l - thresh_crop_s) / _LARGE_SIZE,
_THRESH_MIN_PIXEL / circle['h']])
if (not np.isclose(circle['x_offset'], cc_gt['hori'],
atol=thresh_crop_hori) or
not np.isclose(circle['y_offset'], cc_gt['vert'],
atol=thresh_crop_vert)):
valid_x_range = (cc_gt['hori'] - thresh_crop_hori,
cc_gt['hori'] + thresh_crop_hori)
valid_y_range = (cc_gt['vert'] - thresh_crop_vert,
cc_gt['vert'] + thresh_crop_vert)
e_msg = (f'{fmt_iter} with {fmt_cmpr} {w}x{h} '
f"offset X {circle['x_offset']:.3f}, Y {circle['y_offset']:.3f}, "
f'valid X range: {valid_x_range[0]:.3f} ~ {valid_x_range[1]:.3f}, '
f'valid Y range: {valid_y_range[0]:.3f} ~ {valid_y_range[1]:.3f}')
return e_msg
class AspectRatioAndCropTest(its_base_test.ItsBaseTest):
"""Test aspect ratio/field of view/cropping for each tested fmt combinations.
This test checks for:
1. Aspect ratio: images are not stretched
2. Crop: center of images is not shifted
3. FOV: images cropped to keep maximum possible FOV with only 1 dimension
(horizontal or veritical) cropped.
Aspect ratio and FOV test runs on level3, full and limited devices.
Crop test only runs on level3 and full devices.
The test chart is a black circle inside a black square. When raw capture is
available, set the height vs. width ratio of the circle in the full-frame
raw as ground truth. In an ideal setup such ratio should be very close to
1.0, but here we just use the value derived from full resolution RAW as
ground truth to account for the possibility that the chart is not well
positioned to be precisely parallel to image sensor plane.
The test then compares the ground truth ratio with the same ratio measured
on images captued using different stream combinations of varying formats
('jpeg' and 'yuv') and resolutions.
If raw capture is unavailable, a full resolution JPEG image is used to setup
ground truth. In this case, the ground truth aspect ratio is defined as 1.0
and it is the tester's responsibility to make sure the test chart is
properly positioned so the detected circles indeed have aspect ratio close
to 1.0 assuming no bugs causing image stretched.
The aspect ratio test checks the aspect ratio of the detected circle and
it will fail if the aspect ratio differs too much from the ground truth
aspect ratio mentioned above.
The FOV test examines the ratio between the detected circle area and the
image size. When the aspect ratio of the test image is the same as the
ground truth image, the ratio should be very close to the ground truth
value. When the aspect ratio is different, the difference is factored in
per the expectation of the Camera2 API specification, which mandates the
FOV reduction from full sensor area must only occur in one dimension:
horizontally or vertically, and never both. For example, let's say a sensor
has a 16:10 full sensor FOV. For all 16:10 output images there should be no
FOV reduction on them. For 16:9 output images the FOV should be vertically
cropped by 9/10. For 4:3 output images the FOV should be cropped
horizontally instead and the ratio (r) can be calculated as follows:
(16 * r) / 10 = 4 / 3 => r = 40 / 48 = 0.8333
Say the circle is covering x percent of the 16:10 sensor on the full 16:10
FOV, and assume the circle in the center will never be cut in any output
sizes (this can be achieved by picking the right size and position of the
test circle), the from above cropping expectation we can derive on a 16:9
output image the circle will cover (x / 0.9) percent of the 16:9 image; on
a 4:3 output image the circle will cover (x / 0.8333) percent of the 4:3
image.
The crop test checks that the center of any output image remains aligned
with center of sensor's active area, no matter what kind of cropping or
scaling is applied. The test verifies that by checking the relative vector
from the image center to the center of detected circle remains unchanged.
The relative part is normalized by the detected circle size to account for
scaling effect.
"""
def test_aspect_ratio_and_crop(self):
logging.debug('Starting %s', _NAME)
failed_ar = [] # Streams failed the aspect ratio test.
failed_crop = [] # Streams failed the crop test.
failed_fov = [] # Streams that fail FoV test.
format_list = _create_format_list()
with its_session_utils.ItsSession(
device_id=self.dut.serial,
camera_id=self.camera_id,
hidden_physical_id=self.hidden_physical_id) as cam:
props = cam.get_camera_properties()
fls_logical = props['android.lens.info.availableFocalLengths']
logging.debug('logical available focal lengths: %s', str(fls_logical))
props = cam.override_with_hidden_physical_camera_props(props)
fls_physical = props['android.lens.info.availableFocalLengths']
logging.debug('physical available focal lengths: %s', str(fls_physical))
log_path = self.log_path
# Check SKIP conditions.
first_api_level = its_session_utils.get_first_api_level(self.dut.serial)
_check_skip_conditions(first_api_level, props)
# Load chart for scene.
its_session_utils.load_scene(
cam, props, self.scene, self.tablet, self.chart_distance)
# Determine camera capabilities.
full_or_better = camera_properties_utils.full_or_better(props)
level3 = camera_properties_utils.level3(props)
raw_avlb = camera_properties_utils.raw16(props)
debug = self.debug_mode
# Converge 3A.
cam.do_3a()
req = capture_request_utils.auto_capture_request()
# If raw is available and main camera, use it as ground truth.
if raw_avlb and (fls_physical == fls_logical):
ref_fov, cc_ct_gt, aspect_ratio_gt = _find_raw_fov_reference(
cam, req, props, log_path)
else:
aspect_ratio_gt = 1.0 # Ground truth circle width/height ratio.
ref_fov, cc_ct_gt = _find_jpeg_fov_reference(cam, req, props, log_path)
run_crop_test = full_or_better and raw_avlb
if run_crop_test:
# Normalize the circle size to 1/4 of the image size, so that
# circle size won't affect the crop test result
crop_thresh_factor = ((min(ref_fov['w'], ref_fov['h']) / 4.0) /
max(ref_fov['circle_w'], ref_fov['circle_h']))
else:
logging.debug('Crop test skipped')
# Take pictures of each settings with all the image sizes available.
for fmt in format_list:
fmt_iter = fmt['iter']
fmt_cmpr = fmt['cmpr']
# Get the size of 'cmpr'.
sizes = capture_request_utils.get_available_output_sizes(
fmt_cmpr, props, fmt['cmpr_size'])
if not sizes: # Device might not support RAW.
continue
w_cmpr, h_cmpr = sizes[0][0], sizes[0][1]
for size_iter in capture_request_utils.get_available_output_sizes(
fmt_iter, props, fmt['iter_max']):
w_iter, h_iter = size_iter[0], size_iter[1]
# Skip same format/size combination: ITS doesn't handle that properly.
if w_iter*h_iter == w_cmpr*h_cmpr and fmt_iter == fmt_cmpr:
continue
out_surface = [{'width': w_iter, 'height': h_iter,
'format': fmt_iter}]
out_surface.append({'width': w_cmpr, 'height': h_cmpr,
'format': fmt_cmpr})
cap = cam.do_capture(req, out_surface)[0]
_check_basic_correctness(cap, fmt_iter, w_iter, h_iter)
logging.debug('Captured %s with %s %dx%d. Compared size: %dx%d',
fmt_iter, fmt_cmpr, w_iter, h_iter, w_cmpr, h_cmpr)
img = image_processing_utils.convert_capture_to_rgb_image(cap)
img *= 255 # cv2 uses [0, 255].
img_name = '%s_%s_with_%s_w%d_h%d.png' % (
os.path.join(log_path, _NAME), fmt_iter, fmt_cmpr, w_iter, h_iter)
circle = opencv_processing_utils.find_circle(
img, img_name, _CIRCLE_MIN_AREA, _CIRCLE_COLOR)
if debug:
opencv_processing_utils.append_circle_center_to_img(circle, img,
img_name)
# Check pass/fail for fov coverage for all fmts in AR_CHECKED
img /= 255 # image_processing_utils uses [0, 1].
fov_chk_msg = _check_fov(circle, ref_fov, w_iter, h_iter,
first_api_level)
if fov_chk_msg:
failed_fov.append(fov_chk_msg)
image_processing_utils.write_image(img, img_name, True)
# Check pass/fail for aspect ratio.
ar_chk_msg = _check_ar(
circle, aspect_ratio_gt, w_iter, h_iter, fmt_iter, fmt_cmpr)
if ar_chk_msg:
failed_ar.append(ar_chk_msg)
image_processing_utils.write_image(img, img_name, True)
# Check pass/fail for crop.
if run_crop_test:
crop_chk_msg = _check_crop(circle, cc_ct_gt, w_iter, h_iter,
fmt_iter, fmt_cmpr, crop_thresh_factor)
if crop_chk_msg:
failed_crop.append(crop_chk_msg)
image_processing_utils.write_image(img, img_name, True)
# Print any failed test results.
_print_failed_test_results(failed_ar, failed_fov, failed_crop,
first_api_level, level3)
if __name__ == '__main__':
test_runner.main()