def stack_split_remerge(roi: np.ndarray, max_pixels: Sequence[int],
                            overlap: Sequence[int]) -> np.ndarray:
        """Split and remerge a stack.
        
        Args:
            roi: Region of interest.
            max_pixels: Maximum pixels along each dimension.
            overlap: Number of overlapping pixels along each dimension, to
                be added to ``max_pixels`` if possible.

        Returns:
            The remerged stack

        """
        sub_roi_slices, sub_rois_offsets = chunking.stack_splitter(
            roi.shape, max_pixels, overlap)
        print("sub_rois shape: {}".format(sub_roi_slices.shape))
        print("sub_roi_slices:\n{}".format(sub_roi_slices))
        print("overlap: {}".format(overlap))
        print("sub_rois_offsets:\n{}".format(sub_rois_offsets))
        for z in range(sub_roi_slices.shape[0]):
            for y in range(sub_roi_slices.shape[1]):
                for x in range(sub_roi_slices.shape[2]):
                    coord = (z, y, x)
                    sub_roi_slices[coord] = roi[sub_roi_slices[coord]]
                    print(coord, "shape:", sub_roi_slices[coord].shape,
                          sub_roi_slices[coord])
        # print("sub_rois:\n{}".format(sub_roi_slices))
        merged = chunking.merge_split_stack(sub_roi_slices, max_pixels,
                                            overlap)
        print("merged:\n{}".format(merged))
        return merged
def setup_blocks(settings: "profiles.SettingsDict",
                 shape: Sequence[int]) -> Blocks:
    """Set up blocks for block processing
    
    Each block is a chunk of a larger image processed sequentially or in
    parallel to optimize resource usage.
    
    Args:
        settings: Settings dictionary that defines that the blocks.
        shape: Shape of full image in ``z, y, x``.

    Returns:
        A named tuple of the block parameters.
        
    """
    scaling_factor = detector.calc_scaling_factor()
    print("microsope scaling factor based on resolutions: {}".format(
        scaling_factor))
    denoise_size = settings["denoise_size"]
    denoise_max_shape = None
    if denoise_size:
        # further subdivide each sub-ROI for local preprocessing
        denoise_max_shape = np.ceil(np.multiply(scaling_factor,
                                                denoise_size)).astype(int)

    # overlap sub-ROIs to minimize edge effects
    overlap_base = detector.calc_overlap()
    tol = np.multiply(overlap_base, settings["prune_tol_factor"]).astype(int)
    overlap_padding = np.copy(tol)
    overlap = np.copy(overlap_base)
    exclude_border = settings["exclude_border"]
    if exclude_border is not None:
        # exclude border to avoid blob detector edge effects, where blobs
        # often collect at the faces of the sub-ROI;
        # ensure that overlap is greater than twice the border exclusion per
        # axis so that no plane will be excluded from both overlapping sub-ROIs
        exclude_border_thresh = np.multiply(2, exclude_border)
        overlap_less = np.less(overlap, exclude_border_thresh)
        overlap[overlap_less] = exclude_border_thresh[overlap_less]
        excluded = np.greater(exclude_border, 0)
        overlap[excluded] += 1  # additional padding
        overlap_padding[excluded] = 0  # no need to prune past excluded border
    print("sub-ROI overlap: {}, pruning tolerance: {}, padding beyond "
          "overlap for pruning: {}, exclude borders: {}".format(
              overlap, tol, overlap_padding, exclude_border))
    max_pixels = np.ceil(np.multiply(scaling_factor,
                                     settings["segment_size"])).astype(int)
    print("preprocessing max shape: {}, detection max pixels: {}".format(
        denoise_max_shape, max_pixels))
    sub_roi_slices, sub_rois_offsets = chunking.stack_splitter(
        shape, max_pixels, overlap)
    return Blocks(sub_roi_slices, sub_rois_offsets, denoise_max_shape,
                  exclude_border, tol, overlap_base, overlap, overlap_padding,
                  max_pixels)
