def feature_extraction(im, features): t_start = timeit.default_timer() # crop crop_props = crop(im) features['corners'] = crop_props['corners'] #print crop_props.keys() #features['crop_top'] = crop_props['crop_top'] # features['corner_tl_x'] = crop_props['corners'][0][1] # features['corner_tl_y'] = crop_props['corners'][0][0] # features['corner_tr_x'] = crop_props['corners'][1][1] # features['corner_tr_y'] = crop_props['corners'][1][0] # features['corner_br_x'] = crop_props['corners'][2][1] # features['corner_br_y'] = crop_props['corners'][2][0] # features['corner_bl_x'] = crop_props['corners'][3][1] # features['corner_bl_y'] = crop_props['corners'][3][0] features['wafer_radius'] = crop_props['radius'] features['_wafer_middle_orig'] = crop_props['center'] features['crop_rotation'] = crop_props['estimated_rotation'] cropped = cropping.correct_rotation(im, crop_props, pad=False, border_erode=parameters.BORDER_ERODE_CZ, fix_chamfer=False) if not cropped.flags['C_CONTIGUOUS']: cropped = np.ascontiguousarray(cropped) if False: view = ImageViewer(im) ImageViewer(cropped) view.show() # histogram features h, w = cropped.shape ip.histogram_percentiles(cropped, features, h // 2, w // 2, features['wafer_radius']) # normalise image min_val = features['hist_percentile_01'] / float(features['hist_percentile_99']) norm_upper = features['hist_percentile_99'] norm_lower = min(0.2, min_val) normed = ((cropped / norm_upper) - norm_lower) / (1 - norm_lower) # calculate distance from wafer rotation middle r, theta = np.empty_like(normed, np.float32), np.empty_like(normed, np.float32) pixel_ops.CenterDistance(r, theta, h // 2, w // 2) features['im_center_dist_im'] = r # create mask: 1=background wafer_mask = np.zeros_like(cropped, np.uint8) pixel_ops.ApplyThresholdGT_F32_U8(features['im_center_dist_im'], wafer_mask, features['wafer_radius'], 1) features['bl_cropped_u8'] = wafer_mask features['im_cropped_u8'] = (np.clip(normed, 0.0, 1.0) * 255).astype(np.uint8) if cropped.dtype.type is np.uint16: features['im_cropped_u16'] = cropped else: features['im_cropped_u16'] = cropped.astype(np.uint16) # compute runtime t_stop = timeit.default_timer() features['runtime'] = t_stop - t_start return crop_props
def cell_structure(cropped, features): # create a map of distance from each pixel to center r, theta = np.empty_like(cropped, np.float32), np.empty_like(cropped, np.float32) pixel_ops.CenterDistance(r, theta, features['wafer_middle_y'], features['wafer_middle_x']) features['im_center_dist_im'] = r features['im_center_theta_im'] = theta if False: # in multi mode, don't estimate radius features['wafer_radius'] = features['_cell_diag'] + 1 else: features['im_center_dist_rot'] = r features['im_center_theta_rot'] = theta # determine properties of the cell pattern if features['_fingers_grid']: find_fingers_perc(cropped, features) else: find_fingers(cropped, features) cell_edge_width(cropped, features) find_busbars(cropped, features) create_cell_mask(cropped, features)
def ring_strength(im, features): DEBUG = False # remove a lot of the defects by taking the max of a few positions at equal distance h, w = im.shape if 'im_center_dist_rot' in features: # being called by wafers alg dist = features['im_center_dist_rot'] theta = features['im_center_theta_rot'] center_x = int(round(features['wafer_middle_x'])) center_y = int(round(features['wafer_middle_y'])) radius = int(features['wafer_radius'] - 10) else: dist, theta = np.empty_like(im, np.float32), np.empty_like(im, np.float32) pixel_ops.CenterDistance(dist, theta, features['center_y'], features['center_x']) center_x = int(round(features['center_x'])) center_y = int(round(features['center_y'])) radius = int(features['radius'] - 10) corner_filled, corner_avg = fill_corners(im, features, 10, dist) if False: view = ImageViewer(im) ImageViewer(corner_filled) view.show() sys.exit() maxes = corner_filled.copy() rotated = np.empty_like(im) for r in [-4.0, -2.0, 2.0, 4.0]: rot_mat = cv2.getRotationMatrix2D((center_x, center_y), r, 1.0) cv2.warpAffine(corner_filled, rot_mat, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, dst=rotated, borderValue=0) maxes = np.maximum(maxes, rotated) if False: view = ImageViewer(im) ImageViewer(maxes) view.show() sys.exit() # A spiral smooth # - get coordinates that start at the middle and rotate outwards dist = np.round(dist).astype(np.int32) dist_flat = dist.flat theta_flat = theta.flat # first sort by distance from center args = np.argsort(dist_flat) dist_flat = dist_flat[args] theta_flat = theta_flat[args] # for pixels at an equal distance, sort by theta boundaires = np.where((dist_flat - np.roll(dist_flat, 1)) > 0)[0] for i in range(len(boundaires) - 1): start = boundaires[i] stop = boundaires[i + 1] args_t = np.argsort(theta_flat[start:stop]) args[start:stop] = args[start:stop][args_t] # apply smoothing to flattened, ordered image im1D = maxes.flatten() im1D = im1D[args] if False: im_smooth = ndimage.gaussian_filter1d(im1D, sigma=30) else: # faster: smooth downsized im_smooth = ndimage.gaussian_filter1d(im1D[::3], sigma=10) zoom = len(im1D) / float(len(im_smooth)) im_smooth = ndimage.zoom(im_smooth, zoom=zoom, order=0) assert len(im_smooth) == len(im1D) im_rings = im_smooth[np.argsort(args)].reshape((h, w)) if False: im_rings, _ = cz_wafer.fill_corners_edges(im_rings, features, 4, corner_fill=corner_avg) if False: view = ImageViewer(im) ImageViewer(im_rings) view.show() sys.exit() if DEBUG: plt.figure() rotations = range(0, 361, 10) dip_profiles = np.zeros((len(rotations), radius), np.float32) circle_strengths = [] for e, r in enumerate(rotations): ys, xs = draw.line(center_y, center_x, center_y + int(radius * math.cos(math.radians(r))), center_x + int(radius * math.sin(math.radians(r)))) mask = ((ys >= 0) & (xs >= 0) & (ys < h) & (xs < w)) ys, xs = ys[mask], xs[mask] if DEBUG: im[ys, xs] = 0 profile = im_rings[ys, xs] sample_r = dist[ys[-1], xs[-1]] # resample to standard length rs = np.linspace(0, sample_r, num=len(profile), endpoint=True) f = interpolate.interp1d(rs, profile) profile = f(np.arange(sample_r)) if parameters.RING_SIGMA1 > 0: profile = ndimage.gaussian_filter1d(profile, sigma=parameters.RING_SIGMA1) if parameters.RING_SIGMA2 > 0: profile_upper = ndimage.gaussian_filter1d(profile, sigma=parameters.RING_SIGMA2) # interpolate peaks peaks = np.where((profile_upper > np.roll(profile_upper, 1)) & (profile_upper > np.roll(profile_upper, -1)))[0] if len(peaks) < 2: dip_profiles[e, :len(profile)] = 0 else: f = interpolate.interp1d(peaks, profile_upper[peaks]) xs = np.arange(peaks[0], peaks[-1]) f_upper = profile.copy() f_upper[xs] = f(xs) # find dips dip_shape = f_upper - profile # ignore middle (small artifacts near middle have disproportionally high radius) dip_shape[:100] = 0 dip_profiles[e, :len(profile)] = dip_shape # a second strategy for telling difference between slugs with 1 small dark # and lots/large rings zeros = np.where(dip_shape == 0)[0] gaps = np.where(zeros[1:] - zeros[:-1] > 1)[0] big_dips = [] for g in gaps: start, stop = zeros[g], zeros[g + 1] dip_strength = 1000.0 * dip_shape[start:stop].sum() / float(len(profile)) if dip_strength > 0.5: big_dips.append(dip_strength) circle_strengths.append(np.array(big_dips).sum()) if DEBUG: plt.plot(profile) plt.plot(dip_profiles[e, :]) plt.plot(f_upper, '--') path_xs = np.zeros(dip_profiles.shape[0], np.int32) path_strength = np.zeros_like(dip_profiles, np.float32) pixel_ops.strongest_path(dip_profiles, path_strength, path_xs, 15) path_vals = dip_profiles[np.arange(dip_profiles.shape[0]), path_xs] if False: dip_profiles[np.arange(dip_profiles.shape[0]), path_xs] = dip_profiles.max() * 1.1 view = ImageViewer(dip_profiles) ImageViewer(path_strength) plt.figure() plt.plot(path_strength[-1, :]) plt.figure() plt.plot(path_vals) view.show() sys.exit() # a path might have a few peaks due to non-ring artifacts. # - ignore some of the highest areas path2 = path_vals.copy() for i in range(parameters.NUM_PEAKS): m = np.argmax(path2) path2[max(0, m - 2):min(path2.shape[0], m + 3)] = 0 path2[[0, -1]] = 0 # plt.figure() # plt.plot(path_vals) # plt.plot(path2) # plt.show() features['circle_strength'] = 100 * path2.max() features['circle_strength_2'] = np.median(circle_strengths) * 10 # print features['circle_strength_2'] if DEBUG: # plt.plot(dip_profiles.sum(axis=0)) print features['circle_strength'] ImageViewer(im) ImageViewer(im_rings) dip_profiles[np.arange(dip_profiles.shape[0]), path_xs] = dip_profiles.max() * 1.1 ImageViewer(dip_profiles) plt.figure() plt.plot(path_vals) plt.plot(path2) plt.show() return im_rings
def find_slug(im, features): h, w = im.shape h2, w2 = h // 2, w // 2 # highlight edges in each quadrant edgesH = cv2.Sobel(im, cv2.CV_32F, 0, 1) edgesV = cv2.Sobel(im, cv2.CV_32F, 1, 0) corner_edges = np.zeros_like(im) corner_edges[:h2, :w2] = edgesH[:h2, :w2] + edgesV[:h2, :w2] corner_edges[:h2, -w2:] = edgesH[:h2, -w2:] - edgesV[:h2, -w2:] corner_edges[-h2:, -w2:] = -1 * edgesH[-h2:, -w2:] - edgesV[-h2:, -w2:] corner_edges[-h2:, :w2] = -1 * edgesH[-h2:, :w2] + edgesV[-h2:, :w2] # find points on the corners left = corner_edges[:, :w2] ys = np.arange(left.shape[0]) xs = np.argmax(left, axis=1) mask = corner_edges[ys, xs] > 0.4 ys = ys[mask] xs = xs[mask] right = corner_edges[:, w2:] ys2 = np.arange(right.shape[0]) xs2 = w2 + np.argmax(right, axis=1) mask = corner_edges[ys2, xs2] > 0.4 ys2 = ys2[mask] xs2 = xs2[mask] ys = np.r_[ys, ys2] xs = np.r_[xs, xs2] if False: ImageViewer(corner_edges) plt.figure() plt.imshow(im, cmap="gray") plt.plot(xs, ys, "o") plt.show() sys.exit() t1 = default_timer() # user Hough transform to vote on most likely center/radius # - assume true center is within 150 pixels of image middle # phrase 1: rough fit MAX_OFFSET = 200 step = 3 acc_ys = np.arange(h2 - MAX_OFFSET, h2 + MAX_OFFSET + 1, step) acc_xs = np.arange(w2 - MAX_OFFSET, w2 + MAX_OFFSET + 1, step) diag = math.sqrt(h2 ** 2 + w2 ** 2) min_r = int(0.5 * diag) max_r = int(diag) acc = np.zeros((acc_ys.shape[0], acc_xs.shape[0], max_r - min_r), np.int32) pixel_ops.CircleHoughAcc2(ys, xs, acc_ys, acc_xs, acc, min_r, max_r) acc = ndimage.gaussian_filter(acc.astype(np.float32), sigma=(1, 1, 0)) i, j, r = ndimage.maximum_position(acc) middle_y, middle_x, radius = acc_ys[i], acc_xs[j], r + min_r if True: # phrase 2: fine tune acc_ys = np.arange(middle_y - (2 * step), middle_y + (2 * step) + 1) acc_xs = np.arange(middle_x - (2 * step), middle_x + (2 * step) + 1) min_r = int(radius - 10) max_r = int(radius + 10) acc = np.zeros((acc_ys.shape[0], acc_xs.shape[0], max_r - min_r), np.int32) pixel_ops.CircleHoughAcc2(ys, xs, acc_ys, acc_xs, acc, min_r, max_r) acc = ndimage.gaussian_filter(acc.astype(np.float32), sigma=(1, 1, 0)) i, j, r = ndimage.maximum_position(acc) middle_y, middle_x, radius = acc_ys[i], acc_xs[j], r + min_r features['center_y'] = middle_y features['center_x'] = middle_x features['radius'] = radius features['crop_rotation'] = 0 features['crop_left'] = 0 features['crop_right'] = im.shape[1] - 1 features['crop_top'] = 0 features['crop_bottom'] = im.shape[0] - 1 mask = np.zeros_like(im, np.uint8) r, theta = np.empty_like(im, np.float32), np.empty_like(im, np.float32) pixel_ops.CenterDistance(r, theta, middle_y, middle_x) pixel_ops.ApplyThresholdGT_F32_U8(r, mask, radius, 1) features['bl_uncropped_u8'] = mask features['bl_cropped_u8'] = mask if False: print default_timer() - t1 rgb = create_overlay(im, features) view = ImageViewer(rgb) # ImageViewer(mask) view.show() sys.exit()
def feature_extraction(im, features, skip_crop=False): t_start = timeit.default_timer() # rotation & cropping rotated = cropping.correct_stripe_rotation(im, features, already_cropped=skip_crop) cropped = cropping.crop_stripe(im, rotated, features, already_cropped=skip_crop) h, w = cropped.shape if False: view = ImageViewer(im) ImageViewer(cropped) view.show() features['_cropped_f32'] = cropped features['im_cropped_u16'] = cropped.astype(np.uint16) ip.histogram_percentiles(cropped, features) im_norm = cropped / features['hist_percentile_99'] features['im_norm'] = im_norm pixel_ops.ApplyThresholdGT_F32(im_norm, im_norm, 1.0, 1.0) features['im_cropped_u8'] = np.round(im_norm * 255).astype(np.uint8) # TODO: finger/background mask features['bl_cropped_u8'] = np.zeros_like(im_norm, np.uint8) if False: view = ImageViewer(im) ImageViewer(features['im_cropped_u16']) ImageViewer(im_norm) view.show() if 'input_param_skip_features' in features and int( features['input_param_skip_features']) == 1: return features['_fingers_grid'] = False features['_busbar_cols'] = np.array([], np.int32) features['busbar_width'] = 0 features['cell_edge_left'] = 10 features['cell_edge_right'] = im_norm.shape[1] - 10 features['mask_busbar_edges'] = np.zeros_like(im_norm, dtype=np.uint8) features['mask_busbar_filled'] = np.zeros_like(im_norm, dtype=np.uint8) features['wafer_middle_y'] = h // 2 features['wafer_middle_x'] = w // 2 features['wafer_radius'] = h features['_bright_area_thresh'] = 1 features['cell_edge_tb'] = 0 # assume cropped already. cell.find_fingers(im_norm, features) cell.remove_cell_template(im_norm, features) # TODO: add background to mask features['bl_cropped_u8'] = np.zeros_like(im_norm, np.uint8) features['bl_cropped_u8'][features['_finger_row_nums'], :] = 4 features['bl_cropped_u8'][ im_norm < parameters.STRIPE_BACKGROUND_THRESH] = 1 if False: view = ImageViewer(im_norm) ImageViewer(features['bl_cropped_u8']) view.show() if features['_cell_type'] == "multi": efficiency_analysis(features) cell.multi_cracks(features) features['ov_dislocations_u8'][:, :10] = 0 features['ov_dislocations_u8'][:, -10:] = 0 elif features['_cell_type'] == "mono": # calculate distance from wafer middle r, theta = np.empty_like(im_norm, np.float32), np.empty_like( im_norm, np.float32) pixel_ops.CenterDistance(r, theta, features['wafer_middle_y'], features['wafer_middle_x']) features['im_center_dist_im'] = r features['im_center_theta_im'] = theta cell.mono_cracks(features) mono_cell.dark_areas(features) mono_cell.dark_spots(features) else: print "ERROR -- Unknown mode: %s" % features['_cell_type'] # compute runtime t_stop = timeit.default_timer() features['runtime'] = t_stop - t_start return
def feature_extraction(im, features, already_cropped=False): t_start = timeit.default_timer() if 'input_param_num_stripes' in features: num_stripes = features['input_param_num_stripes'] else: num_stripes = 6 features['num_rows'] = 1 features['num_cols'] = num_stripes if 'input_param_multi' in features: multi = int(features['input_param_multi']) == 1 else: multi = False # rotation & cropping rotated = cropping.correct_cell_rotation(im, features, already_cropped=already_cropped) cropped = cropping.crop_cell(rotated, im, features, width=None, already_cropped=already_cropped) # stripe corners corner_tr_x = features['corner_tr_x'] corner_tr_y = features['corner_tr_y'] corner_tl_x = features['corner_tl_x'] corner_tl_y = features['corner_tl_y'] corner_br_x = features['corner_br_x'] corner_br_y = features['corner_br_y'] corner_bl_x = features['corner_bl_x'] corner_bl_y = features['corner_bl_y'] if features['cell_rotated']: x_diff_l = corner_bl_x - corner_tl_x y_diff_l = corner_bl_y - corner_tl_y x_diff_r = corner_br_x - corner_tr_x y_diff_r = corner_br_y - corner_tr_y for i in range(num_stripes): p_start = i / float(num_stripes) p_stop = (i + 1) / float(num_stripes) features["%02d_corner_tl_y" % (i + 1)] = int( round(corner_tl_y + (p_start * y_diff_l))) features["%02d_corner_tl_x" % (i + 1)] = int( round(corner_tl_x + (p_start * x_diff_l))) features["%02d_corner_bl_y" % (i + 1)] = int( round(corner_tl_y + (p_stop * y_diff_l))) features["%02d_corner_bl_x" % (i + 1)] = int( round(corner_tl_x + (p_stop * x_diff_l))) features["%02d_corner_tr_y" % (i + 1)] = int( round(corner_tr_y + (p_start * y_diff_r))) features["%02d_corner_tr_x" % (i + 1)] = int( round(corner_tr_x + (p_start * x_diff_r))) features["%02d_corner_br_y" % (i + 1)] = int( round(corner_tr_y + (p_stop * y_diff_r))) features["%02d_corner_br_x" % (i + 1)] = int( round(corner_tr_x + (p_stop * x_diff_r))) else: x_diff_t = corner_tr_x - corner_tl_x y_diff_t = corner_tr_y - corner_tl_y x_diff_b = corner_br_x - corner_bl_x y_diff_b = corner_br_y - corner_bl_y for i in range(num_stripes): p_start = i / float(num_stripes) p_stop = (i + 1) / float(num_stripes) features["%02d_corner_tl_y" % (i + 1)] = int( round(corner_tl_y + (p_start * y_diff_t))) features["%02d_corner_tl_x" % (i + 1)] = int( round(corner_tl_x + (p_start * x_diff_t))) features["%02d_corner_tr_y" % (i + 1)] = int( round(corner_tl_y + (p_stop * y_diff_t))) features["%02d_corner_tr_x" % (i + 1)] = int( round(corner_tl_x + (p_stop * x_diff_t))) features["%02d_corner_bl_y" % (i + 1)] = int( round(corner_bl_y + (p_start * y_diff_b))) features["%02d_corner_bl_x" % (i + 1)] = int( round(corner_bl_x + (p_start * x_diff_b))) features["%02d_corner_br_y" % (i + 1)] = int( round(corner_bl_y + (p_stop * y_diff_b))) features["%02d_corner_br_x" % (i + 1)] = int( round(corner_bl_x + (p_stop * x_diff_b))) features['im_cropped_u16'] = cropped.astype(np.uint16) h, w = cropped.shape corner_mask = np.ones_like(cropped, np.uint8) r, theta = np.empty_like(cropped, np.float32), np.empty_like(cropped, np.float32) pixel_ops.CenterDistance(r, theta, features['wafer_middle_y'], features['wafer_middle_x']) pixel_ops.ApplyThresholdGT_F32_U8(r, corner_mask, features['wafer_radius'], 0) if False: print features['cell_rotated'] plt.figure() plt.plot(cropped.mean(axis=0)) view = ImageViewer(im) ImageViewer(rotated) ImageViewer(cropped) ImageViewer(corner_mask) view.show() ip.histogram_percentiles(cropped, features, center_y=h // 2, center_x=w // 2, radius=features['wafer_radius']) cell.normalise(cropped, features) # find cell structure f = features.copy() cell.cell_structure(cropped, f) features['bl_cropped_u8'] = f['bl_cropped_u8'] ip.histogram_percentiles(cropped, f, center_y=h // 2, center_x=w // 2, radius=features['wafer_radius']) cell.normalise(cropped, f) cell.remove_cell_template(f['im_norm'], f) if 'input_param_skip_features' not in features or int( features['input_param_skip_features']) != 1: if multi: # efficiency analysis multi_cell.bright_areas(f) multi_cell.efficiency_analysis(f) # save results features['impure_area_fraction'] = f['impure_area_fraction'] features['dislocation_area_fraction'] = f[ 'dislocation_area_fraction'] im_dislocations = f['_foreground'] dislocation_thresh = f['_dislocation_thresh'] im_impure = f['_impure'] impure_thresh = f['_impure_thresh'] else: # cracks cell.mono_cracks(f) features['mk_cracks_u8'] = f['mk_cracks_u8'] features['defect_count'] = f['defect_count'] features['defect_present'] = f['defect_present'] features['defect_length'] = f['defect_length'] crack_skel = f['_crack_skel'] # extract stats from each stripe stripe_width = w // num_stripes for s in range(num_stripes): s1 = int(round(s * stripe_width)) s2 = int(round(min(w, (s + 1) * stripe_width))) stripe = cropped[:, s1:s2] mask = corner_mask[:, s1:s2] vals = stripe[mask == 1].flat features["%02d_hist_harmonic_mean" % (s + 1)] = 1.0 / (1.0 / np.maximum(0.01, vals)).mean() features["%02d_hist_median" % (s + 1)] = np.median(vals) features["%02d_hist_mean" % (s + 1)] = np.mean(vals) features["%02d_hist_percentile_01" % (s + 1)] = stats.scoreatpercentile(vals, 1) features["%02d_hist_percentile_99" % (s + 1)] = stats.scoreatpercentile(vals, 99) features["%02d_hist_std" % (s + 1)] = np.std(vals) features["%02d_hist_cov" % (s + 1)] = features["%02d_hist_std" % (s + 1)] / max( 1, features["%02d_hist_mean" % (s + 1)]) if 'input_param_no_stripe_images' not in features or int( features['input_param_no_stripe_images']) != 1: features['im_%02d_u16' % (s + 1)] = stripe.astype(np.uint16) features['bl_%02d_cropped_u8' % (s + 1)] = features['bl_cropped_u8'][:, s1:s2] if False: view = ImageViewer(stripe) ImageViewer(mask) view.show() if multi: impure_stripe = im_impure[:, s1:s2] dis_stripe = im_dislocations[:, s1:s2] features["%02d_dislocation" % (s + 1)] = (dis_stripe > dislocation_thresh).mean() features["%02d_impure" % (s + 1)] = (impure_stripe < impure_thresh).mean() else: crack_stripe = features['mk_cracks_u8'][:, s1:s2] if 'input_param_no_stripe_images' not in features or int( features['input_param_no_stripe_images']) != 1: features['mk_%02d_cracks_u8' % (s + 1)] = np.ascontiguousarray(crack_stripe, dtype=np.uint8) skel_stripe = crack_skel[:, s1:s2] features["%02d_defect_length" % (s + 1)] = skel_stripe.sum() if features["%02d_defect_length" % (s + 1)] > 0: _, num_ccs = ip.connected_components(crack_stripe) features["%02d_defect_count" % (s + 1)] = num_ccs features["%02d_defect_present" % (s + 1)] = 1 if num_ccs > 0 else 0 else: features["%02d_defect_count" % (s + 1)] = 0 features["%02d_defect_present" % (s + 1)] = 0 # undo rotation if parameters.ORIGINAL_ORIENTATION and features['cell_rotated']: features['num_rows'], features['num_cols'] = features[ 'num_cols'], features['num_rows'] for feature in features.keys(): if ((feature.startswith('im_') or feature.startswith('ov_') or feature.startswith('bl_') or feature.startswith('mk_')) and features[feature].ndim == 2): features[feature] = features[feature].T[:, ::-1] # compute runtime t_stop = timeit.default_timer() features['runtime'] = t_stop - t_start