Source code for openalea.phenomenal.multi_view_reconstruction.multi_view_reconstruction

# -*- python -*-
#
#       Copyright INRIA - CIRAD - INRA
#
#       Distributed under the Cecill-C License.
#       See accompanying file LICENSE.txt or copy at
#           http://www.cecill.info/licences/Licence_CeCILL-C_V1-en.html
#
# ==============================================================================

import collections
import cv2
import scipy.spatial
import numpy

from ..object import VoxelGrid, ImageView

# ==============================================================================
# Class

Voxels = collections.namedtuple("Voxels", ["position", "size"])

# ==============================================================================
# deprecated numpy-python slow integral (kept as explicit 'doc' of what integral image is for us)


[docs] def get_integrale_image(img): a = numpy.zeros_like(img, dtype=int) a[img > 0] = 1 for y, x in numpy.ndindex(a.shape): if x - 1 >= 0: a[y, x] += a[y, x - 1] if y - 1 >= 0: a[y, x] += a[y - 1, x] if x - 1 >= 0 and y - 1 >= 0: a[y, x] -= a[y - 1, x - 1] return a
[docs] def integral_image(input_array): binary = (input_array > 0).astype(numpy.uint8) integral = cv2.integral(binary, sdepth=cv2.CV_32S) return integral[1:, 1:]
[docs] def find_best_angle(bin_side_images): max_len = 0 max_angle = None for angle in bin_side_images: x_pos, y_pos, x_len, y_len = cv2.boundingRect( cv2.findNonZero(bin_side_images[angle]) ) if x_len > max_len: max_len = x_len max_angle = angle return max_angle
[docs] def get_voxels_corners(voxels_position, voxels_size): """According to the voxels position and their size, return a numpy array containing for each input voxels the position of the 8 corners. Parameters ---------- voxels_position : numpy.ndarray Center position of the voxels voxels_size : float Diameter size of the voxels Returns ------- a : numpy.array """ r = voxels_size / 2.0 x_minus = voxels_position[:, 0] - r x_plus = voxels_position[:, 0] + r y_minus = voxels_position[:, 1] - r y_plus = voxels_position[:, 1] + r z_minus = voxels_position[:, 2] - r z_plus = voxels_position[:, 2] + r a1 = numpy.column_stack((x_minus, y_minus, z_minus)) a2 = numpy.column_stack((x_plus, y_minus, z_minus)) a3 = numpy.column_stack((x_minus, y_plus, z_minus)) a4 = numpy.column_stack((x_minus, y_minus, z_plus)) a5 = numpy.column_stack((x_plus, y_plus, z_minus)) a6 = numpy.column_stack((x_plus, y_minus, z_plus)) a7 = numpy.column_stack((x_minus, y_plus, z_plus)) a8 = numpy.column_stack((x_plus, y_plus, z_plus)) a = numpy.concatenate((a1, a2, a3, a4, a5, a6, a7, a8), axis=1) a = numpy.reshape(a, (a.shape[0] * 8, 3)) return a
[docs] def get_bounding_box_voxel_projected(voxels_position, voxels_size, projection): """Compute the bounding box value according the radius, angle and calibration parameters of point_3d projection Parameters ---------- voxels_position : numpy.ndarray Center position of voxel voxels_size : float Size of side geometry of voxel projection : function ((x, y, z)) -> (x, y) Function of projection who take 1 argument (tuple of position (x, y, z)) and return this position 2D (x, y) Returns ------- bbox : numpy.ndarray [[x_min, x_max, y_min, y_max], ...] Containing min and max value of point_3d projection in x and y axes. """ voxels_corners = get_voxels_corners(voxels_position, voxels_size) pt = projection(voxels_corners) pt = numpy.reshape(pt, (pt.shape[0] // 8, 8, 2)) bbox = numpy.column_stack((pt.min(axis=1), pt.max(axis=1))) return bbox
# ==============================================================================
[docs] def split_voxels_in_eight(voxels): """Split each voxel in 8 en return the numpy.array position _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ / /| / / /| / / | /--------/---------/ | /_ _ _ _ _ _ _ _ _ / | /_ _ _ _ / _ _ _ _ /| | | | | | | | |/| | | | =======> | | | / | | | | |_ _ _ _ | _ _ _ _ |/| | | | / | | | |/ | | / | | | / | _ _ _ _ _ _ _ _ _|/ |_ _ _ _ | _ _ _ _ |/ Parameters ---------- voxels : Voxels Where position is a numpy.array([[x, y, z], ...] containing the center position of each voxel and size the diameter size value (float) of each voxel. Returns ------- voxels : Voxels """ if len(voxels.position) == 0: return Voxels(voxels.position, voxels.size / 2.0) r = voxels.size / 4.0 x_minus = voxels.position[:, 0] - r x_plus = voxels.position[:, 0] + r y_minus = voxels.position[:, 1] - r y_plus = voxels.position[:, 1] + r z_minus = voxels.position[:, 2] - r z_plus = voxels.position[:, 2] + r a1 = numpy.column_stack((x_minus, y_minus, z_minus)) a2 = numpy.column_stack((x_plus, y_minus, z_minus)) a3 = numpy.column_stack((x_minus, y_plus, z_minus)) a4 = numpy.column_stack((x_minus, y_minus, z_plus)) a5 = numpy.column_stack((x_plus, y_plus, z_minus)) a6 = numpy.column_stack((x_plus, y_minus, z_plus)) a7 = numpy.column_stack((x_minus, y_plus, z_plus)) a8 = numpy.column_stack((x_plus, y_plus, z_plus)) return Voxels( numpy.concatenate((a1, a2, a3, a4, a5, a6, a7, a8), axis=0), voxels.size / 2.0 )
# ==============================================================================
[docs] def integral_image_hits(boxes, image_int, width, height): """ boxes: (N, 4) array → [x_min, y_min, x_max, y_max] (float or int) returns: boolean array (N,) → True if any pixel inside box > 0 """ if len(boxes) == 0: return numpy.zeros(0, dtype=bool) # Floor + convert once boxes = numpy.floor(boxes).astype(numpy.int32) # Clip to image bounds boxes[:, 0] = numpy.clip(boxes[:, 0], 0, width - 1) boxes[:, 2] = numpy.clip(boxes[:, 2], 0, width - 1) boxes[:, 1] = numpy.clip(boxes[:, 1], 0, height - 1) boxes[:, 3] = numpy.clip(boxes[:, 3], 0, height - 1) # Integral image offset trick boxes[:, 0:2] -= 1 boxes[boxes < 0] = 0 x0 = boxes[:, 0] y0 = boxes[:, 1] x1 = boxes[:, 2] y1 = boxes[:, 3] # Vectorized integral image query sums = ( image_int[y1, x1] + image_int[y0, x0] - image_int[y0, x1] - image_int[y1, x0] ) return sums > 0
[docs] def voxels_is_visible_in_image( voxels_position, voxels_size, image, projection, inclusive, image_int=None): """ Return a numpy array containing True if the voxel are projected is photo-consistent on image else False **Algorithm** 1. Project each voxel center position on the image, if the position projected (x, y) is positive on image return True | 2. If the bounding box of the voxel projected have positive value on the image the voxel are True | 3. Check if one pixel containing in the bounding box projected on image have positive value, if yes return True else return False Parameters ---------- voxels_position : numpy.array([[x, y, z], ...] Center position of the voxels voxels_size : float diameter size of the voxels image: numpy.array Binary image where the voxels are projected. projection : function (numpy.array([[x, y, z], ...]) -> numpy.array([[x, y], ...]) Function of projection who take 1 argument (numpy.array([[x, y, z], ...] of voxels positions) and return the projected 2D position numpy.array([[x, y], ...]) inclusive: Describe if the voxels projection are out of the image, they are considered like still visible image_int: Integral image of the binary image (optimization) Returns ------- out : numpy.array([True, False, ...]) Numpy array containing True if the voxel are projected is photo-consistent on image else False """ height, width = image.shape N = len(voxels_position) mask = numpy.zeros(N, dtype=bool) # ------------------------------------------------------------------ # 1. Mark True if voxel center projection hits a non-zero pixel on image pixel_coords = projection(voxels_position) px = pixel_coords[:, 0] py = pixel_coords[:, 1] # In-bounds indices idx = numpy.nonzero( (px >= 0) & (py >= 0) & (px < width) & (py < height) )[0] if idx.size > 0: pix = pixel_coords[idx].astype(numpy.int32) mask[idx] |= image[pix[:, 1], pix[:, 0]] > 0 # ------------------------------------------------------------------ # 2. Filter and keep no-hits voxels keep_idx = numpy.nonzero(~mask)[0] if keep_idx.size == 0: return mask voxels_position_kept = voxels_position[keep_idx] # ------------------------------------------------------------------ # 3. Project voxels and get projected Bounding boxes min_xy_max_xy = get_bounding_box_voxel_projected( voxels_position_kept, voxels_size, projection ) x_min = min_xy_max_xy[:, 0] y_min = min_xy_max_xy[:, 1] x_max = min_xy_max_xy[:, 2] y_max = min_xy_max_xy[:, 3] # Out-of-screen are directly mark as True if inclusive outside = ( (x_max < 0) | (x_min >= width) | (y_max < 0) | (y_min >= height) ) # Write directly into global mask mask[keep_idx[outside]] = True if inclusive else False # -------------------------------------------------------------- # 4. Only process remaining inside_idx = keep_idx[~outside] if inside_idx.size > 0: boxes = min_xy_max_xy[~outside] hits = integral_image_hits(boxes, image_int, width, height) mask[inside_idx] |= hits return mask
# ==============================================================================
[docs] def reconstruction_grid(center=(0.0, 0.0, 0.0), grid_size=4096, voxel_size=512): """ Setup a reconstruction grid Args: center: coordinates of the center of the grid grid_size: outer edge length of the grid voxel_size: edge length of voxels composing the grid Returns: a Voxels (positions, size) named tuple """ voxels_position = numpy.array([center]) voxels = Voxels(voxels_position, grid_size) while voxels.size != voxel_size: voxels = split_voxels_in_eight(voxels) return voxels
[docs] def check_each(image_views, check=True): """Return a check dict for all keys in image_views according to check Args: image_views : {name: ImageView, ...} Dict of phenomenal.object.ImageView objects gathering image, projection, where image is a binary image (numpy.ndarray) and projection a function projecting (x, y, z) -> (u, v) coordinate on image check: bool | str | [str,...] If True or False, the value associated to each key present in image_views. If a list of name, the names to be set to True """ if isinstance(check, bool): return {k: check for k in image_views} check = [check] if isinstance(check, str) else check return { name: any(name.startswith(cam) for cam in check) for name in image_views }
[docs] def filter_voxels(voxels, image_views, error_tolerance=0, clear_outside=True): """Filter voxels, keeping the one photo_consistent with all minus error_tolerance images in image_views""" clear_view = check_each(image_views, clear_outside) photo_consistent_score = numpy.zeros((len(voxels.position),), dtype=int) for i, (k, image_view) in enumerate(image_views.items()): if image_view.integral is None: image_view.integral = integral_image(image_view.image) inclusive = not clear_view[k] photo_consistent_score += voxels_is_visible_in_image( voxels.position, voxels.size, image_view.image, image_view.projection, inclusive, image_view.integral ) cond = photo_consistent_score >= i + 1 - error_tolerance voxels = Voxels(voxels.position[cond], voxels.size) photo_consistent_score = photo_consistent_score[cond] return voxels
[docs] def tolerant_reconstruction(image_views, voxels_size=4, error_tolerance=0, clear_outside=None, start=None): if start is None: voxels = reconstruction_grid() elif isinstance(start, VoxelGrid): voxels = Voxels(start.voxels_position, start.voxels_size) else: voxels = start while voxels.size > voxels_size: if len(voxels.position) == 0: break voxels = split_voxels_in_eight(voxels) voxels = filter_voxels(voxels, image_views, error_tolerance=error_tolerance, clear_outside=clear_outside) return voxels
[docs] def multi_tolerant_reconstruction(image_views, voxels_size=4, max_tolerance=0, clear_outside=None, start=None): voxels = tolerant_reconstruction(image_views, voxels_size=voxels_size, error_tolerance=0, clear_outside=clear_outside, start=start) not_reconstructed = {} for k, iv in image_views.items(): projected = project_voxel_centers_on_image( voxels.position, voxels.size, iv.image.shape, iv.projection, ) view = ImageView(numpy.logical_not(projected)*255, iv.projection) not_reconstructed[k] = view for i in range(max_tolerance): new_voxels = reconstruction_grid() while new_voxels.size > voxels_size: if len(new_voxels.position) == 0: break new_voxels = split_voxels_in_eight(new_voxels) new_voxels = filter_voxels(new_voxels, image_views, error_tolerance=i + 1, clear_outside=clear_outside) new_voxels = filter_voxels(new_voxels, not_reconstructed, error_tolerance=0, clear_outside=clear_outside) if len(new_voxels.position) == 0: break for k, iv in image_views.items(): projected = project_voxel_centers_on_image( new_voxels.position, new_voxels.size, iv.image.shape, iv.projection, ) not_reconstructed[k].image *= numpy.logical_not(projected) voxels = Voxels(numpy.concatenate((voxels.position, new_voxels.position), axis=0), voxels.size) return voxels
[docs] def reconstruction_3d( image_views, voxels_size=4, error_tolerance=0, start=None, clear_outside=True ): """ Construct a list of voxel represented object with positive value on binary image in images of images_projections. Parameters ---------- image_views : {name: ImageView, ...} Dict of phenomenal.object.ImageView objects gathering image, projection, where image is a binary image (numpy.ndarray) and projection a function projecting (x, y, z) -> (u, v) coordinate on image voxels_size : float, optional Edge length of reconstructed voxels error_tolerance : int, optional Control the degree of consistency of the reconstructed object with its projected views If not provided the reconstruction is fully consistent with all views (error_tolerance=0) If positive, the number of inconsistent views tolerated per voxel If negative, a composite 3d reconstruction is computed, iteratively aggregating reconstructions of different tolerance, from zero to abs(error_tolerance), discarding at each step voxels projecting on projections of already reconstructed start : VoxelGrid | [Voxel,..], optional A voxel grid to be used as starting point. If None (default), a grid returned by defaults of openalea.phenomenal.multiview_reconstruction.reconstruction_grid clear_outside: bool | str | [str,...], optional Should voxels projected outside image_views be kept ? True (default) or False set a unique rule for all views. if a list of name is provided, only image_views whose key starts with names are used to clear voxels Returns ------- out : VoxelGrid """ if len(image_views) == 0: raise ValueError("Images views is empty") if error_tolerance >= 0: voxels = tolerant_reconstruction(image_views, voxels_size=voxels_size, error_tolerance=error_tolerance, start=start, clear_outside=clear_outside) else: voxels = multi_tolerant_reconstruction(image_views, voxels_size=voxels_size, max_tolerance=abs(error_tolerance), start=start, clear_outside=clear_outside) return VoxelGrid(voxels.position, voxels.size)
# ==============================================================================
[docs] def project_voxel_centers_on_image( voxels_position, voxels_size, shape_image, projection, value=255, dtype=numpy.uint8 ): """ Create a image with same shape that shape_image and project each voxel on image and write positive value (255) on it. Parameters ---------- voxels_position : numpy.ndarray Voxels center position [[x, y, z], ...] voxels_size : float Diameter size of the voxels shape_image: 2-tuple Size height and width of the image target projected projection : function ((x, y, z)) -> (x, y) Function of projection who take 1 argument (tuple of position (x, y, z)) and return this position 2D (x, y) value : int value between 0 and 255 of positive pixel. By default, 255. dtype : type numpy type of the returned image. By default, numpy.uint8. Returns ------- out : numpy.ndarray Binary image """ height, length = shape_image img = numpy.zeros((height, length), dtype=dtype) min_xy_max_xy = get_bounding_box_voxel_projected( voxels_position, voxels_size, projection ) vv = ( (min_xy_max_xy[:, 2] < 0) | (min_xy_max_xy[:, 0] >= length) | (min_xy_max_xy[:, 3] < 0) | (min_xy_max_xy[:, 1] >= height) ) not_vv = numpy.logical_not(vv) min_xy_max_xy = min_xy_max_xy[not_vv] min_xy_max_xy = numpy.floor(min_xy_max_xy) min_xy_max_xy[min_xy_max_xy < 0] = 0 (min_xy_max_xy[:, 0])[min_xy_max_xy[:, 0] >= length] = length - 1 (min_xy_max_xy[:, 1])[min_xy_max_xy[:, 1] >= height] = height - 1 (min_xy_max_xy[:, 2])[min_xy_max_xy[:, 2] >= length] = length - 1 (min_xy_max_xy[:, 3])[min_xy_max_xy[:, 3] >= height] = height - 1 min_xy_max_xy = min_xy_max_xy.astype(int) for x_min, y_min, x_max, y_max in min_xy_max_xy: img[y_min : y_max + 1, x_min : x_max + 1] = value return img
[docs] def project_voxels_position_on_image( voxels_position, voxels_size, shape_image, projection ): """ Create an image with same shape that shape_image and project each voxel on image and write positive value (255) on it. Parameters ---------- voxels_position : [(x, y, z)] cList (collections.deque) of center position of voxel voxels_size : float Size of side geometry of voxel shape_image: Tuple size height and length of the image target projected projection : function ((x, y, z)) -> (x, y) Function of projection who take 1 argument (tuple of position (x, y, z)) and return this position 2D (x, y) Returns ------- out : numpy.ndarray Binary image """ voxels_position = numpy.array(voxels_position) height, length = shape_image img = numpy.zeros((height, length), dtype=numpy.uint8) voxels_corners = get_voxels_corners(voxels_position, voxels_size) pt = projection(voxels_corners) pt = numpy.reshape(pt, (pt.shape[0] // 8, 8, 2)) pt[pt < 0] = 0 (pt[:, :, 0])[pt[:, :, 0] >= length] = length - 1 (pt[:, :, 1])[pt[:, :, 1] >= height] = height - 1 pt = numpy.floor(pt).astype(int) for points in pt: hull = scipy.spatial.ConvexHull(points) cv2.fillConvexPoly(img, points[hull.vertices], 255) return img
# ==============================================================================
[docs] def image_error(img_ref, img_src, precision=2): """ Return false position and true negative result from the comparison on two binaries images """ img_ref = img_ref.astype(numpy.int32) nb_ref = max(numpy.count_nonzero(img_ref), 1) nb_ref2 = max(numpy.count_nonzero(img_ref == 0), 1) img_src = img_src.astype(numpy.int32) img = numpy.subtract(img_ref, img_src) true_negative = numpy.bitwise_and(img_ref == 0, img_src == 0) true_negative = round( numpy.count_nonzero(true_negative) * 100.0 / nb_ref2, precision ) # true_negative = numpy.bitwise_and(img_ref == 0, img_src == 0) false_positive = round( numpy.count_nonzero(img[img < 0]) * 100.0 / nb_ref2, precision ) false_negative = round( numpy.count_nonzero(img[img > 0]) * 100.0 / nb_ref, precision ) print(true_negative, false_positive, false_negative) return false_positive, false_negative
[docs] def reconstruction_error(voxels_grid, image_views): """ Compute the reconstruction error (false positive and true negative) of the 3d reconstruction from the image view. Parameters ---------- voxels_grid: VoxelGrid The voxel grid image_views : numpy.ndarray[ImageView] An array of all image views Returns ------- out : (float,float) A tuple with the mean false positive and the mean false negative """ sum_false_positive = 0 sum_false_negative = 0 for image_view in image_views.values(): img_src = project_voxel_centers_on_image( voxels_grid.voxels_position, voxels_grid.voxels_size, image_view.image.shape, image_view.projection, ) false_positive, false_negative = image_error(image_view.image, img_src) sum_false_positive += false_positive sum_false_negative += false_negative mean_false_positive = sum_false_positive / len(image_views) mean_false_negative = sum_false_negative / len(image_views) return mean_false_positive, mean_false_negative