CameraITS: multi camera frame sync test.
This is the first of a series of CLs. This CL contains the overall
structure of the test. Sensor fusion box control needs to be added.
Test: Ran sensor fusion test directly and ensured 7 captures were
executed.
Bug: 77539367
Change-Id: I8b93f9a06ba59338934644fa335486e05fe35a39
diff --git a/apps/CameraITS/pymodules/its/cv2image.py b/apps/CameraITS/pymodules/its/cv2image.py
index 3020548..58da2c8 100644
--- a/apps/CameraITS/pymodules/its/cv2image.py
+++ b/apps/CameraITS/pymodules/its/cv2image.py
@@ -14,11 +14,13 @@
import os
import unittest
+import time
import cv2
import its.caps
import its.device
import its.error
+import its.image
import numpy
VGA_HEIGHT = 480
@@ -195,6 +197,104 @@
self.ynorm = float(top_left[1]) / scene.shape[0]
+def get_angle(input_img):
+ """Computes anglular inclination of chessboard in input_img.
+
+ Angle estimation algoritm description:
+ Input: 2D grayscale image of chessboard.
+ Output: Angle of rotation of chessboard perpendicular to
+ chessboard. Assumes chessboard and camera are parallel to
+ each other.
+
+ 1) Use adaptive threshold to make image binary
+ 2) Find countours
+ 3) Filter out small contours
+ 4) Filter out all non-square contours
+ 5) Compute most common square shape.
+ The assumption here is that the most common square instances
+ are the chessboard squares. We've shown that with our current
+ tuning, we can robustly identify the squares on the sensor fusion
+ chessboard.
+ 6) Return median angle of most common square shape.
+
+ USAGE NOTE: This function has been tuned to work for the chessboard used in
+ the sensor_fusion tests. See images in test_images/rotated_chessboard/ for
+ sample captures. If this function is used with other chessboards, it may not
+ work as expected.
+
+ TODO: Make algorithm more robust so it works on any type of
+ chessboard.
+
+ Args:
+ input_img (2D numpy.ndarray): Grayscale image stored as a 2D
+ numpy array.
+
+ Returns:
+ Median angle of squares in degrees identified in the image.
+ """
+ # Tuning parameters
+ min_square_area = (float) (input_img.shape[1] * 0.05)
+
+ # Creates copy of image to avoid modifying original.
+ img = numpy.array(input_img, copy=True)
+
+ # Scale pixel values from 0-1 to 0-255
+ img = img * 255
+ img = img.astype(numpy.uint8)
+
+ thresh = cv2.adaptiveThreshold(
+ img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 201, 2)
+
+ # Find all contours
+ _, contours, _ = cv2.findContours(
+ thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
+
+ # Filter contours to squares only.
+ square_contours = []
+
+ for contour in contours:
+ rect = cv2.minAreaRect(contour)
+ _, (width, height), angle = rect
+
+ # Skip non-squares (with 0.1 tolerance)
+ tolerance = 0.1
+ if width < height * (1 - tolerance) or width > height * (1 + tolerance):
+ continue
+
+ # Remove very small contours.
+ # These are usually just tiny dots due to noise.
+ area = cv2.contourArea(contour)
+ if area < min_square_area:
+ continue
+
+ box = cv2.boxPoints(rect)
+ box = numpy.int0(box)
+ square_contours.append(contour)
+
+ areas = []
+ for contour in square_contours:
+ area = cv2.contourArea(contour)
+ areas.append(area)
+
+ median_area = numpy.median(areas)
+
+ filtered_squares = []
+ filtered_angles = []
+ for square in square_contours:
+ area = cv2.contourArea(square)
+ if area < median_area * 0.90 or area > median_area * 1.10:
+ continue
+
+ filtered_squares.append(square)
+ _, (width, height), angle = cv2.minAreaRect(square)
+ filtered_angles.append(angle)
+
+ if len(filtered_angles) < 10:
+ return None
+
+ return numpy.median(filtered_angles)
+
+
class __UnitTest(unittest.TestCase):
"""Run a suite of unit tests on this module.
"""
@@ -224,6 +324,56 @@
self.assertTrue(numpy.isclose(sharpness[4]/sharpness[8],
numpy.sqrt(2), atol=0.1))
+ def test_get_angle_identify_unrotated_chessboard_angle(self):
+ basedir = os.path.join(
+ os.path.dirname(__file__), 'test_images/rotated_chessboards/')
+
+ normal_img_path = os.path.join(basedir, 'normal.jpg')
+ wide_img_path = os.path.join(basedir, 'wide.jpg')
+
+ normal_img = cv2.cvtColor(
+ cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY)
+ wide_img = cv2.cvtColor(
+ cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY)
+
+ assert get_angle(normal_img) == 0
+ assert get_angle(wide_img) == 0
+
+ def test_get_angle_identify_rotated_chessboard_angle(self):
+ basedir = os.path.join(
+ os.path.dirname(__file__), 'test_images/rotated_chessboards/')
+
+ # Array of the image files and angles containing rotated chessboards.
+ test_cases = [
+ ('_15_ccw', 15),
+ ('_30_ccw', 30),
+ ('_45_ccw', 45),
+ ('_60_ccw', 60),
+ ('_75_ccw', 75),
+ ('_90_ccw', 90)
+ ]
+
+ # For each rotated image pair (normal, wide). Check if angle is
+ # identified as expected.
+ for suffix, angle in test_cases:
+ # Define image paths
+ normal_img_path = os.path.join(
+ basedir, 'normal{}.jpg'.format(suffix))
+ wide_img_path = os.path.join(
+ basedir, 'wide{}.jpg'.format(suffix))
+
+ # Load and color convert images
+ normal_img = cv2.cvtColor(
+ cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY)
+ wide_img = cv2.cvtColor(
+ cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY)
+
+ # Assert angle is as expected up to 2.0 degrees of accuracy.
+ assert numpy.isclose(
+ abs(get_angle(normal_img)), angle, 2.0)
+ assert numpy.isclose(
+ abs(get_angle(wide_img)), angle, 2.0)
+
if __name__ == '__main__':
unittest.main()
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal.jpg
new file mode 100644
index 0000000..e6418be
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_15_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_15_ccw.jpg
new file mode 100644
index 0000000..fa921c2
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_15_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_30_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_30_ccw.jpg
new file mode 100644
index 0000000..907f0d2
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_30_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_45_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_45_ccw.jpg
new file mode 100644
index 0000000..59dc939
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_45_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_60_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_60_ccw.jpg
new file mode 100644
index 0000000..7d11c40
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_60_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_75_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_75_ccw.jpg
new file mode 100644
index 0000000..1193bb1
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_75_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_90_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_90_ccw.jpg
new file mode 100644
index 0000000..ea233ae
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/normal_90_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide.jpg
new file mode 100644
index 0000000..a790506
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_15_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_15_ccw.jpg
new file mode 100644
index 0000000..1871bab
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_15_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_30_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_30_ccw.jpg
new file mode 100644
index 0000000..d3bff2a
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_30_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_45_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_45_ccw.jpg
new file mode 100644
index 0000000..1298752
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_45_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_60_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_60_ccw.jpg
new file mode 100644
index 0000000..642aeea
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_60_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_75_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_75_ccw.jpg
new file mode 100644
index 0000000..b224ae4
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_75_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_90_ccw.jpg b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_90_ccw.jpg
new file mode 100644
index 0000000..c2ad401
--- /dev/null
+++ b/apps/CameraITS/pymodules/its/test_images/rotated_chessboards/wide_90_ccw.jpg
Binary files differ
diff --git a/apps/CameraITS/tests/sensor_fusion/test_multi_camera_frame_sync.py b/apps/CameraITS/tests/sensor_fusion/test_multi_camera_frame_sync.py
new file mode 100644
index 0000000..2998c88
--- /dev/null
+++ b/apps/CameraITS/tests/sensor_fusion/test_multi_camera_frame_sync.py
@@ -0,0 +1,182 @@
+# Copyright 2018 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.
+
+import its.caps
+from its.cv2image import get_angle
+import its.device
+import its.image
+import its.objects
+import its.target
+
+import cv2
+import matplotlib
+from matplotlib import pylab
+import numpy
+import os
+
+ANGLE_MASK = 10 # degrees
+ANGULAR_DIFF_THRESHOLD = 5 # degrees
+ANGULAR_MOVEMENT_THRESHOLD = 45 # degrees
+NAME = os.path.basename(__file__).split(".")[0]
+NUM_CAPTURES = 100
+W = 640
+H = 480
+
+
+def _check_available_capabilities(props):
+ """Returns True if all required test capabilities are present."""
+ return all([
+ its.caps.compute_target_exposure(props),
+ its.caps.per_frame_control(props),
+ its.caps.logical_multi_camera(props),
+ its.caps.raw16(props),
+ its.caps.manual_sensor(props),
+ its.caps.sensor_fusion(props)])
+
+
+def _assert_camera_movement(frame_pairs_angles):
+ """Assert the angles between each frame pair are sufficiently different.
+
+ Different angles is an indication of camera movement.
+ """
+ angles = [i for i, j in frame_pairs_angles]
+ max_angle = numpy.amax(angles)
+ min_angle = numpy.amin(angles)
+ emsg = "Not enough phone movement!\n"
+ emsg += "min angle: %.2f, max angle: %.2f deg, THRESH: %d deg" % (
+ min_angle, max_angle, ANGULAR_MOVEMENT_THRESHOLD)
+ assert max_angle - min_angle > ANGULAR_MOVEMENT_THRESHOLD, emsg
+
+
+def _assert_angular_difference(angle_1, angle_2):
+ """Assert angular difference is within threshold."""
+ diff = abs(angle_2 - angle_1)
+
+ # Assert difference is less than threshold
+ emsg = "Diff between frame pair: %.1f. Threshold: %d deg." % (
+ diff, ANGULAR_DIFF_THRESHOLD)
+ assert diff < ANGULAR_DIFF_THRESHOLD, emsg
+
+
+def _mask_angles_near_extremes(frame_pairs_angles):
+ """Mask out the data near the top and bottom of angle range."""
+ masked_pairs_angles = [[i, j] for i, j in frame_pairs_angles
+ if ANGLE_MASK <= abs(i) <= 90-ANGLE_MASK]
+ return masked_pairs_angles
+
+
+def _plot_frame_pairs_angles(frame_pairs_angles, ids):
+ """Plot the extracted angles."""
+ matplotlib.pyplot.figure("Camera Rotation Angle")
+ cam0_angles = [i for i, j in frame_pairs_angles]
+ cam1_angles = [j for i, j in frame_pairs_angles]
+ pylab.plot(range(len(cam0_angles)), cam0_angles, "r", label="%s" % ids[0])
+ pylab.plot(range(len(cam1_angles)), cam1_angles, "g", label="%s" % ids[1])
+ pylab.legend()
+ pylab.xlabel("Camera frame number")
+ pylab.ylabel("Rotation angle (degrees)")
+ matplotlib.pyplot.savefig("%s_angles_plot.png" % (NAME))
+
+ matplotlib.pyplot.figure("Angle Diffs")
+ angle_diffs = [j-i for i, j in frame_pairs_angles]
+ pylab.plot(range(len(angle_diffs)), angle_diffs, "b",
+ label="cam%s-%s" % (ids[1], ids[0]))
+ pylab.legend()
+ pylab.xlabel("Camera frame number")
+ pylab.ylabel("Rotation angle difference (degrees)")
+ matplotlib.pyplot.savefig("%s_angle_diffs_plot.png" % (NAME))
+
+def _collect_data():
+ """Returns list of pair of gray frames and camera ids used for captures."""
+ yuv_sizes = {}
+ with its.device.ItsSession() as cam:
+ props = cam.get_camera_properties()
+
+ # If capabilities not present, skip.
+ its.caps.skip_unless(_check_available_capabilities(props))
+
+ # Determine return parameters
+ debug = its.caps.debug_mode()
+ ids = its.caps.logical_multi_camera_physical_ids(props)
+
+ # Define capture request
+ s, e, _, _, f = cam.do_3a(get_results=True)
+ req = its.objects.manual_capture_request(s, e, f)
+
+ # capture YUVs
+ out_surfaces = [{"format": "yuv", "width": W, "height": H,
+ "physicalCamera": ids[0]},
+ {"format": "yuv", "width": W, "height": H,
+ "physicalCamera": ids[1]}]
+
+ capture_1_list, capture_2_list = cam.do_capture(
+ [req]*NUM_CAPTURES, out_surfaces)
+
+ # Create list of capture pairs. [[cap1A, cap1B], [cap2A, cap2B], ...]
+ frame_pairs = zip(capture_1_list, capture_2_list)
+
+ # Convert captures to grayscale
+ frame_pairs_gray = [
+ [
+ cv2.cvtColor(its.image.convert_capture_to_rgb_image(f, props=props), cv2.COLOR_RGB2GRAY) for f in pair
+ ] for pair in frame_pairs]
+
+ # Save images for debugging
+ if debug:
+ for i, imgs in enumerate(frame_pairs_gray):
+ for j in [0, 1]:
+ file_name = "%s_%s_%03d.png" % (NAME, ids[j], i)
+ cv2.imwrite(file_name, imgs[j]*255)
+
+ return frame_pairs_gray, ids
+
+def main():
+ """Test frame timestamps captured by logical camera are within 10ms."""
+ frame_pairs_gray, ids = _collect_data()
+
+ # Compute angles in frame pairs
+ frame_pairs_angles = [
+ [get_angle(p[0]), get_angle(p[1])] for p in frame_pairs_gray]
+
+ # Remove frames where not enough squares were detected.
+ filtered_pairs_angles = []
+ for angle_1, angle_2 in frame_pairs_angles:
+ if angle_1 == None or angle_2 == None:
+ continue
+ filtered_pairs_angles.append([angle_1, angle_2])
+
+ print 'Using {} image pairs to compute angular difference.'.format(
+ len(filtered_pairs_angles))
+
+ assert len(filtered_pairs_angles) > 20, (
+ "Unable to identify enough frames with detected squares.")
+
+ # Mask out data near 90 degrees.
+ # The chessboard angles we compute go from 0 to 89. Meaning,
+ # 90 degrees equals to 0 degrees.
+ # In order to avoid this jump, we ignore any frames at these extremeties.
+ masked_pairs_angles = _mask_angles_near_extremes(filtered_pairs_angles)
+
+ # Plot angles and differences
+ _plot_frame_pairs_angles(filtered_pairs_angles, ids)
+
+ # Ensure camera moved
+ _assert_camera_movement(filtered_pairs_angles)
+
+ # Ensure angle between images from each camera does not change appreciably
+ for cam_1_angle, cam_2_angle in masked_pairs_angles:
+ _assert_angular_difference(cam_1_angle, cam_2_angle)
+
+if __name__ == "__main__":
+ main()