| # Copyright (c) 2012 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| # Import guard for OpenCV. |
| try: |
| import cv |
| import cv2 |
| except ImportError: |
| pass |
| |
| import itertools |
| import math |
| import numpy as np |
| import sys |
| |
| import mtf_calculator |
| import grid_mapper |
| |
| from camera_utils import Pod |
| from camera_utils import Pad |
| from camera_utils import Unpad |
| from multiprocessing import Pool |
| |
| _CORNER_MAX_NUM = 1000000 |
| _CORNER_QUALITY_RATIO = 0.05 |
| _CORNER_MIN_DISTANCE_RATIO = 0.016 |
| |
| _EDGE_LINK_THRESHOLD = 2000 |
| _EDGE_DETECT_THRESHOLD = 4000 |
| _EDGE_MIN_SQUARE_SIZE_RATIO = 0.024 |
| |
| _POINT_MATCHING_MAX_TOLERANCE_RATIO = 0.020 |
| |
| _IMAGE_DEFAULT_MAX_SHIFT = 0.025 |
| _IMAGE_DEFAULT_MAX_TILT = 1 |
| |
| _MTF_DEFAULT_MAX_CHECK_NUM = 308 |
| _MTF_DEFAULT_PATCH_WIDTH = 20 |
| _MTF_DEFAULT_CROP_RATIO = 0.25 |
| _MTF_DEFAULT_CHECK_PASS_VALUE = 0.45 |
| _MTF_DEFAULT_CHECK_PASS_VALUE_LOWEST = 0.10 |
| _MTF_DEFAULT_THREAD_COUNT = 4 |
| |
| _SHADING_DOWNSAMPLE_SIZE = 250.0 |
| _SHADING_BILATERAL_SPATIAL_SIGMA = 20 |
| _SHADING_BILATERAL_RANGE_SIGMA = 0.15 |
| _SHADING_DEFAULT_MAX_RESPONSE = 0.01 |
| _SHADING_DEFAULT_MAX_TOLERANCE_RATIO = 0.15 |
| |
| |
| class ReturnValue(Pod): |
| pass |
| |
| |
| def _MTFComputeWrapper(args): |
| '''Parallel MTF computation wrapper function.''' |
| return mtf_calculator.Compute(*args)[0] |
| |
| |
| def _FindCornersOnConvexHull(hull): |
| '''Find the four corners of a rectangular point grid.''' |
| # Compute the inner angle of each point on the hull. |
| hull_left = np.roll(hull, -1, axis=0) |
| hull_right = np.roll(hull, 1, axis=0) |
| |
| hull_dl = hull_left - hull |
| hull_dr = hull_right - hull |
| |
| angle = np.sum(hull_dl * hull_dr, axis=1) |
| angle /= np.sum(hull_dl ** 2, axis=1) ** (1./2) |
| angle /= np.sum(hull_dr ** 2, axis=1) ** (1./2) |
| |
| # Take the top four sharpest angle points and |
| # arrange them in the same order as on the hull. |
| corners = hull[np.sort(np.argsort(angle)[:-5:-1])] |
| return corners |
| |
| |
| def _ComputeCosine(a, b): |
| '''Compute the cosine value of the angle bewteen two vectors a and b.''' |
| return np.sum(a * b) / math.sqrt(np.sum(a ** 2) * np.sum(b ** 2) + 1e-10) |
| |
| |
| def _ComputeShiftAndTilt(rect, img_size): |
| '''Compute the shift and tilt values for a given rectangle in the form of |
| its four corners. |
| |
| Args: |
| rect: The four corners of the rectangle in the contour's order. |
| img_size: The size of the whole image. |
| |
| Returns: |
| The shift vector length. |
| The tilt degrees. |
| The shift vector. |
| ''' |
| # Compute the shift. |
| center = np.sum(rect, axis=0) / 4.0 |
| shift = center - np.array((img_size[1] - 1.0, img_size[0] - 1.0)) / 2.0 |
| shift_len = math.sqrt(np.sum(shift ** 2)) |
| |
| # Compute the tilt (image rotation). |
| va = (rect[1] - rect[0] + rect[2] - rect[3]) / 2.0 |
| vb = (rect[2] - rect[1] + rect[3] - rect[0]) / 2.0 |
| la = math.sqrt(np.sum(va ** 2)) |
| lb = math.sqrt(np.sum(vb ** 2)) |
| # Get the sign of the rotation angle by looking at the long side. |
| sign = (np.sign(-va[1] * va[0]) if la > lb else np.sign(-vb[1] * vb[0])) |
| if sign == 0: |
| sign = 1 |
| da = np.max(np.abs(va)) / la |
| db = np.max(np.abs(vb)) / lb |
| return shift_len, sign * math.degrees(math.acos((da + db) / 2.0)), shift |
| |
| |
| def _CheckSquareness(contour, min_square_area): |
| '''Check the squareness of a contour.''' |
| if len(contour) != 4: |
| return False |
| |
| # Filter out noise squares. |
| if cv2.contourArea(Pad(contour)) < min_square_area: |
| return False |
| |
| # Check convexity. |
| if not cv2.isContourConvex(Pad(contour)): |
| return False |
| |
| min_angle = 0 |
| for i in range(0, 4): |
| # Find the minimum inner angle. |
| dl = contour[i] - contour[i-1] |
| dr = contour[i-2] - contour[i-1] |
| angle = _ComputeCosine(dl, dr) |
| |
| ac = abs(angle) |
| if ac > min_angle: |
| min_angle = ac |
| |
| # If the absolute value of cosines of all angles are small, then all angles |
| # are ~90 degree -> implies a square. |
| if min_angle > 0.3: |
| return False |
| return True |
| |
| |
| def _ExtractEdgeSegments(edge_map, min_square_size_ratio): |
| '''Extract robust edges of squares from a binary edge map.''' |
| diag_len = math.sqrt(edge_map.shape[0] ** 2 + edge_map.shape[1] ** 2) |
| min_square_area = int(round(diag_len * min_square_size_ratio)) ** 2 |
| |
| # Dilate the output from Canny to fix broken edge segments. |
| edge_map = edge_map.copy() |
| cv.Dilate(edge_map, edge_map, None, 1) |
| |
| # Find contours of the binary edge map. |
| squares = [] |
| storage = cv.CreateMemStorage() |
| contours = cv.FindContours(edge_map, storage, cv.CV_RETR_TREE, |
| cv.CV_CHAIN_APPROX_SIMPLE) |
| |
| # Check if each contour is a square. |
| storage = cv.CreateMemStorage() |
| while contours: |
| # Approximate contour with an accuracy proportional to the contour |
| # perimeter length. |
| arc_len = cv.ArcLength(contours) |
| polygon = cv.ApproxPoly(contours, storage, cv.CV_POLY_APPROX_DP, |
| arc_len * 0.02) |
| polygon = np.array(polygon, dtype=np.float32) |
| |
| # If the contour passes the squareness check, add it to the list. |
| if _CheckSquareness(polygon, min_square_area): |
| sq_edges = np.hstack((polygon, np.roll(polygon, -1, axis=0))) |
| for t in range(4): |
| squares.append(sq_edges[t]) |
| |
| contours = contours.h_next() |
| |
| return np.array(squares, dtype=np.float32) |
| |
| |
| def _StratifiedSample2D(xys, n, dims=None, strict=False): |
| '''Do stratified random sampling on a 2D point set. |
| |
| The algorithm will try to spread the samples around the plane. |
| |
| Args: |
| n: Sample count requested. |
| dims: The x-y plane size that is used to normalize the coordinates. |
| strict: Should we fall back to the pure random sampling on failure. |
| |
| Returns: |
| A list of indexes of sampled points. |
| ''' |
| if not dims: |
| dims = np.array([1, 1.0]) |
| |
| # Place the points onto the grid in a random order. |
| ln = xys.shape[0] |
| perm = np.random.permutation(ln) |
| grid_size = math.ceil(math.sqrt(n)) |
| taken = np.zeros((grid_size, grid_size), dtype=np.bool) |
| result = [] |
| for t in perm: |
| # Normalize the coordinates to [0, 1). |
| gx = int(xys[t, 0] * grid_size / dims[1]) |
| gy = int(xys[t, 1] * grid_size / dims[0]) |
| if not taken[gy, gx]: |
| taken[gy, gx] = True |
| result.append(t) |
| if len(result) == n: |
| break |
| |
| # Fall back to the pure random sampling on failure. |
| if len(result) != n: |
| if not strict: |
| return perm[0:n] |
| return None |
| return np.array(result) |
| |
| |
| def PrepareTest(pat_file): |
| '''Extract information from the reference test pattern. |
| |
| The data will be used in the actual test as the ground truth. |
| ''' |
| ret = ReturnValue() |
| |
| # Locate corners. |
| pat = cv2.imread(pat_file, cv.CV_LOAD_IMAGE_GRAYSCALE) |
| diag_len = math.sqrt(pat.shape[0] ** 2 + pat.shape[1] ** 2) |
| min_corner_dist = diag_len * _CORNER_MIN_DISTANCE_RATIO |
| |
| ret.corners = Unpad( |
| cv2.goodFeaturesToTrack(pat, _CORNER_MAX_NUM, _CORNER_QUALITY_RATIO, |
| min_corner_dist)) |
| |
| ret.pmatch_tol = diag_len * _POINT_MATCHING_MAX_TOLERANCE_RATIO |
| |
| # Locate four corners of the corner grid. |
| hull = Unpad(cv2.convexHull(Pad(ret.corners))) |
| ret.four_corners = _FindCornersOnConvexHull(hull) |
| |
| # Locate edges. |
| edge_map = cv2.Canny(pat, _EDGE_LINK_THRESHOLD, _EDGE_DETECT_THRESHOLD, |
| apertureSize=5) |
| |
| ret.edges = _ExtractEdgeSegments(edge_map, _EDGE_MIN_SQUARE_SIZE_RATIO) |
| return ret |
| |
| |
| def CheckLensShading(sample, check_low_freq=True, |
| max_response=_SHADING_DEFAULT_MAX_RESPONSE, |
| max_shading_ratio=_SHADING_DEFAULT_MAX_TOLERANCE_RATIO): |
| '''Check if lens shading is present. |
| |
| Args: |
| sample: The test target image. It needs to be single-channel. |
| check_low_freq: Check low frequency variation or not. The low frequency |
| is very sensitive to uneven illumination so one may want |
| to turn it off when a fixture is not available. |
| max_response: Maximum acceptable response of low frequency variation. |
| max_shading_ratio: Maximum acceptable shading ratio value of boundary |
| pixels. |
| |
| Returns: |
| 1: Pass or Fail. |
| 2: A structure contains the response value and the error message in case |
| the test failed. |
| ''' |
| ret = ReturnValue(msg=None) |
| |
| # Downsample for speed. |
| ratio = _SHADING_DOWNSAMPLE_SIZE / max(sample.shape) |
| img = cv2.resize(sample, None, fx=ratio, fy=ratio, |
| interpolation=cv2.INTER_AREA) |
| img = img.astype(np.float32) |
| |
| # Method 1 - Low-frequency variation check: |
| ret.check_low_freq = False |
| if check_low_freq: |
| ret.check_low_freq = True |
| # Homomorphic filtering. |
| ilog = np.log(0.001 + img) |
| # Substract a bilateral smoothed version. |
| ismooth = cv2.bilateralFilter(ilog, |
| _SHADING_BILATERAL_SPATIAL_SIGMA * 3, |
| _SHADING_BILATERAL_RANGE_SIGMA, |
| _SHADING_BILATERAL_SPATIAL_SIGMA, |
| borderType=cv2.BORDER_REFLECT) |
| ihigh = ilog - ismooth |
| |
| # Check if there are significant response. |
| # The response is computed as the 95th pencentile minus the median. |
| ihsorted = np.sort(ihigh, axis=None) |
| N = ihsorted.shape[0] |
| peak_to_med = ihsorted[int(0.95 * N)] - ihsorted[int(0.5 * N)] |
| ret.response = peak_to_med |
| if peak_to_med > max_response: |
| ret.msg = 'Found significant low-frequency variation.' |
| return False, ret |
| |
| # Method 2 - Boundary scan: |
| # Get the mean of top 5 percent pixels. |
| ihsorted = np.sort(img, axis=None) |
| mtop = np.mean(ihsorted[int(0.95 * ihsorted.shape[0]):ihsorted.shape[0]]) |
| pass_value = mtop * (1.0 - max_shading_ratio) |
| |
| # Check if any pixel on the boundary is lower than the threshold. |
| # A little smoothing to deal with the possible noise. |
| k_size = (7, 7) |
| ret.msg = 'Found dark pixels on the boundary.' |
| if np.any(cv2.blur(img[0, :], k_size) < pass_value): |
| return False, ret |
| if np.any(cv2.blur(img[-1, :], k_size) < pass_value): |
| return False, ret |
| if np.any(cv2.blur(img[:, 0], k_size) < pass_value): |
| return False, ret |
| if np.any(cv2.blur(img[:, -1], k_size) < pass_value): |
| return False, ret |
| |
| ret.msg = None |
| return True, ret |
| |
| |
| def CheckVisualCorrectness( |
| sample, ref_data, register_grid=False, corner_only=False, |
| min_corner_quality_ratio=_CORNER_QUALITY_RATIO, |
| min_square_size_ratio=_EDGE_MIN_SQUARE_SIZE_RATIO, |
| min_corner_distance_ratio=_CORNER_MIN_DISTANCE_RATIO, |
| max_image_shift=_IMAGE_DEFAULT_MAX_SHIFT, |
| max_image_tilt=_IMAGE_DEFAULT_MAX_TILT): |
| '''Check if the test pattern is present. |
| |
| Args: |
| sample: The test target image. It needs to be single-channel. |
| ref_data: A struct that contains information extracted from the |
| reference pattern using PrepareTest. |
| register_grid: Check if the point grid can be matched to the reference |
| one, i.e. whether they are of the same type. |
| corner_only: Check only the corners (skip the edges). |
| min_corner_quality_ratio: Minimum acceptable relative corner quality |
| difference. |
| min_square_size_ratio: Minimum allowed square edge length in relative |
| to the image diagonal length. |
| min_corner_distance_ratio: Minimum allowed corner distance in relative |
| to the image diagonal length. |
| max_image_shift: Maximum allowed image center shift in relative to the |
| image diagonal length. |
| max_image_tilt: Maximum allowed image tilt amount in degrees. |
| |
| Returns: |
| 1: Pass or Fail. |
| 2: A structure contains the found corners and edges and the error |
| message in case the test failed. |
| ''' |
| ret = ReturnValue(msg=None) |
| |
| # CHECK 1: |
| # a) See if all corners are present with reasonable strength. |
| edge_map = cv2.Canny(sample, _EDGE_LINK_THRESHOLD, _EDGE_DETECT_THRESHOLD, |
| apertureSize=5) |
| dilator = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) |
| edge_mask = cv2.dilate(edge_map, dilator) |
| |
| diag_len = math.sqrt(sample.shape[0] ** 2 + sample.shape[1] ** 2) |
| min_corner_dist = diag_len * min_corner_distance_ratio |
| |
| sample_corners = cv2.goodFeaturesToTrack(sample, ref_data.corners.shape[0], |
| min_corner_quality_ratio, |
| min_corner_dist, mask=edge_mask) |
| if sample_corners is None: |
| ret.msg = "Can't find strong corners." |
| return False, ret |
| sample_corners = Unpad(sample_corners) |
| ret.sample_corners = sample_corners |
| |
| # b) Check if we got the same amount of corners as the reference. |
| if sample_corners.shape[0] != ref_data.corners.shape[0]: |
| ret.msg = "Can't find the same amount of corners." |
| return False, ret |
| |
| # Find the 4 corners of the square grid. |
| hull = Unpad(cv2.convexHull(Pad(sample_corners))) |
| if hull.shape[0] < 4: |
| ret.msg = "All the corners are co-linear." |
| return False, ret |
| ret.four_corners = _FindCornersOnConvexHull(hull) |
| |
| # c) Check if the image shift and tilt amount are within the spec. |
| shift_len, ret.tilt, ret.v_shift = _ComputeShiftAndTilt(ret.four_corners, |
| sample.shape) |
| ret.shift = shift_len / diag_len |
| if ret.shift > max_image_shift: |
| ret.msg = 'The image shift is too large.' |
| return False, ret |
| if abs(ret.tilt) > max_image_tilt: |
| ret.msg = 'The image tilt is too large.' |
| return False, ret |
| # TODO(sheckylin) Refine points locations. |
| |
| # Perform point grid registration if requested. This can confirm that the |
| # desired test pattern is correctly found (i.e. not fooled by some other |
| # stuff) and also that the geometric distortion is small. However, we |
| # choose to skip it by default due to the heavy computation demands. |
| # TODO(sheckylin) Enable it after the C++ registration module is done. |
| ret.register_grid = False |
| if register_grid: |
| ret.register_grid = True |
| |
| # There are 4 possible mappings of the 4 corners between the reference |
| # and the sample due to rotation because we can't tell the starting |
| # point of the convex hull on the rectangle grid. |
| match = False |
| for i in range(0,4): |
| success, homography, _ = grid_mapper.Register( |
| four_corners, sample_corners, ref_data.four_corners, |
| ref_data.corners, ref_data.pmatch_tol) |
| if success: |
| match = True |
| break |
| |
| four_corners = np.roll(four_corners, 1, axis=0) |
| |
| # CHECK 2: |
| # Check if all corners are successfully mapped. |
| if not match: |
| ret.msg = "Can't match the sample to the reference." |
| return False, ret |
| ret.homography = homography |
| |
| if corner_only: |
| return True, ret |
| |
| # Find squares on the edge map. |
| edges = _ExtractEdgeSegments(edge_map, min_square_size_ratio) |
| |
| # CHECK 3: |
| # Check if we can find the same amount of edges on the target. |
| ret.edges = edges |
| if edges.shape[0] != ref_data.edges.shape[0]: |
| ret.msg = "Can't find the same amount of squares/edges." |
| return False, ret |
| return True, ret |
| |
| |
| def CheckSharpness(sample, edges, |
| min_pass_mtf=_MTF_DEFAULT_CHECK_PASS_VALUE, |
| min_pass_lowest_mtf=_MTF_DEFAULT_CHECK_PASS_VALUE_LOWEST, |
| mtf_sample_count=_MTF_DEFAULT_MAX_CHECK_NUM, |
| mtf_patch_width=_MTF_DEFAULT_PATCH_WIDTH, |
| mtf_crop_ratio=_MTF_DEFAULT_CROP_RATIO, |
| use_50p=True, |
| n_thread=_MTF_DEFAULT_THREAD_COUNT): |
| '''Check if the captured image is sharp. |
| |
| Args: |
| sample: The test target image. It needs to be single-channel. |
| edges: A list of edges on the test image. Should be extracted with |
| CheckVisualCorrectness. |
| min_pass_mtf: Minimum acceptable (median) MTF value. |
| min_pass_lowest_mtf: Minimum acceptable lowest MTF value. |
| mtf_sample_count: How many edges we are going to compute MTF values. |
| mtf_patch_width: The desired margin on the both side of an edge. Larger |
| margins provides more precise MTF values. |
| mtf_crop_ratio: How much we want to truncate at the beginning and the |
| end of the edge. Lower value (less truncation) better |
| reduces the MTF value variations between each test. |
| use_50p: Compute whether the MTF50P value or the MTF50 value. |
| n_thread: Number of threads to use to compute MTF values. |
| |
| Returns: |
| 1: Pass or Fail. |
| 2: A structure contains the median MTF value (MTF50P) and the error |
| message in case the test failed. |
| ''' |
| ret = ReturnValue(msg=None) |
| |
| if (mtf_sample_count <= 0 or mtf_patch_width <= 0 or mtf_crop_ratio < 0 or |
| mtf_crop_ratio > 1 or edges is None): |
| ret.msg = 'Input values are invalid.' |
| return False, ret |
| line_start = edges[:, [0, 1]] |
| line_end = edges[:, [2, 3]] |
| ln = line_start.shape[0] |
| |
| # Compute MTF for some edges. |
| # Random sample a few edges to work on. |
| n_check = min(ln, mtf_sample_count) |
| mids = (line_start + line_end) / 2 |
| mids = mids - np.amin(mids, axis=0) |
| new_dim = np.amax(mids, axis=0) + 1 |
| perm = _StratifiedSample2D(mids, n_check, tuple([new_dim[1], new_dim[0]])) |
| |
| # Multi-threading to speed up the computation. |
| if n_thread > 1: |
| pool = Pool(processes=min(n_thread, n_check)) |
| mtfs = pool.map(_MTFComputeWrapper, itertools.izip( |
| itertools.repeat(sample), line_start[perm], line_end[perm], |
| itertools.repeat(mtf_patch_width), itertools.repeat(mtf_crop_ratio), |
| itertools.repeat(use_50p))) |
| pool.close() |
| pool.join() |
| else: |
| mtfs = [mtf_calculator.Compute(sample, line_start[t], line_end[t], |
| mtf_patch_width, mtf_crop_ratio, |
| use_50p)[0] for t in perm] |
| |
| # CHECK 1: |
| # Check if the median of MTF values pass the threshold. |
| ret.perm = perm |
| ret.mtfs = np.array(mtfs) |
| ret.mtf = np.median(ret.mtfs) |
| if ret.mtf < min_pass_mtf: |
| ret.msg = 'The MTF values are too low.' |
| return False, ret |
| |
| # CHECK 2: |
| # Check if the minimum of MTF values pass the threshold. |
| ret.min_mtf = np.amin(ret.mtfs) |
| if ret.min_mtf < min_pass_lowest_mtf: |
| ret.msg = 'The min MTF value is too low.' |
| return False, ret |
| return True, ret |