Exemple #3
0
    def detect_sub_roi(cls, coord, offset, last_coord, denoise_max_shape,
                       exclude_border, sub_roi, channel, img_path=None,
                       coloc=False):
        """Perform 3D blob detection within a sub-ROI without accessing
        class attributes, such as for spawned multiprocessing.
        
        Args:
            coord (Tuple[int]): Coordinate of the sub-ROI in the order z,y,x.
            offset (Tuple[int]): Offset of the sub-ROI within the full ROI,
                in z,y,x.
            last_coord (:obj:`np.ndarray`): See attributes.
            denoise_max_shape (Tuple[int]): See attributes.
            exclude_border (bool): See attributes.
            sub_roi (:obj:`np.ndarray`): Array in which to perform detections.
            img_path (str): Path from which to load metadatat; defaults to None.
                If given, the command line arguments will be reloaded to
                set up the image and processing parameters.
            coloc (bool): True to perform blob co-localizations; defaults
                to False.
            channel (Sequence[int]): Sequence of channels, where None detects
                in all channels.
        
        Returns:
            Tuple[int], :obj:`np.ndarray`: The coordinate given back again to
            identify the sub-ROI position and an array of detected blobs.

        """
        if img_path:
            # reload command-line parameters and image metadata, which is
            # required if run from a spawned (not forked) process
            cli.process_cli_args()
            _, orig_info = importer.make_filenames(img_path)
            importer.load_metadata(orig_info)
        print("detecting blobs in sub-ROI at {} of {}, offset {}, shape {}..."
              .format(coord, last_coord, tuple(offset.astype(int)),
                      sub_roi.shape))
        
        if denoise_max_shape is not None:
            # further split sub-ROI for preprocessing locally
            denoise_roi_slices, _ = chunking.stack_splitter(
                sub_roi.shape, denoise_max_shape)
            for z in range(denoise_roi_slices.shape[0]):
                for y in range(denoise_roi_slices.shape[1]):
                    for x in range(denoise_roi_slices.shape[2]):
                        denoise_coord = (z, y, x)
                        denoise_roi = sub_roi[denoise_roi_slices[denoise_coord]]
                        _logger.debug(
                            f"preprocessing sub-sub-ROI {denoise_coord} of "
                            f"{np.subtract(denoise_roi_slices.shape, 1)} "
                            f"(shape {denoise_roi.shape} within sub-ROI shape "
                            f"{sub_roi.shape})")
                        denoise_roi = plot_3d.saturate_roi(
                            denoise_roi, channel=channel)
                        denoise_roi = plot_3d.denoise_roi(
                            denoise_roi, channel=channel)
                        # replace slices with denoised ROI
                        denoise_roi_slices[denoise_coord] = denoise_roi
            
            # re-merge back into the sub-ROI
            merged_shape = chunking.get_split_stack_total_shape(
                denoise_roi_slices)
            merged = np.zeros(
                tuple(merged_shape), dtype=denoise_roi_slices[0, 0, 0].dtype)
            chunking.merge_split_stack2(denoise_roi_slices, None, 0, merged)
            sub_roi = merged
        
        if exclude_border is None:
            exclude = None
        else:
            exclude = np.array([exclude_border, exclude_border])
            exclude[0, np.equal(coord, 0)] = 0
            exclude[1, np.equal(coord, last_coord)] = 0
        segments = detector.detect_blobs(sub_roi, channel, exclude)
        if coloc and segments is not None:
            # co-localize blobs and append to blobs array
            colocs = colocalizer.colocalize_blobs(sub_roi, segments)
            segments = np.hstack((segments, colocs))
        #print("segs before (offset: {}):\n{}".format(offset, segments))
        if segments is not None:
            # shift both coordinate sets (at beginning and end of array) to 
            # absolute positioning, using the latter set to store shifted 
            # coordinates based on duplicates and the former for initial 
            # positions to check for multiple duplicates
            detector.shift_blob_rel_coords(segments, offset)
            detector.shift_blob_abs_coords(segments, offset)
            #print("segs after:\n{}".format(segments))
        return coord, segments
