ITS: create image_fov_utils

files affected:
  utils/image_fov_utils.py
  test_aspect_ratio_and_crop.py

Other changes:
  merged _find_raw_fov_reference() and _find_jpeg_fov_reference()
  moved from np.isclose() to math.isclose() as math is more accurate

bug: 221286703

Change-Id: I22e8ec1f751c1ab2dfd44134a5da26120ecd7e53
diff --git a/apps/CameraITS/utils/image_fov_utils.py b/apps/CameraITS/utils/image_fov_utils.py
new file mode 100644
index 0000000..c3badf9
--- /dev/null
+++ b/apps/CameraITS/utils/image_fov_utils.py
@@ -0,0 +1,291 @@
+# Copyright 2022 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.
+"""Image Field-of-View utilities for aspect ratio, crop, and FoV tests."""
+
+
+import logging
+import math
+import unittest
+
+import cv2
+import camera_properties_utils
+import capture_request_utils
+import image_processing_utils
+import opencv_processing_utils
+
+CIRCLE_COLOR = 0  # [0: black, 255: white]
+CIRCLE_MIN_AREA = 0.01  # 1% of image size
+
+LARGE_SIZE_IMAGE = 2000  # Size of a large image (compared against max(w, h))
+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
+
+
+def check_ar(circle, ar_gt, w, h, e_msg_stem):
+  """Check the aspect ratio of the circle.
+
+  size is the larger of w or h.
+  if size >= LARGE_SIZE_IMAGE: use THRESH_AR_L
+  elif size == 0 (extreme case): THRESH_AR_S
+  elif 0 < image size < LARGE_SIZE_IMAGE: scale between THRESH_AR_S & 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
+    e_msg_stem: customized string for error message
+
+  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_IMAGE)
+  ar = circle['w'] / circle['h']
+  if not math.isclose(ar, ar_gt, abs_tol=thresh_ar):
+    e_msg = (f'{e_msg_stem} {w}x{h}: aspect_ratio {ar:.3f}, '
+             f'thresh {thresh_ar:.3f}')
+    return e_msg
+
+
+def check_crop(circle, cc_gt, w, h, e_msg_stem, crop_thresh_factor):
+  """Check cropping.
+
+  if size >= LARGE_SIZE_IMAGE: use thresh_crop_l
+  elif size == 0 (extreme case): thresh_crop_s
+  elif 0 < size < LARGE_SIZE_IMAGE: 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
+    e_msg_stem: text to customize error message
+    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_IMAGE,
+       THRESH_MIN_PIXEL / circle['w']])
+  thresh_crop_vert = max(
+      [thresh_crop_l,
+       thresh_crop_s + h * (thresh_crop_l - thresh_crop_s) / LARGE_SIZE_IMAGE,
+       THRESH_MIN_PIXEL / circle['h']])
+
+  if (not math.isclose(circle['x_offset'], cc_gt['hori'],
+                       abs_tol=thresh_crop_hori) or
+      not math.isclose(circle['y_offset'], cc_gt['vert'],
+                       abs_tol=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'{e_msg_stem} {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
+
+
+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 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 find_fov_reference(cam, req, props, fmt_type, ref_img_name_stem):
+  """Determine the circle coverage of the image in reference image.
+
+  Captures a full-frame RAW or JPEG 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
+    fmt_type: 'RAW' or 'JPEG'
+    ref_img_name_stem: test _NAME + 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 %s', fmt_type)
+  if fmt_type == 'RAW':
+    out_surface = {'format': 'raw'}
+    cap = cam.do_capture(req, out_surface)
+    logging.debug('Captured RAW %dx%d', cap['width'], cap['height'])
+    img = image_processing_utils.convert_capture_to_rgb_image(
+        cap, props=props)
+    # Resize back up to full scale.
+    img = cv2.resize(img, (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['metadata']['android.lens.focalLength'])
+      k = camera_properties_utils.get_intrinsic_calibration(props, True, fd)
+      opencv_dist = camera_properties_utils.get_distortion_matrix(props)
+      k_new = cv2.getOptimalNewCameraMatrix(
+          k, opencv_dist, (img.shape[1], img.shape[0]), 0)[0]
+      scale = max(k_new[0][0] / k[0][0], k_new[1][1] / k[1][1])
+      if scale > 1:
+        k_new[0][0] = k[0][0] * scale
+        k_new[1][1] = k[1][1] * scale
+        img = cv2.undistort(img, k, opencv_dist, None, k_new)
+      else:
+        img = cv2.undistort(img, k, opencv_dist)
+    size = img.shape
+
+  elif fmt_type == 'JPEG':
+    ref_fov = {}
+    fmt = capture_request_utils.get_largest_jpeg_format(props)
+    cap = cam.do_capture(req, fmt)
+    logging.debug('Captured JPEG %dx%d', cap['width'], cap['height'])
+    img = image_processing_utils.convert_capture_to_rgb_image(cap, props)
+    size = (cap['height'], cap['width'])
+
+  # Get image size.
+  w = size[1]
+  h = size[0]
+  img_name = f'{ref_img_name_stem}_{fmt_type}_w{w}_h{h}.png'
+  image_processing_utils.write_image(img, img_name, True)
+
+  # Find circle.
+  img *= 255  # cv2 needs images between [0,255].
+  circle = opencv_processing_utils.find_circle(
+      img, img_name, CIRCLE_MIN_AREA, CIRCLE_COLOR)
+  opencv_processing_utils.append_circle_center_to_img(circle, img, img_name)
+
+  # Determine final return values.
+  if fmt_type == 'RAW':
+    aspect_ratio_gt = circle['w'] / circle['h']
+  else:
+    aspect_ratio_gt = 1.0
+  cc_ct_gt = {'hori': circle['x_offset'], 'vert': circle['y_offset']}
+  fov_percent = calc_circle_image_ratio(circle['r'], w, h)
+  ref_fov = {}
+  ref_fov['fmt'] = fmt_type
+  ref_fov['percent'] = fov_percent
+  ref_fov['w'] = w
+  ref_fov['h'] = h
+  ref_fov['circle_w'] = circle['w']
+  ref_fov['circle_h'] = circle['h']
+  logging.debug('Using %s reference: %s', fmt_type, str(ref_fov))
+  return ref_fov, cc_ct_gt, aspect_ratio_gt
+
+
+class ImageFovUtilsTest(unittest.TestCase):
+  """Unit tests for this module."""
+
+  def test_calc_expected_circle_image_ratio(self):
+    """Unit test for calc_expected_circle_image_ratio.
+
+    Test by using 5% area circle in VGA cropped to nHD format
+    """
+    ref_fov = {'w': 640, 'h': 480, 'percent': 5}
+    # nHD format cut down
+    img_w, img_h = 640, 360
+    nhd = calc_expected_circle_image_ratio(ref_fov, img_w, img_h)
+    self.assertTrue(math.isclose(nhd, 5*480/360, abs_tol=0.01))
+
+  def test_check_ar(self):
+    """Unit test for aspect ratio check."""
+    # Circle true
+    circle = {'w': 1, 'h': 1}
+    ar_gt = 1.0
+    w, h = 640, 480
+    e_msg_stem = 'check_ar_true'
+    e_msg = check_ar(circle, ar_gt, w, h, e_msg_stem)
+    self.assertIsNone(e_msg)
+
+    # Circle false
+    circle = {'w': 2, 'h': 1}
+    e_msg_stem = 'check_ar_false'
+    e_msg = check_ar(circle, ar_gt, w, h, e_msg_stem)
+    self.assertTrue('check_ar_false' in e_msg)
+
+  def test_check_crop(self):
+    """Unit test for crop check."""
+    # Crop true
+    circle = {'w': 100, 'h': 100, 'x_offset': 1, 'y_offset': 1}
+    cc_gt = {'hori': 1.0, 'vert': 1.0}
+    w, h = 640, 480
+    e_msg_stem = 'check_crop_true'
+    crop_thresh_factor = 1
+    e_msg = check_crop(circle, cc_gt, w, h, e_msg_stem, crop_thresh_factor)
+    self.assertIsNone(e_msg)
+
+    # Crop false
+    circle = {'w': 100, 'h': 100, 'x_offset': 2, 'y_offset': 1}
+    e_msg_stem = 'check_crop_false'
+    e_msg = check_crop(circle, cc_gt, w, h, e_msg_stem, crop_thresh_factor)
+    self.assertTrue('check_crop_false' in e_msg)
+
+if __name__ == '__main__':
+  unittest.main()
+