Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 1 | # Copyright 2022 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 | """Image Field-of-View utilities for aspect ratio, crop, and FoV tests.""" |
| 15 | |
| 16 | |
| 17 | import logging |
| 18 | import math |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 19 | |
| 20 | import cv2 |
Clemenz Portmann | b947041 | 2024-06-11 08:40:33 -0700 | [diff] [blame] | 21 | import numpy as np |
| 22 | |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 23 | import camera_properties_utils |
| 24 | import capture_request_utils |
| 25 | import image_processing_utils |
| 26 | import opencv_processing_utils |
| 27 | |
| 28 | CIRCLE_COLOR = 0 # [0: black, 255: white] |
| 29 | CIRCLE_MIN_AREA = 0.01 # 1% of image size |
Clemenz Portmann | 1ad7849 | 2022-03-29 15:48:01 -0700 | [diff] [blame] | 30 | FOV_PERCENT_RTOL = 0.15 # Relative tolerance on circle FoV % to expected. |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 31 | LARGE_SIZE_IMAGE = 2000 # Size of a large image (compared against max(w, h)) |
Leslie Shaw | 0704d8c | 2025-01-29 19:03:27 -0800 | [diff] [blame^] | 32 | MIN_FOV = 10 |
| 33 | MAX_FOV = 130 |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 34 | THRESH_AR_L = 0.02 # Aspect ratio test threshold of large images |
| 35 | THRESH_AR_S = 0.075 # Aspect ratio test threshold of mini images |
| 36 | THRESH_CROP_L = 0.02 # Crop test threshold of large images |
| 37 | THRESH_CROP_S = 0.075 # Crop test threshold of mini images |
| 38 | THRESH_MIN_PIXEL = 4 # Crop test allowed offset |
| 39 | |
| 40 | |
Leslie Shaw | 0704d8c | 2025-01-29 19:03:27 -0800 | [diff] [blame^] | 41 | def calc_camera_fov_from_metadata(metadata, props): |
| 42 | """Get field of view of camera. |
| 43 | |
| 44 | Args: |
| 45 | metadata: capture result's metadata. |
| 46 | props: obj; camera properties object. |
| 47 | Returns: |
| 48 | fov: int; field of view of camera. |
| 49 | """ |
| 50 | sensor_size = props['android.sensor.info.physicalSize'] |
| 51 | diag = math.sqrt(sensor_size['height']**2 + sensor_size['width']**2) |
| 52 | fl = metadata['android.lens.focalLength'] |
| 53 | logging.debug('Focal length: %.3f', fl) |
| 54 | fov = 2 * math.degrees(math.atan(diag / (2 * fl))) |
| 55 | logging.debug('Field of view: %.1f degrees', fov) |
| 56 | |
| 57 | if not MIN_FOV <= fov <= MAX_FOV: |
| 58 | raise AssertionError(f'FoV error: {fov:.1f}') |
| 59 | return fov |
| 60 | |
leslieshaw | e711d05 | 2023-12-07 16:52:30 -0800 | [diff] [blame] | 61 | def calc_scaler_crop_region_ratio(scaler_crop_region, props): |
| 62 | """Calculate ratio of scaler crop region area over active array area. |
| 63 | |
| 64 | Args: |
| 65 | scaler_crop_region: Rect(left, top, right, bottom) |
| 66 | props: camera properties |
| 67 | |
| 68 | Returns: |
| 69 | ratio of scaler crop region area over active array area |
| 70 | """ |
| 71 | a = props['android.sensor.info.activeArraySize'] |
| 72 | s = scaler_crop_region |
| 73 | logging.debug('Active array size: %s', a) |
| 74 | active_array_area = (a['right'] - a['left']) * (a['bottom'] - a['top']) |
| 75 | scaler_crop_region_area = (s['right'] - s['left']) * (s['bottom'] - s['top']) |
| 76 | crop_region_active_array_ratio = scaler_crop_region_area / active_array_area |
| 77 | return crop_region_active_array_ratio |
| 78 | |
| 79 | |
Clemenz Portmann | 1ad7849 | 2022-03-29 15:48:01 -0700 | [diff] [blame] | 80 | def check_fov(circle, ref_fov, w, h): |
| 81 | """Check the FoV for correct size.""" |
| 82 | fov_percent = calc_circle_image_ratio(circle['r'], w, h) |
| 83 | chk_percent = calc_expected_circle_image_ratio(ref_fov, w, h) |
| 84 | if not math.isclose(fov_percent, chk_percent, rel_tol=FOV_PERCENT_RTOL): |
| 85 | e_msg = (f'FoV %: {fov_percent:.2f}, Ref FoV %: {chk_percent:.2f}, ' |
| 86 | f'TOL={FOV_PERCENT_RTOL*100}%, img: {w}x{h}, ref: ' |
| 87 | f"{ref_fov['w']}x{ref_fov['h']}") |
| 88 | return e_msg |
| 89 | |
| 90 | |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 91 | def check_ar(circle, ar_gt, w, h, e_msg_stem): |
| 92 | """Check the aspect ratio of the circle. |
| 93 | |
| 94 | size is the larger of w or h. |
| 95 | if size >= LARGE_SIZE_IMAGE: use THRESH_AR_L |
| 96 | elif size == 0 (extreme case): THRESH_AR_S |
| 97 | elif 0 < image size < LARGE_SIZE_IMAGE: scale between THRESH_AR_S & AR_L |
| 98 | |
| 99 | Args: |
| 100 | circle: dict with circle parameters |
| 101 | ar_gt: aspect ratio ground truth to compare against |
| 102 | w: width of image |
| 103 | h: height of image |
| 104 | e_msg_stem: customized string for error message |
| 105 | |
| 106 | Returns: |
| 107 | error string if check fails |
| 108 | """ |
| 109 | thresh_ar = max(THRESH_AR_L, THRESH_AR_S + |
| 110 | max(w, h) * (THRESH_AR_L-THRESH_AR_S) / LARGE_SIZE_IMAGE) |
| 111 | ar = circle['w'] / circle['h'] |
| 112 | if not math.isclose(ar, ar_gt, abs_tol=thresh_ar): |
| 113 | e_msg = (f'{e_msg_stem} {w}x{h}: aspect_ratio {ar:.3f}, ' |
| 114 | f'thresh {thresh_ar:.3f}') |
| 115 | return e_msg |
| 116 | |
| 117 | |
| 118 | def check_crop(circle, cc_gt, w, h, e_msg_stem, crop_thresh_factor): |
| 119 | """Check cropping. |
| 120 | |
| 121 | if size >= LARGE_SIZE_IMAGE: use thresh_crop_l |
| 122 | elif size == 0 (extreme case): thresh_crop_s |
| 123 | elif 0 < size < LARGE_SIZE_IMAGE: scale between thresh_crop_s & thresh_crop_l |
| 124 | Also allow at least THRESH_MIN_PIXEL to prevent threshold being too tight |
| 125 | for very small circle. |
| 126 | |
| 127 | Args: |
| 128 | circle: dict of circle values |
| 129 | cc_gt: circle center {'hori', 'vert'} ground truth (ref'd to img center) |
| 130 | w: width of image |
| 131 | h: height of image |
| 132 | e_msg_stem: text to customize error message |
| 133 | crop_thresh_factor: scaling factor for crop thresholds |
| 134 | |
| 135 | Returns: |
| 136 | error string if check fails |
| 137 | """ |
| 138 | thresh_crop_l = THRESH_CROP_L * crop_thresh_factor |
| 139 | thresh_crop_s = THRESH_CROP_S * crop_thresh_factor |
| 140 | thresh_crop_hori = max( |
| 141 | [thresh_crop_l, |
| 142 | thresh_crop_s + w * (thresh_crop_l - thresh_crop_s) / LARGE_SIZE_IMAGE, |
| 143 | THRESH_MIN_PIXEL / circle['w']]) |
| 144 | thresh_crop_vert = max( |
| 145 | [thresh_crop_l, |
| 146 | thresh_crop_s + h * (thresh_crop_l - thresh_crop_s) / LARGE_SIZE_IMAGE, |
| 147 | THRESH_MIN_PIXEL / circle['h']]) |
| 148 | |
| 149 | if (not math.isclose(circle['x_offset'], cc_gt['hori'], |
| 150 | abs_tol=thresh_crop_hori) or |
| 151 | not math.isclose(circle['y_offset'], cc_gt['vert'], |
| 152 | abs_tol=thresh_crop_vert)): |
| 153 | valid_x_range = (cc_gt['hori'] - thresh_crop_hori, |
| 154 | cc_gt['hori'] + thresh_crop_hori) |
| 155 | valid_y_range = (cc_gt['vert'] - thresh_crop_vert, |
| 156 | cc_gt['vert'] + thresh_crop_vert) |
| 157 | e_msg = (f'{e_msg_stem} {w}x{h} ' |
| 158 | f"offset X {circle['x_offset']:.3f}, Y {circle['y_offset']:.3f}, " |
| 159 | f'valid X range: {valid_x_range[0]:.3f} ~ {valid_x_range[1]:.3f}, ' |
| 160 | f'valid Y range: {valid_y_range[0]:.3f} ~ {valid_y_range[1]:.3f}') |
| 161 | return e_msg |
| 162 | |
| 163 | |
| 164 | def calc_expected_circle_image_ratio(ref_fov, img_w, img_h): |
| 165 | """Determine the circle image area ratio in percentage for a given image size. |
| 166 | |
| 167 | Cropping happens either horizontally or vertically. In both cases crop results |
| 168 | in the visble area reduced by a ratio r (r < 1) and the circle will in turn |
| 169 | occupy ref_pct/r (percent) on the target image size. |
| 170 | |
| 171 | Args: |
| 172 | ref_fov: dict with {fmt, % coverage, w, h, circle_w, circle_h} |
| 173 | img_w: the image width |
| 174 | img_h: the image height |
| 175 | |
| 176 | Returns: |
| 177 | chk_percent: the expected circle image area ratio in percentage |
| 178 | """ |
| 179 | ar_ref = ref_fov['w'] / ref_fov['h'] |
| 180 | ar_target = img_w / img_h |
| 181 | |
| 182 | r = ar_ref / ar_target |
| 183 | if r < 1.0: |
| 184 | r = 1.0 / r |
| 185 | return ref_fov['percent'] * r |
| 186 | |
| 187 | |
| 188 | def calc_circle_image_ratio(radius, img_w, img_h): |
| 189 | """Calculate the percent of area the input circle covers in input image. |
| 190 | |
| 191 | Args: |
| 192 | radius: radius of circle |
| 193 | img_w: int width of image |
| 194 | img_h: int height of image |
| 195 | Returns: |
| 196 | fov_percent: float % of image covered by circle |
| 197 | """ |
| 198 | return 100 * math.pi * math.pow(radius, 2) / (img_w * img_h) |
| 199 | |
| 200 | |
Clemenz Portmann | 66bb005 | 2022-03-02 13:34:30 -0800 | [diff] [blame] | 201 | def find_fov_reference(cam, req, props, raw_bool, ref_img_name_stem): |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 202 | """Determine the circle coverage of the image in reference image. |
| 203 | |
| 204 | Captures a full-frame RAW or JPEG and uses its aspect ratio and circle center |
| 205 | location as ground truth for the other jpeg or yuv images. |
| 206 | |
| 207 | The intrinsics and distortion coefficients are meant for full-sized RAW, |
| 208 | so convert_capture_to_rgb_image returns a 2x downsampled version, so resizes |
| 209 | RGB back to full size. |
| 210 | |
| 211 | If the device supports lens distortion correction, applies the coefficients on |
| 212 | the RAW image so it can be compared to YUV/JPEG outputs which are subject |
| 213 | to the same correction via ISP. |
| 214 | |
| 215 | Finds circle size and location for reference values in calculations for other |
| 216 | formats. |
| 217 | |
| 218 | Args: |
| 219 | cam: camera object |
| 220 | req: camera request |
| 221 | props: camera properties |
Clemenz Portmann | 66bb005 | 2022-03-02 13:34:30 -0800 | [diff] [blame] | 222 | raw_bool: True if RAW available |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 223 | ref_img_name_stem: test _NAME + location to save data |
| 224 | |
| 225 | Returns: |
Clemenz Portmann | b9899f4 | 2022-11-14 12:52:59 -0800 | [diff] [blame] | 226 | ref_fov: dict with {fmt, % coverage, w, h, circle_w, circle_h} |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 227 | cc_ct_gt: circle center position relative to the center of image. |
| 228 | aspect_ratio_gt: aspect ratio of the detected circle in float. |
| 229 | """ |
Clemenz Portmann | 66bb005 | 2022-03-02 13:34:30 -0800 | [diff] [blame] | 230 | logging.debug('Creating references for fov_coverage') |
| 231 | if raw_bool: |
| 232 | logging.debug('Using RAW for reference') |
| 233 | fmt_type = 'RAW' |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 234 | out_surface = {'format': 'raw'} |
| 235 | cap = cam.do_capture(req, out_surface) |
| 236 | logging.debug('Captured RAW %dx%d', cap['width'], cap['height']) |
| 237 | img = image_processing_utils.convert_capture_to_rgb_image( |
| 238 | cap, props=props) |
| 239 | # Resize back up to full scale. |
| 240 | img = cv2.resize(img, (0, 0), fx=2.0, fy=2.0) |
| 241 | |
Clemenz Portmann | b947041 | 2024-06-11 08:40:33 -0700 | [diff] [blame] | 242 | fd = float(cap['metadata']['android.lens.focalLength']) |
| 243 | k = camera_properties_utils.get_intrinsic_calibration( |
| 244 | props, cap['metadata'], True, fd |
| 245 | ) |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 246 | if (camera_properties_utils.distortion_correction(props) and |
Clemenz Portmann | b947041 | 2024-06-11 08:40:33 -0700 | [diff] [blame] | 247 | isinstance(k, np.ndarray)): |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 248 | logging.debug('Applying intrinsic calibration and distortion params') |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 249 | opencv_dist = camera_properties_utils.get_distortion_matrix(props) |
| 250 | k_new = cv2.getOptimalNewCameraMatrix( |
| 251 | k, opencv_dist, (img.shape[1], img.shape[0]), 0)[0] |
| 252 | scale = max(k_new[0][0] / k[0][0], k_new[1][1] / k[1][1]) |
| 253 | if scale > 1: |
| 254 | k_new[0][0] = k[0][0] * scale |
| 255 | k_new[1][1] = k[1][1] * scale |
| 256 | img = cv2.undistort(img, k, opencv_dist, None, k_new) |
| 257 | else: |
| 258 | img = cv2.undistort(img, k, opencv_dist) |
| 259 | size = img.shape |
| 260 | |
Clemenz Portmann | 66bb005 | 2022-03-02 13:34:30 -0800 | [diff] [blame] | 261 | else: |
| 262 | logging.debug('Using JPEG for reference') |
| 263 | fmt_type = 'JPEG' |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 264 | ref_fov = {} |
Clemenz Portmann | 49f35a4 | 2024-07-11 09:28:55 -0700 | [diff] [blame] | 265 | fmt = capture_request_utils.get_largest_format('jpeg', props) |
Clemenz Portmann | e0ea087 | 2022-02-23 18:24:14 -0800 | [diff] [blame] | 266 | cap = cam.do_capture(req, fmt) |
| 267 | logging.debug('Captured JPEG %dx%d', cap['width'], cap['height']) |
| 268 | img = image_processing_utils.convert_capture_to_rgb_image(cap, props) |
| 269 | size = (cap['height'], cap['width']) |
| 270 | |
| 271 | # Get image size. |
| 272 | w = size[1] |
| 273 | h = size[0] |
| 274 | img_name = f'{ref_img_name_stem}_{fmt_type}_w{w}_h{h}.png' |
| 275 | image_processing_utils.write_image(img, img_name, True) |
| 276 | |
| 277 | # Find circle. |
| 278 | img *= 255 # cv2 needs images between [0,255]. |
| 279 | circle = opencv_processing_utils.find_circle( |
| 280 | img, img_name, CIRCLE_MIN_AREA, CIRCLE_COLOR) |
| 281 | opencv_processing_utils.append_circle_center_to_img(circle, img, img_name) |
| 282 | |
| 283 | # Determine final return values. |
| 284 | if fmt_type == 'RAW': |
| 285 | aspect_ratio_gt = circle['w'] / circle['h'] |
| 286 | else: |
| 287 | aspect_ratio_gt = 1.0 |
| 288 | cc_ct_gt = {'hori': circle['x_offset'], 'vert': circle['y_offset']} |
| 289 | fov_percent = calc_circle_image_ratio(circle['r'], w, h) |
| 290 | ref_fov = {} |
| 291 | ref_fov['fmt'] = fmt_type |
| 292 | ref_fov['percent'] = fov_percent |
| 293 | ref_fov['w'] = w |
| 294 | ref_fov['h'] = h |
| 295 | ref_fov['circle_w'] = circle['w'] |
| 296 | ref_fov['circle_h'] = circle['h'] |
| 297 | logging.debug('Using %s reference: %s', fmt_type, str(ref_fov)) |
| 298 | return ref_fov, cc_ct_gt, aspect_ratio_gt |