Exemple #4
0
def setup_blocks(settings, shape):
    """Set up blocks for block processing, where each block is a chunk of
    a larger image processed sequentially or in parallel to optimize
    resource usage.
    
    Args:
        settings (:obj:`magmap.settings.profiles.SettingsDict`): Settings
            dictionary that defines that the blocks.
        shape (List[int]): Shape of full image in z,y,x.

    Returns:
        :obj:`np.ndarray`, :obj:`np.ndarray`, :obj:`np.ndarray`, List[int],
        :obj:`np.ndarray`, :obj:`np.ndarray`, :obj:`np.ndarray`,
        :obj:`np.ndarray`: Numpy object array of tuples containing slices
        of each block; similar Numpy array but with tuples of offsets;
        Numpy int array of max shape for each sub-block used for denoising;
        List of ints for border pixels to exclude in z,y,x;
        match tolerance as a Numpy float array in z,y,x;
        Numpy float array of overlapping pixels in z,y,x;
        similar overlap array but modified by the border exclusion; and
        similar overlap array but for padding beyond the overlap.

    """
    scaling_factor = detector.calc_scaling_factor()
    print("microsope scaling factor based on resolutions: {}"
          .format(scaling_factor))
    denoise_size = settings["denoise_size"]
    denoise_max_shape = None
    if denoise_size:
        # further subdivide each sub-ROI for local preprocessing
        denoise_max_shape = np.ceil(
            np.multiply(scaling_factor, denoise_size)).astype(int)

    # overlap sub-ROIs to minimize edge effects
    overlap_base = chunking.calc_overlap()
    tol = np.multiply(overlap_base, settings["prune_tol_factor"]).astype(int)
    overlap_padding = np.copy(tol)
    overlap = np.copy(overlap_base)
    exclude_border = settings["exclude_border"]
    if exclude_border is not None:
        # exclude border to avoid blob detector edge effects, where blobs
        # often collect at the faces of the sub-ROI;
        # ensure that overlap is greater than twice the border exclusion per
        # axis so that no plane will be excluded from both overlapping sub-ROIs
        exclude_border_thresh = np.multiply(2, exclude_border)
        overlap_less = np.less(overlap, exclude_border_thresh)
        overlap[overlap_less] = exclude_border_thresh[overlap_less]
        excluded = np.greater(exclude_border, 0)
        overlap[excluded] += 1  # additional padding
        overlap_padding[excluded] = 0  # no need to prune past excluded border
    print("sub-ROI overlap: {}, pruning tolerance: {}, padding beyond "
          "overlap for pruning: {}, exclude borders: {}"
          .format(overlap, tol, overlap_padding, exclude_border))
    max_pixels = np.ceil(np.multiply(
        scaling_factor, settings["segment_size"])).astype(int)
    print("preprocessing max shape: {}, detection max pixels: {}"
          .format(denoise_max_shape, max_pixels))
    sub_roi_slices, sub_rois_offsets = chunking.stack_splitter(
        shape, max_pixels, overlap)
    return sub_roi_slices, sub_rois_offsets, denoise_max_shape, \
        exclude_border, tol, overlap_base, overlap, overlap_padding
