Source code for process_slim_mapper

# -*- coding: utf-8 -*-

"""

The below functions can be used to post-process SLIM (spacer layer imaging method)
mapper bitmap files created by MTM or EHD test rigs by PCS Instruments.

"""

import copy
import math
import os
import os.path
import sys

import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
import cv2


def __print_progress(current, to_reach):
    """

    Print progress bar to console based on ratio of current / to_reach.

    Parameters
    ----------
    current: int
        Current iteration number.
    to_reach: int
        Final iteration number to reach.

    Returns
    -------
    current: int
        The next iteration number.

    """
    current += 1
    prog_bar_len = 25
    approximate_progress = int(math.floor(current / to_reach * prog_bar_len))
    progress_bar = approximate_progress * '#' + '' + \
        (prog_bar_len - approximate_progress) * ' '
    sys.stdout.write("\rprogress: |{}| ({} %){}"
                     .format(progress_bar,
                             round(current / to_reach * 100),
                             '' if current != to_reach else '\n'))
    sys.stdout.flush()
    return current


[docs]def load_npz(file): """ Load an npz database file into a mutable dictionary. Parameters ---------- file: str Path to npz database file. Returns ------- dat_cop: dict Loaded data. """ dat = np.load(file) dat_cop = {} for var in dat.files: dat_cop[var] = copy.deepcopy(dat[var]) return dat_cop
def __show_circles(img, x, y, rads, img_path): """ Plot an image where the automatically detected circles are overlayed over the original bitmap file. Parameters ---------- img: ndarray Array with image data. x: list Pixel coordinates of radii centers in x-direction y: list Pixel coordinates of radii centers in y-direction rads: list Radii of detected circles in units of pixels img_path: Path of original bitmap file """ cv2.circle(img, (int(np.mean(x)), int(np.mean(y))), int(np.mean(rads)), (0, 255, 0), 2) # draw center of circle cv2.circle(img, (int(np.mean(x)), int(np.mean(y))), 2, (0, 255, 0), 3) cv2.imshow('detected circles ' + img_path.split(os.sep)[-1], img) cv2.waitKey(20000) cv2.destroyAllWindows() def __find_mean_circles(img_gray): """ Call the HoughCircles function using a range of fit parameters, then average over all detected circles to find mean center (x, y) and radius. Parameters ---------- img_gray: ndarray Array containing grayscale image data. Returns ------- x: list List of x-coordinates of radii centers y: list List of y-coordinates of radii centers rads: list list of radii """ rads = [] x = [] y = [] for par in np.linspace(1.1, 50, 40): circles = cv2.HoughCircles(img_gray, cv2.HOUGH_GRADIENT, par, 1500, param1=50, param2=30, minRadius=200, maxRadius=0) # pylint: disable=unsubscriptable-object new_circles = np.int16(np.around(circles)) for circ in new_circles[0, :]: rads.append(circ[2]) x.append(circ[0]) y.append(circ[1]) return x, y, rads def __dist_2d(x_1, y_1, x_2, y_2): """ Calculate the absolute distance between two points in the x-y plane. Parameters ---------- x_1: float x-coordinate of first point. y_1: float y-coordinate of first point. x_2: float x-coordinate of second point. y_2: float y-coordinate of second point. Returns ------- distance: float """ return np.sqrt((x_1 - x_2) ** 2 + (y_1 - y_2) ** 2) def __plot_thick(file, thick, x_vals, y_vals, rgb, skip, crop, out_dir): """ Plot the evaluated film thickness data in a 2D plot, using the extracted rgb values. Parameters ---------- file: str Path to the bitmap file. thick: ndarray The thickness values for each pixel. x_vals: tuple Minimum and maximum evaluated pixel coordinates in x-direction. y_vals: tuple Minimum and maximum evaluated pixel coordinates in y-direction. rgb: ndarray Array of rgb tuples, containing color information for each pixel. skip: int The skip value provided by the user. crop: float The crop value provided by the user. out_dir: str Path to output directory. Returns ------- out_file: str Path to plot file. """ x_grid, y_grid = np.meshgrid( np.linspace(x_vals[0], x_vals[1], np.shape(thick)[0]), np.linspace(y_vals[0], y_vals[1], np.shape(thick)[1]), sparse=False, indexing='ij') fig = plt.figure(dpi=300) ax = fig.add_subplot(111) ar_shape = x_grid.shape nu_shape = (ar_shape[0] * ar_shape[1], 1) x = x_grid.reshape(nu_shape) y = y_grid.reshape(nu_shape) z = np.fliplr(thick).reshape(nu_shape) idx = np.where(np.isnan(z) == False) ax.scatter(x[idx], y[idx], c=list(list(l) for l in (np.fliplr(rgb).reshape(nu_shape))[idx]), marker='s', s=0.5 * skip) ax.set_xlabel('x') ax.set_ylabel('y') ax.set_title(file.split('/')[-1], fontsize=6) ax.set_aspect('equal', adjustable='box') if not os.path.exists(out_dir): os.makedirs(out_dir) out_file = out_dir + '/' + file.rstrip('.bmp').split(os.sep)[-1] \ + '-crop-{}-skip-{}.png'.format(crop, skip) plt.savefig(out_file) # plt.show() plt.close(fig) return out_file def __adjust_rad_for_skip(diam, skip): """ If necessary, increase the crop circle diameter (radius) for the chosen skip value. This is required because the diameter must be divisible by skip without rest. Parameters ---------- diam: int Initial diameter of crop circle (in pixels). skip: int The numbers of pixels to skip when evaluating film thickness information. Returns ------- diam: int Diameter of circle that is divisible by skip without rest. """ while diam % skip != 0: diam += 1 return diam def __distance(c_1, c_2): """ Get the absolute distance between two rgb color tuples in the 3D rgb space. Parameters ---------- c_1: tuple First set of rgb values. c_2: tuple Second set of rgb values. Returns ------- dist: float Absolute distance between color tuples. """ (r_1, g_1, b_1) = c_1 (r_2, g_2, b_2) = c_2 dist = math.sqrt((r_1 - r_2) ** 2 + (g_1 - g_2) ** 2 + (b_1 - b_2) ** 2) return dist def __rgb_to_thick(point, rgb_map): """ Find rgb value that matches pixels best, then extract corresponding film thickness value. Parameters ---------- point: tuple rgb value of point as extracted from bitmap file rgb_map: dict rgb map created from SLIM Spacer Calibration file, with rgb-tuples as keys and film thickness values as values. Returns ------- film_thick: float Film thickness value for current pixel rgb_norm: tuple rgb value for pixel as extracted from color map, normalized to range 0-1 dist: float Distance in RGB space between actual color and closest color """ colors = list(rgb_map.keys()) closest_colors = sorted(colors, key=lambda color: __distance(color, point)) dist = __distance(colors[0], point) rgb = closest_colors[0] film_thick = rgb_map[rgb][0] rgb_norm = (c/255 for c in rgb) return film_thick, rgb_norm, dist def __set_aperture(ap_in): """ Make sure that aperture dictionary has correct format. Parameters ---------- ap_in: dict Aperture as defined by user. Returns ------- ap_out: dict Correctly formated and complete aperture dictionary """ sides = ['top', 'right', 'bottom', 'left'] ap_out = {} for side in sides: if ap_in and side in ap_in: ap_out[side] = ap_in[side] else: ap_out[side] = 0 return ap_out def __get_thick(file, x, y, rads, x_vals, y_vals, r_mean, rgb_map, skip=1, crop=0.25, aperture=None): """ Get the film thickness values for each pixel of the bitmap image file. Parameters ---------- file: str Path to bitmap image. x: list Centers of automatically detected circle centers in x-direction. y: list Centers of automatically detected circle centers in y-direction. rads: ndarray Radii of automatically detected circle centers. x_vals: tuple Minimum and maximum evaluated pixel values in x-direction. y_vals: tuple Minimum and maximum evaluated pixel values in y-direction. r_mean: int Radius of automatically detected circles. rgb_map: dict Dictionary mapping rgb tuples (keys) to thickness values (val). skip: int Skip value as defined by user. crop: float Crop value as defined by user. aperture: dict Aperture as defined by user. Returns ------- thick: ndarray Thickness values for each pixel of bitmap image. rgb: ndarray Array of rgb tuples for each pixel of bitmap image. dist: ndarray Array of distance values between actual color and closest known color in the RGB space. """ img = Image.open(file) pixels = img.load() diam = __adjust_rad_for_skip(2 * r_mean, skip) mat_size = int(diam / skip) thick = np.ones((mat_size, mat_size)) * float('nan') dist = np.ones((mat_size, mat_size)) * float('nan') rgb = np.zeros((mat_size, mat_size), dtype=object) rgb[:][:] = "white" x_mean = np.mean(x) y_mean = np.mean(y) aperture = __set_aperture(aperture) r_mean = np.mean(rads) for idx_1, x_idx in enumerate(range(x_vals[0], x_vals[1], skip)): for idx_2, y_idx in enumerate(range(y_vals[0], y_vals[1], skip)): position = __dist_2d(x_idx, y_idx, x_mean, y_mean) if position <= r_mean * (1 - crop) and \ y_idx <= y_mean + r_mean * (1 - aperture['bottom']) and \ y_idx >= y_mean - r_mean * (1 - aperture['top']) and \ x_idx <= x_mean + r_mean * (1 - aperture['right']) and \ x_idx >= x_mean - r_mean * (1 - aperture['left']): pix = pixels[x_idx, y_idx][0:3] thick[idx_1, idx_2], rgb[idx_1, idx_2], dist[idx_1, idx_2] = \ __rgb_to_thick(pix, rgb_map) return thick, rgb, dist def __get_extrema(x, y, r_mean): """ Get the minima and maxima of x and y circle center coordinates. Parameters ---------- x: list x-coordinates y: list y-coordinates r_mean: int mean radius Returns ------- x_vals: tuple Containing minimum and maximum x-value y_vals: tuple Containing minimum and maximum y-value """ x_mean = int(np.mean(x)) y_mean = int(np.mean(y)) x_min = x_mean - r_mean x_max = x_mean + r_mean y_min = y_mean - r_mean y_max = y_mean + r_mean x_vals = (x_min, x_max) y_vals = (y_min, y_max) return x_vals, y_vals def __load_grayscale_img(image): """ Load an image file into an array. Parameters ---------- image: str The path to a PIL-compatible image file in BMP format. Returns ------- img_gray: ndarray Array containing grayscale image data. img_orig: ndarray Array containing original image data. """ img = cv2.imread(image) img_orig = img.copy() img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) return img_gray, img_orig def __load_rgb_map(file): """ Load a npz database containing an imported PCS Spacer Calibration data set. To create an npz database from a PCS Spacer Calibration file, the `import_del` function of the `data_import` module should be used. Parameters ---------- file: str Path to npz database. Returns ------- rgb_dat: dict Dictionary mapping rgb tuples (keys) to thickness values (val). """ spacer_calib = load_npz(file) rgb_dat = {} for idx, film in enumerate(spacer_calib['film']): rgb_dat.update( {(spacer_calib['r'][idx][0], spacer_calib['g'][idx][0], spacer_calib['b'][idx][0]): film}) return rgb_dat def __get_data_at_step(file, mtm_dat, var): """ Find the data step that corresponds to an BPM image file and get the corresponding data (key `var`) from an MTM data file. Parameters ---------- file: str The path to a BMP image file. The file name given by the PCS instrument may not be changed, otherwise the step extraction may fail. mtm_dat: dict Instrument output data loaded from npz database file. var: str A key in the npz database. Returns ------- dat: ndarray An array containing a single data point (float, int) """ if '_ZERO.bmp' in file: step = 1 else: step = int(file.split('_')[-1].replace('.bmp', '')) step_idx = mtm_dat['step_start'][step-1] dat = mtm_dat[var][step_idx] return dat def __calc_mean_thick(thick): """ Calculate the mean value of the thickness data. Parameters ---------- thick: ndarray Pixel-by-pixel thickness data. Returns ------- Mean thickness: float """ return np.nanmean(thick)
[docs]def slim2thick(file, rgb_map, rads=None, skip=1, crop=0.0, aperture=None): """ Extract film thickness data from a PCS Instruments SLIM image file in BMP format. To obtain the thickness data, the color information of the BMP is converted to thickness information according to the rgb color map (Spacer Calibration file) provided by the instrument. The center point of the relevant (circular) contact area between the ball and the glass window is automatically detected. The radius of the contact area is automatically detected if `rads` is not provided. Thickness information outside of the detected area is discarded. The `crop` and `aperture` parameters can be used to further constrain the area of interest. The `skip` parameter determines the number of data points that is evaluated. For example, for `skip = 1`, every pixel within the area of interest is evaluated; for `skip = 10` every 10th pixel is evaluated. Parameters ---------- file: str Path to a PCS bitmap 3D SLIM mapper output file rgb_map: str Path to a PCS Spacer Calibration file in npz format. The original instrument output file needs to be imported into npz database format using the `import_del` function of the `data_import` module first. rads: int, float, list, ndarray, optional The contact radius (in pixels) of the circular contact area between ball and glass window. If several radii are provided, the mean value will be used. skip: positive int, optional The number of pixels to skip (in both x and y direction). Higher skip numbers will lead to faster processing; lower skip numbers to higher accuracies. crop: float, optional Determines by how much the radius of the contact area is cropped during the data processing step. Value needs to be in the range [0, 1]. A crop value of 0.5 will reduce the radius by 50 %. aperture: dict, optional A dictionary defining a rectangular area relative to the crop radius. Data points outside the rectangular area are not evaluated. The borders of the rectangle are defined relative to the crop radius. Values in the range [0, 1] may be defined for the following keys: - top - right - bottom - left If the aperture value is 0, the borders of the rectangle intersect with the crop radius on all four sides (i.e., the aperture has no effect). The following will reduce the crop radius on the top and bottom by 30 %: aperture = {'top': 0.3, 'bottom': 0.3} Returns ------- thick: ndarray Film thickness in nanometer for each evaluated pixel. rgb: ndarray Best matching RGB tuple for each evaluated pixel obtained by comparison of bitmap image and SLIM Spacer Calibration file. rads: ndarray The automatically detected radii of the contact between ball and window. If `rads` is provided as an argument, `rads` is simply returned. x_vals: tuple Minimum and maximum evaluated pixel coordinate (in x-direction) y_vals: tuple Minimum and maximum evaluated pixel coordinate (in y-direction) dist: ndarray Array of distance values between actual color and closest known color in the RGB space, normalized to 255. """ img_gray, _ = __load_grayscale_img(file) rgb_dat = __load_rgb_map(rgb_map) if not rads: x, y, rads = __find_mean_circles(img_gray) else: x, y, _ = __find_mean_circles(img_gray) # __show_circles(img_orig, x, y, rads, file) r_mean = int(np.mean(rads)) x_vals, y_vals = __get_extrema(x, y, r_mean) thick, rgb, dist = __get_thick(file, x, y, rads, x_vals, y_vals, r_mean, rgb_dat, skip, crop, aperture) return thick, rgb, rads, x_vals, y_vals, dist
[docs]def slim2thick_batch(bitmaps, zero_bmp, rgb_map, mtm_file, relative=False, pcs_vars=('time_accumulated_s',), plot=False, plt_dir='', print_prog=True, skip=1, crop=0.0, aperture=None): """ Batch process a number of PCS Spacer Layer image files that share the same zero step, instrument output file and spacer layer calibration file. This function is a wrapper function for :code:`slim2thick`. See docstring for more information. Returns a dictionary containing mean film thickness values and, depending on user input, corresponding MTM variable values at the time of the film thickness measurement step. Parameters ---------- bitmaps: tuple, list Paths to spacer layer bitmap files as list/tuple of strings. zero_bmp: str Path to bitmap image that corresponds to initial zero step of experiment. rgb_map: str Path to the shared spacer layer calibration file, imported into npz database format using the `import_del` function of the `import_data` module. mtm_file: str Path to the shared instrument output file, imported into npz database format using the `import_del` function of the `import_data` module. relative: bool, optional If True, the mean film thickness value is calculated relative to the mean film thickness of the zero step; if False, the absolute mean film thickness is calculated. pcs_vars: list, optional A list of MTM variables. Variable names must match those used in npz database created using the `import_del` function of the `import_data` module. By default, values for variable `time_accumulated_s` are included. plot: bool, optional If True, film thickness plots will be created for each image. plt_dir: str, optional Path to the output directroy in which to store plots. print_prog: bool If true, status updates are printed to the command line. skip: positive int, optional See function :code:`slim2thick` crop: float, optional See function :code:`slim2thick` aperture: dict, optional See function :code:`slim2thick` Returns ------- outdict: dict The mean film thickness data for each bitmap file, along with extracted data from instrument output files (depending on user inputs). """ mtm_dat = load_npz(mtm_file) # initialize dictionary that holds outputs out_dict = { 'mean_thickness_nm': [], 'thickness_nm': [], 'mean_color_error': [], 'skip': skip, 'crop': crop, 'aperture': aperture, 'plots': [] } for var in pcs_vars: out_dict.update({var: []}) files = [zero_bmp] + list(bitmaps) zero_thick = 0 rads = None if print_prog: print('analyzing {} images'.format(len(files))) # loop over images and extract film thickness for all steps for idx, file in enumerate(files): # calculate mean film thickness from image data if "_ZERO" in file: thick, rgb, rads, xtrem, ytrem, dist = slim2thick( zero_bmp, rgb_map, skip=skip, crop=crop, aperture=aperture) zero_thick = __calc_mean_thick(thick) else: thick, rgb, _, xtrem, ytrem, dist = slim2thick( file, rgb_map, rads=rads, skip=skip, crop=crop, aperture=aperture) mean_thick = __calc_mean_thick(thick) if relative: mean_thick -= zero_thick # store data in output dictionary out_dict['mean_thickness_nm'].append(mean_thick) out_dict['thickness_nm'].append(thick) out_dict['mean_color_error'].append(np.nanmean(dist)) for var in pcs_vars: out_dict[var].append(__get_data_at_step(file, mtm_dat, var)) if plot: out_dict['plots'].append( __plot_thick(file, thick, xtrem, ytrem, rgb, skip, crop, plt_dir)) if print_prog: __print_progress(idx, len(files)) return out_dict