Source code for detectree.pixel_features

"""Build pixel features."""

import glob
from os import path

import cv2
import dask
import numpy as np
from dask import diagnostics
from scipy import ndimage as ndi
from skimage import color, morphology, transform
from skimage.filters import rank

from . import filters, settings, utils

__all__ = ["PixelFeaturesBuilder"]

# to convert to illumination-invariant color space
# https://www.cs.harvard.edu/~sjg/papers/cspace.pdf
B = np.array(
    [
        [0.9465229, 0.2946927, -0.1313419],
        [-0.1179179, 0.9929960, 0.007371554],
        [0.09230461, -0.04645794, 0.9946464],
    ]
)

A = np.array(
    [
        [27.07439, -22.80783, -1.806681],
        [-5.646736, -7.722125, 12.86503],
        [-4.163133, -4.579428, -4.576049],
    ]
)

# TODO: only one `NUM_CHANNELS` constant?
NUM_RGB_CHANNELS = 3
NUM_LAB_CHANNELS = 3
NUM_XYZ_CHANNELS = 3
NUM_ILL_CHANNELS = 3


[docs] class PixelFeaturesBuilder: """Customize how pixel features are computed."""
[docs] def __init__( self, *, sigmas=None, num_orientations=None, neighborhood=None, min_neighborhood_range=None, num_neighborhoods=None, ): """ Initialize the pixel feature builder. See the `background <https://bit.ly/2KlCICO>`_ example notebook for more details. Parameters ---------- sigmas : list-like, optional The list of scale parameters (sigmas) to build the Gaussian filter bank that will be used to compute the pixel-level features. The provided argument will be passed to the initialization method of the `PixelFeaturesBuilder` class. If no value is provided, the value set in `settings.GAUSS_SIGMAS` is used. num_orientations : int, optional The number of equally-distributed orientations to build the Gaussian filter bank that will be used to compute the pixel-level features. The provided argument will be passed to the initialization method of the `PixelFeaturesBuilder` class. If no value is provided, the value set in `settings.GAUSS_NUM_ORIENTATIONS` is used. neighborhood : array-like, optional The base neighborhood structure that will be used to compute the entropy features. The provided argument will be passed to the initialization method of the `PixelFeaturesBuilder` class. If no value is provided, a square with a side size of `2 * min_neighborhood_range + 1` is used. min_neighborhood_range : int, optional The range (i.e., the square radius) of the smallest neigbhorhood window that will be used to compute the entropy features. The provided argument will be passed to the initialization method of the `PixelFeaturesBuilder` class. If no value is provided, the value set in `settings.ENTROPY_MIN_NEIGHBORHOOD_RANGE` is used. num_neighborhoods : int, optional The number of neigbhorhood windows (whose size follows a geometric progression starting at `min_neighborhood_range`) that will be used to compute the entropy features. The provided argument will be passed to the initialization method of the `PixelFeaturesBuilder` class. If no value is provided, the value set in `settings.ENTROPY_NUM_NEIGHBORHOODS` is used. """ # preprocess technical keyword arguments # texture features if sigmas is None: sigmas = settings.GAUSS_SIGMAS self.sigmas = sigmas if num_orientations is None: num_orientations = settings.GAUSS_NUM_ORIENTATIONS self.num_orientations = num_orientations # entropy features # TODO: compute entropy features with `neighborhoods` kwarg # TODO: scales NOT used when `neighborhoods` kwarg is provided # if neighborhoods is None: # if min_neighborhood_range is None: # min_neighborhood_range = \ # settings.ENTROPY_DEFAULT_MIN_NEIGHBORHOOD_RANGE # if num_neighborhoods is None: # num_neighborhoods = \ # settings.ENTROPY_DEFAULT_NUM_NEIGHBORHOODS # scales = np.geomspace(1, 2**(num_neighborhoods - 1), # num_neighborhoods).astype(int) # neighborhood = morphology.square(2 * min_neighborhood_range + 1) # else: # num_neighborhoods = len(neighborhoods) if neighborhood is None: if min_neighborhood_range is None: min_neighborhood_range = settings.ENTROPY_MIN_NEIGHBORHOOD_RANGE neighborhood = morphology.square(2 * min_neighborhood_range + 1) self.neighborhood = neighborhood if num_neighborhoods is None: num_neighborhoods = settings.ENTROPY_NUM_NEIGHBORHOODS self.scales = np.geomspace( 1, 2 ** (num_neighborhoods - 1), num_neighborhoods ).astype(int) self.num_color_features = NUM_LAB_CHANNELS + NUM_ILL_CHANNELS self.num_texture_features = num_orientations * len(sigmas) self.num_entropy_features = num_neighborhoods self.num_pixel_features = ( self.num_color_features + self.num_texture_features + self.num_entropy_features )
# self.X = np.zeros((num_tiling_pixels, num_img_features)) def build_features_from_arr(self, img_rgb): """ Build feature array from an RGB image array. Parameters ---------- img_rgb : numpy ndarray The image in RGB format, i.e., in a 3-D array Returns ------- responses : numpy ndarray Array with the pixel responses. """ # the third component `_` is actually the number of channels in RGB, which is # already defined in the constant `NUM_RGB_CHANNELS` num_rows, num_cols, _ = img_rgb.shape num_pixels = num_rows * num_cols img_lab = color.rgb2lab(img_rgb) img_lab_l = img_lab[:, :, 0] # ACHTUNG: this is a view X = np.zeros((num_pixels, self.num_pixel_features), dtype=np.float32) # color features # tpf.compute_color_features(X_img[:, self.color_slice]) img_lab_vec = img_lab.reshape(num_rows * num_cols, NUM_LAB_CHANNELS) img_xyz_vec = color.rgb2xyz(img_rgb).reshape( num_rows * num_cols, NUM_XYZ_CHANNELS ) img_ill_vec = np.dot( A, np.log(np.dot(B, img_xyz_vec.transpose()) + 1) ).transpose() X[:, :NUM_LAB_CHANNELS] = img_lab_vec X[:, NUM_LAB_CHANNELS : NUM_LAB_CHANNELS + NUM_ILL_CHANNELS] = img_ill_vec # texture features # tpf.compute_texture_features(X_img[:, self.texture_slice], # self.sigmas, self.num_orientations) for i, sigma in enumerate(self.sigmas): base_kernel_arr = filters.get_texture_kernel(sigma) for j, orientation in enumerate(range(self.num_orientations)): # theta = orientation / num_orientations * np.pi theta = orientation * 180 / self.num_orientations oriented_kernel_arr = ndi.rotate(base_kernel_arr, theta) # img_filtered = ndi.convolve(img_lab_l, oriented_kernel_arr) img_filtered = cv2.filter2D( img_lab_l, ddepth=-1, kernel=oriented_kernel_arr ) img_filtered_vec = img_filtered.flatten() X[:, self.num_color_features + i * self.num_orientations + j] = ( img_filtered_vec ) # entropy features # tpf.compute_entropy_features(X_img[:, self.entropy_slice], # self.neighborhood, self.scales) entropy_start = self.num_color_features + self.num_texture_features X[:, entropy_start] = rank.entropy( img_lab_l.astype(np.uint16), self.neighborhood ).flatten() for i, factor in enumerate(self.scales[1:], start=1): img = transform.resize( transform.downscale_local_mean(img_lab_l, (factor, factor)), img_lab_l.shape, ).astype(np.uint16) X[:, entropy_start + i] = rank.entropy(img, self.neighborhood).flatten() return X def build_features_from_filepath(self, img_filepath): """ Build feature array from an RGB image file. Parameters ---------- img_filepath : str, file object or pathlib.Path object Path to a file, URI, file object opened in binary ('rb') mode, or a Path object to the RGB image for which the features will be computed. The value will be passed to `rasterio.open`. Returns ------- responses : numpy ndarray Array with the pixel responses. """ img_rgb = utils.img_rgb_from_filepath(img_filepath) return self.build_features_from_arr(img_rgb)
[docs] def build_features( self, *, split_df=None, img_filepaths=None, img_dir=None, img_filename_pattern=None, method=None, img_cluster=None, ): """ Build the pixel feature array for a list of images. Parameters ---------- split_df : pd.DataFrame Data frame with the train/test split. img_filepaths : list of image file paths, optional List of images to be transformed into features. Alternatively, the same information can be provided by means of the `img_dir` and `img_filename_pattern` keyword arguments. Ignored if providing `split_df`. img_dir : str representing path to a directory, optional Path to the directory where the images whose filename matches `img_filename_pattern` are to be located. Ignored if `split_df` or `img_filepaths` is provided. img_filename_pattern : str representing a file-name pattern, optional Filename pattern to be matched in order to obtain the list of images. If no value is provided, the value set in `settings.IMG_FILENAME_PATTERN` is used. Ignored if `split_df` or `img_filepaths` is provided. method : {'cluster-I', 'cluster-II'}, optional Method used in the train/test split img_cluster : int, optional The label of the cluster of images. Only used if `method` is 'cluster-II'. Returns ------- X : numpy ndarray Array with the pixel features. """ # TODO: accept `neighborhoods` kwarg if split_df is not None: if method is None: if "img_cluster" in split_df: method = "cluster-II" else: method = "cluster-I" if method == "cluster-I": # dump_train_feature_arrays(split_df, output_filepath) img_filepaths = split_df[split_df["train"]]["img_filepath"] else: if img_cluster is None: raise ValueError( "If `method` is 'cluster-II', `img_cluster` must be provided" ) img_filepaths = utils.get_img_filepaths(split_df, img_cluster, True) else: if img_filepaths is None: if img_filename_pattern is None: img_filename_pattern = settings.IMG_FILENAME_PATTERN if img_dir is None: raise ValueError( "Either `split_df`, `img_filepaths` or `img_dir` must be" " provided" ) img_filepaths = glob.glob(path.join(img_dir, img_filename_pattern)) values = [ dask.delayed(self.build_features_from_filepath)(img_filepath) for img_filepath in img_filepaths ] with diagnostics.ProgressBar(): X = dask.compute(*values) return np.vstack(X)