Exemple #5
0
    def colocalize_stack(cls, shape, blobs):
        """Entry point to colocalizing blobs within a stack.

        Args:
            shape (List[int]): Image shape in z,y,x.
            blobs (:obj:`np.ndarray`): 2D Numpy array of blobs.

        Returns:
            dict[tuple[int, int], :class:`BlobMatch`]: The
            dictionary of matches, where keys are tuples of the channel pairs,
            and values are blob match objects. 

        """
        _logger.info(
            "Colocalizing blobs based on matching blobs in each pair of "
            "channels")
        # scale match tolerance based on block processing ROI size
        blocks = stack_detect.setup_blocks(config.roi_profile, shape)
        match_tol = np.multiply(blocks.overlap_base,
                                config.roi_profile["verify_tol_factor"])

        # re-split stack with ROI size adjusted to the inner padding plus
        # the raw overlap size
        inner_pad = verifier.setup_match_blobs_roi(match_tol)[2]
        inner_pad = np.add(inner_pad, blocks.overlap_base)
        sub_roi_slices, sub_rois_offsets = chunking.stack_splitter(
            shape, blocks.max_pixels, inner_pad[::-1])

        is_fork = chunking.is_fork()
        if is_fork:
            # set shared data in forked multiprocessing
            cls.blobs = blobs
            cls.match_tol = match_tol
        pool = mp.Pool(processes=config.cpus)
        pool_results = []
        for z in range(sub_roi_slices.shape[0]):
            for y in range(sub_roi_slices.shape[1]):
                for x in range(sub_roi_slices.shape[2]):
                    coord = (z, y, x)
                    offset = sub_rois_offsets[coord]
                    slices = sub_roi_slices[coord]
                    shape = [s.stop - s.start for s in slices]
                    if is_fork:
                        # use variables stored as class attributes
                        pool_results.append(
                            pool.apply_async(StackColocalizer.colocalize_block,
                                             args=(coord, offset, shape)))
                    else:
                        # pickle full set of variables
                        pool_results.append(
                            pool.apply_async(StackColocalizer.colocalize_block,
                                             args=(coord, offset, shape,
                                                   detector.get_blobs_in_roi(
                                                       blobs, offset,
                                                       shape)[0], match_tol,
                                                   True)))

        # dict of channel combos to blob matches data frame
        matches_all = {}
        for result in pool_results:
            coord, matches = result.get()
            count = 0
            for key, val in matches.items():
                matches_all.setdefault(key, []).append(val.df)
                count += len(val.df)
            _logger.info("Adding %s matches from block at %s of %s", count,
                         coord, np.add(sub_roi_slices.shape, -1))

        pool.close()
        pool.join()

        # prune duplicates by taking matches with shortest distance
        for key in matches_all.keys():
            matches_all[key] = pd.concat(matches_all[key])
            if matches_all[key].size > 0:
                for blobi in (BlobMatch.Cols.BLOB1, BlobMatch.Cols.BLOB2):
                    # convert blob column to ndarray to extract coords by column
                    matches = matches_all[key]
                    matches_uniq, matches_i, matches_inv, matches_cts = np.unique(
                        np.vstack(matches[blobi.value])[:, :3],
                        axis=0,
                        return_index=True,
                        return_inverse=True,
                        return_counts=True)
                    if np.sum(matches_cts > 1) > 0:
                        # prune if at least one blob has been matched to
                        # multiple other blobs
                        singles = matches.iloc[matches_i[matches_cts == 1]]
                        dups = []
                        for i, ct in enumerate(matches_cts):
                            # include non-duplicates to retain index
                            if ct <= 1: continue
                            # get indices in orig matches at given unique array
                            # index and take match with lowest dist
                            matches_mult = matches.loc[matches_inv == i]
                            dists = matches_mult[BlobMatch.Cols.DIST.value]
                            min_dist = np.amin(dists)
                            num_matches = len(matches_mult)
                            if num_matches > 1:
                                _logger.debug(
                                    "Pruning from %s matches of dist: %s",
                                    num_matches, dists)
                            matches_mult = matches_mult.loc[dists == min_dist]
                            # take first in case of any ties
                            dups.append(matches_mult.iloc[[0]])
                        matches_all[key] = pd.concat(
                            (singles, pd.concat(dups)))
                _logger.info("Colocalization matches for channels %s: %s", key,
                             len(matches_all[key]))
            _logger.debug("Blob matches for %s after pruning:\n%s", key,
                          matches_all[key])
            # store data frame in BlobMatch object
            matches_all[key] = BlobMatch(df=matches_all[key])

        return matches_all
