blob: 4c3c4d9cd79664ca322ff1340e1158cd2bcd1568 [file] [log] [blame]
# 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 math
import os.path
import re
import sys
import cv2
import its.caps
import its.device
import its.image
import its.objects
import numpy as np
ALIGN_TOL_MM = 4.0E-3 # mm
ALIGN_TOL = 0.01 # multiplied by sensor diagonal to convert to pixels
CHART_DISTANCE_CM = 22 # cm
CIRCLE_RTOL = 0.1
GYRO_REFERENCE = 1
NAME = os.path.basename(__file__).split('.')[0]
TRANS_REF_MATRIX = np.array([0, 0, 0])
def convert_to_world_coordinates(x, y, r, t, k, z_w):
"""Convert x,y coordinates to world coordinates.
Conversion equation is:
A = [[x*r[2][0] - dot(k_row0, r_col0), x*r_[2][1] - dot(k_row0, r_col1)],
[y*r[2][0] - dot(k_row1, r_col0), y*r_[2][1] - dot(k_row1, r_col1)]]
b = [[z_w*dot(k_row0, r_col2) + dot(k_row0, t) - x*(r[2][2]*z_w + t[2])],
[z_w*dot(k_row1, r_col2) + dot(k_row1, t) - y*(r[2][2]*z_w + t[2])]]
[[x_w], [y_w]] = inv(A) * b
Args:
x: x location in pixel space
y: y location in pixel space
r: rotation matrix
t: translation matrix
k: intrinsic matrix
z_w: z distance in world space
Returns:
x_w: x in meters in world space
y_w: y in meters in world space
"""
c_1 = r[2, 2] * z_w + t[2]
k_x1 = np.dot(k[0, :], r[:, 0])
k_x2 = np.dot(k[0, :], r[:, 1])
k_x3 = z_w * np.dot(k[0, :], r[:, 2]) + np.dot(k[0, :], t)
k_y1 = np.dot(k[1, :], r[:, 0])
k_y2 = np.dot(k[1, :], r[:, 1])
k_y3 = z_w * np.dot(k[1, :], r[:, 2]) + np.dot(k[1, :], t)
a = np.array([[x*r[2][0]-k_x1, x*r[2][1]-k_x2],
[y*r[2][0]-k_y1, y*r[2][1]-k_y2]])
b = np.array([[k_x3-x*c_1], [k_y3-y*c_1]])
return np.dot(np.linalg.inv(a), b)
def convert_to_image_coordinates(p_w, r, t, k):
p_c = np.dot(r, p_w) + t
p_h = np.dot(k, p_c)
return p_h[0] / p_h[2], p_h[1] / p_h[2]
def rotation_matrix(rotation):
"""Convert the rotation parameters to 3-axis data.
Args:
rotation: android.lens.Rotation vector
Returns:
3x3 matrix w/ rotation parameters
"""
x = rotation[0]
y = rotation[1]
z = rotation[2]
w = rotation[3]
return np.array([[1-2*y**2-2*z**2, 2*x*y-2*z*w, 2*x*z+2*y*w],
[2*x*y+2*z*w, 1-2*x**2-2*z**2, 2*y*z-2*x*w],
[2*x*z-2*y*w, 2*y*z+2*x*w, 1-2*x**2-2*y**2]])
# TODO: merge find_circle() & test_aspect_ratio_and_crop.measure_aspect_ratio()
# for a unified circle script that is and in pymodules/image.py
def find_circle(gray, name):
"""Find the black circle in the image.
Args:
gray: numpy grayscale array with pixel values in [0,255].
name: string of file name.
Returns:
circle: (circle_center_x, circle_center_y, radius)
"""
size = gray.shape
# otsu threshold to binarize the image
_, img_bw = cv2.threshold(np.uint8(gray), 0, 255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# connected component
cv2_version = cv2.__version__
if cv2_version.startswith('2.4.'):
contours, hierarchy = cv2.findContours(255-img_bw, cv2.RETR_TREE,
cv2.CHAIN_APPROX_SIMPLE)
elif cv2_version.startswith('3.2.'):
_, contours, hierarchy = cv2.findContours(255-img_bw, cv2.RETR_TREE,
cv2.CHAIN_APPROX_SIMPLE)
# Check each component and find the black circle
min_cmpt = size[0] * size[1] * 0.005
max_cmpt = size[0] * size[1] * 0.35
num_circle = 0
for ct, hrch in zip(contours, hierarchy[0]):
# The radius of the circle is 1/3 of the length of the square, meaning
# around 1/3 of the area of the square
# Parental component should exist and the area is acceptable.
# The contour of a circle should have at least 5 points
child_area = cv2.contourArea(ct)
if (hrch[3] == -1 or child_area < min_cmpt or child_area > max_cmpt
or len(ct) < 15):
continue
# Check the shapes of current component and its parent
child_shape = component_shape(ct)
parent = hrch[3]
prt_shape = component_shape(contours[parent])
prt_area = cv2.contourArea(contours[parent])
dist_x = abs(child_shape['ctx']-prt_shape['ctx'])
dist_y = abs(child_shape['cty']-prt_shape['cty'])
# 1. 0.56*Parent's width < Child's width < 0.76*Parent's width.
# 2. 0.56*Parent's height < Child's height < 0.76*Parent's height.
# 3. Child's width > 0.1*Image width
# 4. Child's height > 0.1*Image height
# 5. 0.25*Parent's area < Child's area < 0.45*Parent's area
# 6. Child is a black, and Parent is white
# 7. Center of Child and center of parent should overlap
if (prt_shape['width'] * 0.56 < child_shape['width']
< prt_shape['width'] * 0.76
and prt_shape['height'] * 0.56 < child_shape['height']
< prt_shape['height'] * 0.76
and child_shape['width'] > 0.1 * size[1]
and child_shape['height'] > 0.1 * size[0]
and 0.30 * prt_area < child_area < 0.50 * prt_area
and img_bw[child_shape['cty']][child_shape['ctx']] == 0
and img_bw[child_shape['top']][child_shape['left']] == 255
and dist_x < 0.1 * child_shape['width']
and dist_y < 0.1 * child_shape['height']):
# Calculate circle center and size
circle_ctx = float(child_shape['ctx'])
circle_cty = float(child_shape['cty'])
circle_w = float(child_shape['width'])
circle_h = float(child_shape['height'])
num_circle += 1
# If more than one circle found, break
if num_circle == 2:
break
its.image.write_image(gray[..., np.newaxis]/255.0, name)
if num_circle == 0:
print 'No black circle was detected. Please take pictures according',
print 'to instruction carefully!\n'
assert num_circle == 1
if num_circle > 1:
print 'More than one black circle was detected. Background of scene',
print 'may be too complex.\n'
assert num_circle == 1
return (circle_ctx, circle_cty, (circle_w+circle_h)/4.0)
def component_shape(contour):
"""Measure the shape of a connected component.
Args:
contour: return from cv2.findContours. A list of pixel coordinates of
the contour.
Returns:
The most left, right, top, bottom pixel location, height, width, and
the center pixel location of the contour.
"""
shape = {'left': np.inf, 'right': 0, 'top': np.inf, 'bottom': 0,
'width': 0, 'height': 0, 'ctx': 0, 'cty': 0}
for pt in contour:
if pt[0][0] < shape['left']:
shape['left'] = pt[0][0]
if pt[0][0] > shape['right']:
shape['right'] = pt[0][0]
if pt[0][1] < shape['top']:
shape['top'] = pt[0][1]
if pt[0][1] > shape['bottom']:
shape['bottom'] = pt[0][1]
shape['width'] = shape['right'] - shape['left'] + 1
shape['height'] = shape['bottom'] - shape['top'] + 1
shape['ctx'] = (shape['left']+shape['right'])/2
shape['cty'] = (shape['top']+shape['bottom'])/2
return shape
def define_reference_camera(pose_reference, cam_reference):
"""Determine the reference camera.
Args:
pose_reference: 0 for cameras, 1 for gyro
cam_reference: dict with key of physical camera and value True/False
Returns:
i_ref: physical id of reference camera
i_2nd: physical id of secondary camera
"""
if pose_reference == GYRO_REFERENCE:
print 'pose_reference is GYRO'
i_ref = list(cam_reference.keys())[0] # pick first camera as ref
i_2nd = list(cam_reference.keys())[1]
else:
print 'pose_reference is CAMERA'
i_ref = (k for (k, v) in cam_reference.iteritems() if v).next()
i_2nd = (k for (k, v) in cam_reference.iteritems() if not v).next()
return i_ref, i_2nd
def main():
"""Test the multi camera system parameters related to camera spacing.
Using the multi-camera physical cameras, take a picture of scene4
(a black circle and surrounding square on a white background) with
one of the physical cameras. Then find the circle center. Using the
parameters:
android.lens.poseReference
android.lens.poseTranslation
android.lens.poseRotation
android.lens.instrinsicCalibration
android.lens.distortion (if available)
project the circle center to the world coordinates for each camera.
Compare the difference between the two cameras' circle centers in
world coordinates.
Reproject the world coordinates back to pixel coordinates and compare
against originals as a sanity check.
Compare the circle sizes if the focal lengths of the cameras are
different using
android.lens.availableFocalLengths.
"""
chart_distance = CHART_DISTANCE_CM
for s in sys.argv[1:]:
if s[:5] == 'dist=' and len(s) > 5:
chart_distance = float(re.sub('cm', '', s[5:]))
print 'Using chart distance: %.1fcm' % chart_distance
chart_distance *= 1.0E-2
with its.device.ItsSession() as cam:
props = cam.get_camera_properties()
its.caps.skip_unless(its.caps.compute_target_exposure(props) and
its.caps.per_frame_control(props) and
its.caps.logical_multi_camera(props) and
its.caps.raw16(props) and
its.caps.manual_sensor(props))
debug = its.caps.debug_mode()
avail_fls = props['android.lens.info.availableFocalLengths']
pose_reference = props['android.lens.poseReference']
max_raw_size = its.objects.get_available_output_sizes('raw', props)[0]
w, h = its.objects.get_available_output_sizes(
'yuv', props, match_ar_size=max_raw_size)[0]
# Do 3A and get the values
s, e, _, _, fd = cam.do_3a(get_results=True,
lock_ae=True, lock_awb=True)
e *= 2 # brighten RAW images
req = its.objects.manual_capture_request(s, e, fd, True, props)
# get physical camera properties
ids = its.caps.logical_multi_camera_physical_ids(props)
props_physical = {}
for i in ids:
props_physical[i] = cam.get_camera_properties_by_id(i)
# capture RAWs of 1st 2 cameras
cap_raw = {}
out_surfaces = [{'format': 'yuv', 'width': w, 'height': h},
{'format': 'raw', 'physicalCamera': ids[0]},
{'format': 'raw', 'physicalCamera': ids[1]}]
_, cap_raw[ids[0]], cap_raw[ids[1]] = cam.do_capture(req, out_surfaces)
size_raw = {}
k = {}
cam_reference = {}
r = {}
t = {}
circle = {}
fl = {}
sensor_diag = {}
for i in ids:
print 'Camera %s' % i
# process image
img_raw = its.image.convert_capture_to_rgb_image(
cap_raw[i], props=props)
size_raw[i] = (cap_raw[i]['width'], cap_raw[i]['height'])
# save images if debug
if debug:
its.image.write_image(img_raw, '%s_raw_%s.jpg' % (NAME, i))
# convert to [0, 255] images
img_raw *= 255
# scale to match calibration data
img = cv2.resize(img_raw.astype(np.uint8), None, fx=2, fy=2)
# load parameters for each physical camera
ical = props_physical[i]['android.lens.intrinsicCalibration']
assert len(ical) == 5, 'android.lens.instrisicCalibration incorrect.'
k[i] = np.array([[ical[0], ical[4], ical[2]],
[0, ical[1], ical[3]],
[0, 0, 1]])
print ' k:', k[i]
rotation = np.array(props_physical[i]['android.lens.poseRotation'])
print ' rotation:', rotation
assert len(rotation) == 4, 'poseRotation has wrong # of params.'
r[i] = rotation_matrix(rotation)
t[i] = np.array(props_physical[i]['android.lens.poseTranslation'])
print ' translation:', t[i]
assert len(t[i]) == 3, 'poseTranslation has wrong # of params.'
if (t[i] == TRANS_REF_MATRIX).all():
cam_reference[i] = True
else:
cam_reference[i] = False
# API spec defines poseTranslation as the world coordinate p_w_cam of
# optics center. When applying [R|t] to go from world coordinates to
# camera coordinates, we need -R*p_w_cam of the coordinate reported in
# metadata.
# ie. for a camera with optical center at world coordinate (5, 4, 3)
# and identity rotation, to convert a world coordinate into the
# camera's coordinate, we need a translation vector of [-5, -4, -3]
# so that: [I|[-5, -4, -3]^T] * [5, 4, 3]^T = [0,0,0]^T
t[i] = -1.0 * np.dot(r[i], t[i])
if debug:
print 't:', t[i]
print 'r:', r[i]
# Do operation on distorted image
print 'Detecting pre-correction circle'
circle_distorted = find_circle(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),
'%s_gray_precorr_cam_%s.jpg' % (NAME, i))
print 'camera %s circle pre-distortion correction: x, y: %.2f, %.2f' % (
i, circle_distorted[0], circle_distorted[1])
# Apply correction to image (if available)
if its.caps.distortion_correction(props):
distort = np.array(props_physical[i]['android.lens.distortion'])
assert len(distort) == 5, 'distortion has wrong # of params.'
cv2_distort = np.array([distort[0], distort[1],
distort[3], distort[4],
distort[2]])
print ' cv2 distortion params:', cv2_distort
its.image.write_image(img/255.0, '%s_raw_%s.jpg' % (
NAME, i))
img = cv2.undistort(img, k[i], cv2_distort)
its.image.write_image(img/255.0, '%s_correct_%s.jpg' % (
NAME, i))
# Find the circles in grayscale image
circle[i] = find_circle(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),
'%s_gray_%s.jpg' % (NAME, i))
# Find focal length & sensor size
fl[i] = props_physical[i]['android.lens.info.availableFocalLengths'][0]
sensor_diag[i] = math.sqrt(size_raw[i][0] ** 2 + size_raw[i][1] ** 2)
i_ref, i_2nd = define_reference_camera(pose_reference, cam_reference)
print 'reference camera: %s, secondary camera: %s' % (i_ref, i_2nd)
# Convert circle centers to real world coordinates
x_w = {}
y_w = {}
if props['android.lens.facing']:
print 'lens facing BACK'
chart_distance *= -1 # API spec defines +z i pointing out from screen
for i in [i_ref, i_2nd]:
x_w[i], y_w[i] = convert_to_world_coordinates(
circle[i][0], circle[i][1], r[i], t[i], k[i], chart_distance)
# Back convert to image coordinates for sanity check
x_p = {}
y_p = {}
x_p[i_2nd], y_p[i_2nd] = convert_to_image_coordinates(
[x_w[i_ref], y_w[i_ref], chart_distance],
r[i_2nd], t[i_2nd], k[i_2nd])
x_p[i_ref], y_p[i_ref] = convert_to_image_coordinates(
[x_w[i_2nd], y_w[i_2nd], chart_distance],
r[i_ref], t[i_ref], k[i_ref])
# Summarize results
for i in [i_ref, i_2nd]:
print ' Camera: %s' % i
print ' x, y (pixels): %.1f, %.1f' % (circle[i][0], circle[i][1])
print ' x_w, y_w (mm): %.2f, %.2f' % (x_w[i]*1.0E3, y_w[i]*1.0E3)
print ' x_p, y_p (pixels): %.1f, %.1f' % (x_p[i], y_p[i])
# Check center locations
err = np.linalg.norm(np.array([x_w[i_ref], y_w[i_ref]]) -
np.array([x_w[i_2nd], y_w[i_2nd]]))
print '\nCenter location err (mm): %.2f' % (err*1E3)
msg = 'Center locations %s <-> %s too different!' % (i_ref, i_2nd)
msg += ' val=%.2fmm, THRESH=%.fmm' % (err*1E3, ALIGN_TOL_MM*1E3)
assert err < ALIGN_TOL, msg
# Check projections back into pixel space
for i in [i_ref, i_2nd]:
err = np.linalg.norm(np.array([circle[i][0], circle[i][1]]) -
np.array([x_p[i], y_p[i]]))
print 'Camera %s projection error (pixels): %.1f' % (i, err)
tol = ALIGN_TOL * sensor_diag[i]
msg = 'Camera %s project locations too different!' % i
msg += ' diff=%.2f, TOL=%.2f' % (err, tol)
assert err < tol, msg
# Check focal length and circle size if more than 1 focal length
if len(avail_fls) > 1:
print 'Circle radii (pixels); ref: %.1f, 2nd: %.1f' % (
circle[i_ref][2], circle[i_2nd][2])
print 'Focal lengths (diopters); ref: %.2f, 2nd: %.2f' % (
fl[i_ref], fl[i_2nd])
print 'Sensor diagonals (pixels); ref: %.2f, 2nd: %.2f' % (
sensor_diag[i_ref], sensor_diag[i_2nd])
msg = 'Circle size scales improperly! RTOL=%.1f' % CIRCLE_RTOL
msg += '\nMetric: radius/focal_length*sensor_diag should be equal.'
assert np.isclose(circle[i_ref][2]/fl[i_ref]*sensor_diag[i_ref],
circle[i_2nd][2]/fl[i_2nd]*sensor_diag[i_2nd],
rtol=CIRCLE_RTOL), msg
if __name__ == '__main__':
main()