def transpose_img(filename,
                  series,
                  plane=None,
                  rescale=None,
                  target_size=None):
    """Transpose Numpy NPY saved arrays into new planar orientations and 
    rescaling or resizing.
    
    Rescaling/resizing take place in multiprocessing. Files are saved
    through memmap-based arrays to minimize RAM usage. Output filenames
    are based on the ``make_modifer_[task]`` functions. Currently transposes
    all channels, ignoring :attr:``config.channel`` parameter.
    
    Args:
        filename: Full file path in :attribute:cli:`filename` format.
        series: Series within multi-series file.
        plane: Planar orientation (see :attribute:plot_2d:`PLANES`). Defaults 
            to None, in which case no planar transformation will occur.
        rescale: Rescaling factor; defaults to None. Takes precedence over
            ``target_size``.
        target_size (List[int]): Target shape in x,y,z; defaults to None,
            in which case the target size will be extracted from the register
            profile if available if available.

    """
    if target_size is None:
        target_size = config.atlas_profile["target_size"]
    if plane is None and rescale is None and target_size is None:
        print("No transposition to perform, skipping")
        return

    time_start = time()
    # even if loaded already, reread to get image metadata
    # TODO: consider saving metadata in config and retrieving from there
    img5d = importer.read_file(filename, series)
    info = img5d.meta
    image5d = img5d.img
    sizes = info["sizes"]

    # make filenames based on transpositions
    modifier = ""
    if plane is not None:
        modifier = make_modifier_plane(plane)
    # either rescaling or resizing
    if rescale is not None:
        modifier += make_modifier_scale(rescale)
    elif target_size:
        # target size may differ from final output size but allows a known
        # size to be used for finding the file later
        modifier += make_modifier_resized(target_size)
    filename_image5d_npz, filename_info_npz = importer.make_filenames(
        filename, series, modifier=modifier)

    # TODO: image5d should assume 4/5 dimensions
    offset = 0 if image5d.ndim <= 3 else 1
    multichannel = image5d.ndim >= 5
    image5d_swapped = image5d

    if plane is not None and plane != config.PLANE[0]:
        # swap z-y to get (y, z, x) order for xz orientation
        image5d_swapped = np.swapaxes(image5d_swapped, offset, offset + 1)
        config.resolutions[0] = libmag.swap_elements(config.resolutions[0], 0,
                                                     1)
        if plane == config.PLANE[2]:
            # swap new y-x to get (x, z, y) order for yz orientation
            image5d_swapped = np.swapaxes(image5d_swapped, offset, offset + 2)
            config.resolutions[0] = libmag.swap_elements(
                config.resolutions[0], 0, 2)

    scaling = None
    if rescale is not None or target_size is not None:
        # rescale based on scaling factor or target specific size
        rescaled = image5d_swapped
        # TODO: generalize for more than 1 preceding dimension?
        if offset > 0:
            rescaled = rescaled[0]
        max_pixels = [100, 500, 500]
        sub_roi_size = None
        if target_size:
            # to avoid artifacts from thin chunks, fit image into even
            # number of pixels per chunk by rounding up number of chunks
            # and resizing each chunk by ratio of total size to chunk num
            target_size = target_size[::-1]  # change to z,y,x
            shape = rescaled.shape[:3]
            num_chunks = np.ceil(np.divide(shape, max_pixels))
            max_pixels = np.ceil(np.divide(shape, num_chunks)).astype(np.int)
            sub_roi_size = np.floor(np.divide(target_size,
                                              num_chunks)).astype(np.int)
            print("Resizing image of shape {} to target_size: {}, using "
                  "num_chunks: {}, max_pixels: {}, sub_roi_size: {}".format(
                      rescaled.shape, target_size, num_chunks, max_pixels,
                      sub_roi_size))
        else:
            print("Rescaling image of shape {} by factor of {}".format(
                rescaled.shape, rescale))

        # rescale in chunks with multiprocessing
        sub_roi_slices, _ = chunking.stack_splitter(rescaled.shape, max_pixels)
        is_fork = chunking.is_fork()
        if is_fork:
            Downsampler.set_data(rescaled)
        sub_rois = np.zeros_like(sub_roi_slices)
        pool = chunking.get_mp_pool()
        pool_results = []
        for z in range(sub_roi_slices.shape[0]):
            for y in range(sub_roi_slices.shape[1]):
                for x in range(sub_roi_slices.shape[2]):
                    coord = (z, y, x)
                    slices = sub_roi_slices[coord]
                    args = [coord, slices, rescale, sub_roi_size, multichannel]
                    if not is_fork:
                        # pickle chunk if img not directly available
                        args.append(rescaled[slices])
                    pool_results.append(
                        pool.apply_async(Downsampler.rescale_sub_roi,
                                         args=args))
        for result in pool_results:
            coord, sub_roi = result.get()
            print("replacing sub_roi at {} of {}".format(
                coord, np.add(sub_roi_slices.shape, -1)))
            sub_rois[coord] = sub_roi

        pool.close()
        pool.join()
        rescaled_shape = chunking.get_split_stack_total_shape(sub_rois)
        if offset > 0:
            rescaled_shape = np.concatenate(([1], rescaled_shape))
        print("rescaled_shape: {}".format(rescaled_shape))
        # rescale chunks directly into memmap-backed array to minimize RAM usage
        image5d_transposed = np.lib.format.open_memmap(
            filename_image5d_npz,
            mode="w+",
            dtype=sub_rois[0, 0, 0].dtype,
            shape=tuple(rescaled_shape))
        chunking.merge_split_stack2(sub_rois, None, offset, image5d_transposed)

        if rescale is not None:
            # scale resolutions based on single rescaling factor
            config.resolutions = np.multiply(config.resolutions, 1 / rescale)
        else:
            # scale resolutions based on size ratio for each dimension
            config.resolutions = np.multiply(config.resolutions,
                                             (image5d_swapped.shape /
                                              rescaled_shape)[1:4])
        sizes[0] = rescaled_shape
        scaling = importer.calc_scaling(image5d_swapped, image5d_transposed)
    else:
        # transfer directly to memmap-backed array
        image5d_transposed = np.lib.format.open_memmap(
            filename_image5d_npz,
            mode="w+",
            dtype=image5d_swapped.dtype,
            shape=image5d_swapped.shape)
        if plane == config.PLANE[1] or plane == config.PLANE[2]:
            # flip upside-down if re-orienting planes
            if offset:
                image5d_transposed[0, :] = np.fliplr(image5d_swapped[0, :])
            else:
                image5d_transposed[:] = np.fliplr(image5d_swapped[:])
        else:
            image5d_transposed[:] = image5d_swapped[:]
        sizes[0] = image5d_swapped.shape

    # save image metadata
    print("detector.resolutions: {}".format(config.resolutions))
    print("sizes: {}".format(sizes))
    image5d.flush()
    importer.save_image_info(
        filename_info_npz, info["names"], sizes, config.resolutions,
        info["magnification"], info["zoom"],
        *importer.calc_intensity_bounds(image5d_transposed), scaling, plane)
    print("saved transposed file to {} with shape {}".format(
        filename_image5d_npz, image5d_transposed.shape))
    print("time elapsed (s): {}".format(time() - time_start))
def detect_blobs_large_image(filename_base, image5d, offset, size,
                             verify=False, save_dfs=True, full_roi=False):
    """Detect blobs within a large image through parallel processing of 
    smaller chunks.
    
    Args:
        filename_base: Base path to use file output.
        image5d: Large image to process as a Numpy array of t,z,y,x,[c]
        offset: Sub-image offset given as coordinates in z,y,x.
        size: Sub-image shape given in z,y,x.
        verify: True to verify detections against truth database; defaults 
            to False.
        save_dfs: True to save data frames to file; defaults to True.
        full_roi (bool): True to treat ``image5d`` as the full ROI; defaults
            to False.
    """
    time_start = time()
    if size is None or offset is None:
        # uses the entire stack if no size or offset specified
        size = image5d.shape[1:4]
        offset = (0, 0, 0)
    else:
        # change base filename for ROI-based partial stack
        filename_base = make_subimage_name(filename_base, offset, size)
    filename_subimg = libmag.combine_paths(filename_base, config.SUFFIX_SUBIMG)
    filename_blobs = libmag.combine_paths(filename_base, config.SUFFIX_BLOBS)
    
    # get ROI for given region, including all channels
    if full_roi:
        # treat the full image as the ROI
        roi = image5d[0]
    else:
        roi = plot_3d.prepare_subimg(image5d, size, offset)
    _, channels = plot_3d.setup_channels(roi, config.channel, 3)
    
    # prep chunking ROI into sub-ROIs with size based on segment_size, scaling
    # by physical units to make more independent of resolution
    time_detection_start = time()
    settings = config.roi_profile  # use default settings
    scaling_factor = detector.calc_scaling_factor()
    print("microsope scaling factor based on resolutions: {}"
          .format(scaling_factor))
    denoise_size = config.roi_profile["denoise_size"]
    denoise_max_shape = None
    if denoise_size:
        # further subdivide each sub-ROI for local preprocessing
        denoise_max_shape = np.ceil(
            np.multiply(scaling_factor, denoise_size)).astype(int)

    # overlap sub-ROIs to minimize edge effects
    overlap_base = chunking.calc_overlap()
    tol = np.multiply(overlap_base, settings["prune_tol_factor"]).astype(int)
    overlap_padding = np.copy(tol)
    overlap = np.copy(overlap_base)
    exclude_border = config.roi_profile["exclude_border"]
    if exclude_border is not None:
        # exclude border to avoid blob detector edge effects, where blobs
        # often collect at the faces of the sub-ROI;
        # ensure that overlap is greater than twice the border exclusion per
        # axis so that no plane will be excluded from both overlapping sub-ROIs
        exclude_border_thresh = np.multiply(2, exclude_border)
        overlap_less = np.less(overlap, exclude_border_thresh)
        overlap[overlap_less] = exclude_border_thresh[overlap_less]
        excluded = np.greater(exclude_border, 0)
        overlap[excluded] += 1  # additional padding
        overlap_padding[excluded] = 0  # no need to prune past excluded border
    print("sub-ROI overlap: {}, pruning tolerance: {}, padding beyond "
          "overlap for pruning: {}, exclude borders: {}"
          .format(overlap, tol, overlap_padding, exclude_border))
    max_pixels = np.ceil(np.multiply(
        scaling_factor, 
        config.roi_profile["segment_size"])).astype(int)
    print("preprocessing max shape: {}, detection max pixels: {}"
          .format(denoise_max_shape, max_pixels))
    sub_roi_slices, sub_rois_offsets = chunking.stack_splitter(
        roi.shape, max_pixels, overlap)
    # TODO: option to distribute groups of sub-ROIs to different servers 
    # for blob detection
    seg_rois = detect_blobs_sub_rois(
        roi, sub_roi_slices, sub_rois_offsets, denoise_max_shape, exclude_border)
    detection_time = time() - time_detection_start
    print("blob detection time (s):", detection_time)
    
    # prune blobs in overlapping portions of sub-ROIs
    time_pruning_start = time()
    segments_all, df_pruning = _prune_blobs_mp(
        roi, seg_rois, overlap, tol, sub_roi_slices, sub_rois_offsets, channels,
        overlap_padding)
    pruning_time = time() - time_pruning_start
    print("blob pruning time (s):", pruning_time)
    #print("maxes:", np.amax(segments_all, axis=0))
    
    # get weighted mean of ratios
    if df_pruning is not None:
        print("\nBlob pruning ratios:")
        path_pruning = "blob_ratios.csv" if save_dfs else None
        df_pruning_all = df_io.data_frames_to_csv(
            df_pruning, path_pruning, show=" ")
        cols = df_pruning_all.columns.tolist()
        blob_pruning_means = {}
        if "blobs" in cols:
            blobs_unpruned = df_pruning_all["blobs"]
            num_blobs_unpruned = np.sum(blobs_unpruned)
            for col in cols[1:]:
                blob_pruning_means["mean_{}".format(col)] = [
                    np.sum(np.multiply(df_pruning_all[col], blobs_unpruned)) 
                    / num_blobs_unpruned]
            path_pruning_means = "blob_ratios_means.csv" if save_dfs else None
            df_pruning_means = df_io.dict_to_data_frame(
                blob_pruning_means, path_pruning_means, show=" ")
        else:
            print("no blob ratios found")
    
    '''# report any remaining duplicates
    np.set_printoptions(linewidth=500, threshold=10000000)
    print("all blobs (len {}):".format(len(segments_all)))
    sort = np.lexsort(
        (segments_all[:, 2], segments_all[:, 1], segments_all[:, 0]))
    blobs = segments_all[sort]
    print(blobs)
    print("checking for duplicates in all:")
    print(detector.remove_duplicate_blobs(blobs, slice(0, 3)))
    '''
    
    stats_detection = None
    fdbk = None
    if segments_all is not None:
        # remove the duplicated elements that were used for pruning
        detector.replace_rel_with_abs_blob_coords(segments_all)
        segments_all = detector.remove_abs_blob_coords(segments_all)
        
        # compare detected blobs with truth blobs
        # TODO: assumes ground truth is relative to any ROI offset,
        # but should make customizable
        if verify:
            db_path_base = None
            exp_name = os.path.splitext(os.path.basename(config.filename))[0]
            try:
                if config.truth_db is None:
                    # find and load truth DB based on filename and subimage
                    db_path_base = os.path.basename(filename_base)
                    print("about to verify with truth db from {}"
                          .format(db_path_base))
                    sqlite.load_truth_db(db_path_base)
                if config.truth_db is not None:
                    # truth DB may contain multiple experiments for different
                    # subimages; series not included in exp name since in ROI
                    rois = config.truth_db.get_rois(exp_name)
                    if rois is None:
                        # exp may have been named by ROI
                        print("{} experiment name not found, will try with"
                              "ROI offset/size".format(exp_name))
                        exp_name = make_subimage_name(exp_name, offset, size)
                        rois = config.truth_db.get_rois(exp_name)
                    if rois is None:
                        raise LookupError(
                            "No truth set ROIs found for experiment {}, will "
                            "skip detection verification".format(exp_name))
                    print("load ROIs from exp: {}".format(exp_name))
                    exp_id = sqlite.insert_experiment(
                        config.verified_db.conn, config.verified_db.cur, 
                        exp_name, None)
                    verify_tol = np.multiply(
                        overlap_base, settings["verify_tol_factor"])
                    stats_detection, fdbk = detector.verify_rois(
                        rois, segments_all, config.truth_db.blobs_truth, 
                        verify_tol, config.verified_db, exp_id, config.channel)
            except FileNotFoundError:
                libmag.warn("Could not load truth DB from {}; "
                            "will not verify ROIs".format(db_path_base))
            except LookupError as e:
                libmag.warn(str(e))
    
    file_time_start = time()
    if config.save_subimg:
        if (isinstance(config.image5d, np.memmap) and 
                config.image5d.filename == os.path.abspath(filename_subimg)):
            # file at sub-image save path may have been opened as a memmap
            # file, in which case saving would fail
            libmag.warn("{} is currently open, cannot save sub-image"
                        .format(filename_subimg))
        else:
            # write sub-image, which is in ROI (3D) format
            with open(filename_subimg, "wb") as f:
                np.save(f, roi)

    # save blobs
    # TODO: only segments used; consider removing the rest except ver
    outfile_blobs = open(filename_blobs, "wb")
    np.savez(outfile_blobs, ver=BLOBS_NP_VER, segments=segments_all,
             resolutions=config.resolutions,
             basename=os.path.basename(config.filename),  # only save name
             offset=offset, roi_size=size)  # None unless explicitly set
    outfile_blobs.close()
    file_save_time = time() - file_time_start
    
    # whole image benchmarking time
    times = (
        [detection_time], 
        [pruning_time], 
        time() - time_start)
    times_dict = {}
    for key, val in zip(StackTimes, times):
        times_dict[key] = val
    if segments_all is None:
        print("\nNo blobs detected")
    else:
        print("\nTotal blobs found:", len(segments_all))
        detector.show_blobs_per_channel(segments_all)
    print("file save time:", file_save_time)
    print("\nTotal detection processing times (s):")
    path_times = "stack_detection_times.csv" if save_dfs else None
    df_io.dict_to_data_frame(times_dict, path_times, show=" ")
    
    return stats_detection, fdbk, segments_all