diff options
Diffstat (limited to 'src/silx/image/marchingsquares')
-rw-r--r-- | src/silx/image/marchingsquares/__init__.py | 117 | ||||
-rw-r--r-- | src/silx/image/marchingsquares/_mergeimpl.pyx | 1319 | ||||
-rw-r--r-- | src/silx/image/marchingsquares/_skimage.py | 139 | ||||
-rw-r--r-- | src/silx/image/marchingsquares/include/patterns.h | 89 | ||||
-rw-r--r-- | src/silx/image/marchingsquares/setup.py | 51 | ||||
-rw-r--r-- | src/silx/image/marchingsquares/test/__init__.py | 24 | ||||
-rw-r--r-- | src/silx/image/marchingsquares/test/test_funcapi.py | 92 | ||||
-rw-r--r-- | src/silx/image/marchingsquares/test/test_mergeimpl.py | 264 |
8 files changed, 2095 insertions, 0 deletions
diff --git a/src/silx/image/marchingsquares/__init__.py b/src/silx/image/marchingsquares/__init__.py new file mode 100644 index 0000000..a47a7f6 --- /dev/null +++ b/src/silx/image/marchingsquares/__init__.py @@ -0,0 +1,117 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This module provides implementations based on marching squares algorithms. + +The main implementation is done by :class:`MarchingSquaresMergeImpl`. It was +designed to speed up the computation of iso surface using Cython and OpenMP. +It also provides features like support of mask, and cache of min/max per tiles +which is very efficient to find many iso contours from image gradient. + +Utilitary functions are provided as facade for simple use. +:meth:`find_contours` to find iso contours from an image and using the same +main signature as `find_contours` from `skimage`, but supporting mask. +And :meth:`find_pixels` which returns a set of pixel coords containing the +points of the iso contours. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "02/07/2018" + + +from ._mergeimpl import MarchingSquaresMergeImpl + + +def _factory(engine, image, mask): + """Factory to create the marching square implementation from the engine + name""" + if engine == "merge": + return MarchingSquaresMergeImpl(image, mask) + elif engine == "skimage": + from _skimage import MarchingSquaresSciKitImage + return MarchingSquaresSciKitImage(image, mask) + else: + raise ValueError("Engine '%s' is not supported ('merge' or 'skimage' expected).") + + +def find_pixels(image, level, mask=None): + """ + Find the pixels following the iso contours at the given `level`. + + These pixels are localized by the bound of the segment generated by the + iso contour algorithm. + + The result is returned as a numpy array storing a list of coordinates y/x. + + .. code-block:: python + + # Example using a mask + shape = 100, 100 + image = numpy.random.random(shape) + mask = numpy.random.random(shape) < 0.01 + pixels = silx.image.marchingsquares.find_pixels(image, 0.5, mask=mask) + + :param numpy.ndarray image: Image to process + :param float level: Level of the requested iso contours. + :param numpy.ndarray mask: An optional mask (a non-zero value invalidate + the pixels of the image) + :returns: An array of coordinates in y/x + :rtype: numpy.ndarray + """ + assert(image is not None) + if mask is not None: + assert(image.shape == mask.shape) + engine = "merge" + impl = _factory(engine, image, mask) + return impl.find_pixels(level) + + +def find_contours(image, level, mask=None): + """ + Find the iso contours at the given `level`. + + The result is returned as a list of polygons. + + .. code-block:: python + + # Example using a mask + shape = 100, 100 + image = numpy.random.random(shape) + mask = numpy.random.random(shape) < 0.01 + polygons = silx.image.marchingsquares.find_contours(image, 0.5, mask=mask) + + :param numpy.ndarray image: Image to process + :param float level: Level of the requested iso contours. + :param numpy.ndarray mask: An optional mask (a non-zero value invalidate + the pixels of the image) + :returns: A list of array containing y-x coordinates of points + :rtype: List[numpy.ndarray] + """ + assert(image is not None) + if mask is not None: + assert(image.shape == mask.shape) + engine = "merge" + impl = _factory(engine, image, mask) + return impl.find_contours(level) diff --git a/src/silx/image/marchingsquares/_mergeimpl.pyx b/src/silx/image/marchingsquares/_mergeimpl.pyx new file mode 100644 index 0000000..5a7a3b5 --- /dev/null +++ b/src/silx/image/marchingsquares/_mergeimpl.pyx @@ -0,0 +1,1319 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2018-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Marching squares implementation based on a merge of segements and polygons. +""" + +__authors__ = ["Almar Klein", "Jerome Kieffer", "Valentin Valls"] +__license__ = "MIT" +__date__ = "23/04/2018" + +import numpy +cimport numpy as cnumpy + +from libcpp.vector cimport vector +from libcpp.list cimport list as clist +from libcpp.set cimport set as cset +from libcpp.map cimport map +from libcpp cimport bool +from libc.math cimport fabs +from libc.math cimport floor + +from cython.parallel import prange +from cython.operator cimport dereference +from cython.operator cimport preincrement +cimport libc.stdlib +cimport libc.string + +cimport cython + +from ...utils._have_openmp cimport COMPILED_WITH_OPENMP +"""Store in the module if it was compiled with OpenMP""" + +cdef double EPSILON = numpy.finfo(numpy.float64).eps + +# Windows compatibility: Cross-platform INFINITY +from libc.float cimport DBL_MAX +cdef double INFINITY = DBL_MAX + DBL_MAX +# from libc.math cimport INFINITY + +cdef extern from "include/patterns.h": + cdef unsigned char EDGE_TO_POINT[][2] + cdef unsigned char CELL_TO_EDGE[][5] + cdef struct coord_t: + short x + short y + +ctypedef cnumpy.uint32_t point_index_t +"""Type of the unique index identifying a connection for the polygons.""" + +"""Define a point of a polygon.""" +cdef struct point_t: + cnumpy.float32_t x + cnumpy.float32_t y + +"""Description of a non-final polygon.""" +cdef cppclass PolygonDescription: + point_index_t begin + point_index_t end + clist[point_t] points + + PolygonDescription() nogil: + pass + +"""Description of a tile context. + +It contains structure to store intermediate and final data of a thread. +Pixels and contours structures are merged together as it looks to have +mostly no cost. +""" +cdef cppclass TileContext: + int pos_x + int pos_y + int dim_x + int dim_y + + # Only used to find contours + clist[PolygonDescription*] final_polygons + map[point_index_t, PolygonDescription*] polygons + + # Only used to find pixels + clist[coord_t] final_pixels + cset[coord_t] pixels + + TileContext() nogil: + pass + + +cdef class _MarchingSquaresAlgorithm(object): + """Abstract class managing a marching squares algorithm. + + It provides common methods to execute the process, with the support of + OpenMP, plus some hooks. Mostly created to be able to reuse part of the + logic between `_MarchingSquaresContours` and `_MarchingSquaresPixels`. + """ + + cdef cnumpy.float32_t *_image_ptr + cdef cnumpy.int8_t *_mask_ptr + cdef int _dim_x + cdef int _dim_y + cdef int _group_size + cdef bool _use_minmax_cache + cdef bool _force_sequencial_reduction + + cdef TileContext* _final_context + + cdef cnumpy.float32_t *_min_cache + cdef cnumpy.float32_t *_max_cache + + def __cinit__(self): + self._use_minmax_cache = False + self._force_sequencial_reduction = False + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void marching_squares(self, cnumpy.float64_t level) nogil: + """ + Main method to execute the marching squares. + + :param level: The level expected. + """ + cdef: + TileContext** contexts + TileContext** valid_contexts + int nb_contexts, nb_valid_contexts + int i, j + TileContext* context + int dim_x, dim_y + + contexts = self.create_contexts(level, &dim_x, &dim_y, &nb_valid_contexts) + nb_contexts = dim_x * dim_y + + if nb_valid_contexts == 0: + # shortcut + self._final_context = new TileContext() + libc.stdlib.free(contexts) + return + + j = 0 + valid_contexts = <TileContext **>libc.stdlib.malloc(nb_valid_contexts * sizeof(TileContext*)) + for i in xrange(nb_contexts): + if contexts[i] != NULL: + valid_contexts[j] = contexts[i] + j += 1 + + # openmp + for i in prange(nb_valid_contexts, nogil=True): + self.marching_squares_mp(valid_contexts[i], level) + + if nb_valid_contexts == 1: + # shortcut + self._final_context = valid_contexts[0] + libc.stdlib.free(valid_contexts) + libc.stdlib.free(contexts) + return + + if self._force_sequencial_reduction: + self.sequencial_reduction(nb_valid_contexts, valid_contexts) + # FIXME can only be used if compiled with openmp + # elif copenmp.omp_get_num_threads() <= 1: + # self._sequencial_reduction(nb_valid_contexts, valid_contexts) + else: + self.reduction_2d(dim_x, dim_y, contexts) + + libc.stdlib.free(valid_contexts) + libc.stdlib.free(contexts) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void reduction_2d(self, int dim_x, int dim_y, TileContext **contexts) nogil: + """ + Reduce the problem merging first neighbours together in a recursive + process. Optimized with OpenMP. + + :param dim_x: Number of contexts in the x dimension + :param dim_y: Number of contexts in the y dimension + :param contexts: Array of contexts + """ + cdef: + int x1, y1, x2, y2, i1, i2 + int delta = 1 + + while True: + if delta >= dim_x and delta >= dim_y: + break + # NOTE: Cython 0.21.1 is buggy with prange + steps + # It is needed to add a delta and the 'to' + # Here is what we can use with Cython 0.28: + # for i in prange(0, dim_x, (delta + delta)): + for i1 in prange(0, dim_x + (delta + delta - 1), delta + delta, nogil=True): + x1 = i1 + if x1 + delta < dim_x: + y1 = 0 + while y1 < dim_y: + self.merge_array_contexts(contexts, y1 * dim_x + x1, y1 * dim_x + x1 + delta) + y1 = y1 + delta + + # NOTE: Cython 0.21.1 is buggy with prange + steps + # It is needed to add a delta and the 'to' + # Here is what we can use with Cython 0.28: + # for i in prange(0, dim_y, (delta + delta)): + for i2 in prange(0, dim_y + (delta + delta - 1), delta + delta, nogil=True): + y2 = i2 + if y2 + delta < dim_y: + x2 = 0 + while x2 < dim_x: + self.merge_array_contexts(contexts, y2 * dim_x + x2, (y2 + delta) * dim_x + x2) + x2 = x2 + delta + delta + delta <<= 1 + + self._final_context = contexts[0] + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef inline void merge_array_contexts(self, + TileContext **contexts, + int index1, + int index2) nogil: + """ + Merge contexts from `index2` to `index1` and delete the one from index2. + If the one from index1 was NULL, the one from index2 is moved to index1 + and is not deleted. + + This intermediate function was needed to avoid compilation problem of + Cython + OpenMP. + """ + cdef: + TileContext *context1 + TileContext *context2 + + context1 = contexts[index1] + context2 = contexts[index2] + if context1 != NULL and context2 != NULL: + self.merge_context(context1, context2) + del context2 + elif context2 != NULL: + contexts[index1] = context2 + # for sanity + # contexts[index2] = NULL + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void sequencial_reduction(self, + int nb_contexts, + TileContext **contexts) nogil: + """ + Reduce the problem sequencially without taking care of the topology + + :param nb_contexts: Number of contexts + :param contexts: Array of contexts + """ + cdef: + int i + # merge + self._final_context = new TileContext() + for i in xrange(nb_contexts): + if contexts[i] != NULL: + self.merge_context(self._final_context, contexts[i]) + del contexts[i] + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void marching_squares_mp(self, + TileContext *context, + cnumpy.float64_t level) nogil: + """ + Main entry of the marching squares algorithm for each threads. + + :param context: Context used by the thread to store data + :param level: The requested level + """ + cdef: + int x, y, pattern + cnumpy.float64_t tmpf + cnumpy.float32_t *image_ptr + cnumpy.int8_t *mask_ptr + + image_ptr = self._image_ptr + (context.pos_y * self._dim_x + context.pos_x) + if self._mask_ptr != NULL: + mask_ptr = self._mask_ptr + (context.pos_y * self._dim_x + context.pos_x) + else: + mask_ptr = NULL + + for y in range(context.pos_y, context.pos_y + context.dim_y): + for x in range(context.pos_x, context.pos_x + context.dim_x): + # Calculate index. + pattern = 0 + if image_ptr[0] > level: + pattern += 1 + if image_ptr[1] > level: + pattern += 2 + if image_ptr[self._dim_x] > level: + pattern += 8 + if image_ptr[self._dim_x + 1] > level: + pattern += 4 + + # Resolve ambiguity + if pattern == 5 or pattern == 10: + # Calculate value of cell center (i.e. average of corners) + tmpf = 0.25 * (image_ptr[0] + + image_ptr[1] + + image_ptr[self._dim_x] + + image_ptr[self._dim_x + 1]) + # If below level, swap + if tmpf <= level: + if pattern == 5: + pattern = 10 + else: + pattern = 5 + + # Cache mask information + if mask_ptr != NULL: + # Note: Store the mask in the index. It could be usefull to + # generate accurate segments in some cases, but yet it + # is not used + if mask_ptr[0] > 0: + pattern += 16 + if mask_ptr[1] > 0: + pattern += 32 + if mask_ptr[self._dim_x] > 0: + pattern += 128 + if mask_ptr[self._dim_x + 1] > 0: + pattern += 64 + mask_ptr += 1 + + if pattern < 16 and pattern != 0 and pattern != 15: + self.insert_pattern(context, x, y, pattern, level) + + image_ptr += 1 + + # There is a missing pixel at the end of each rows + image_ptr += self._dim_x - context.dim_x + if mask_ptr != NULL: + mask_ptr += self._dim_x - context.dim_x + + self.after_marching_squares(context) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void after_marching_squares(self, TileContext *context) nogil: + """ + Called by each threads after execution of the marching squares + algorithm. Called before merging together the contextes. + + :param context: Context used by the thread to store data + """ + pass + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void insert_pattern(self, + TileContext *context, + int x, + int y, + int pattern, + cnumpy.float64_t level) nogil: + """ + Called by the marching squares algorithm each time a pattern is found. + + :param context: Context used by the thread to store data + :param x: X location of the pattern + :param y: Y location of the pattern + :param pattern: Binary-field identifying lower and higher pixel levels + :param level: The requested level + """ + pass + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void merge_context(self, + TileContext *context, + TileContext *other) nogil: + """ + Merge into a context another context. + + :param context: Context which will contains the merge result + :param other: Context to merge into the other one. The merging process + is destructive. The context may returns empty. + """ + pass + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef TileContext** create_contexts(self, + cnumpy.float64_t level, + int* dim_x, + int* dim_y, + int* nb_valid_contexts) nogil: + """ + Create and initialize a 2d-array of contexts. + + If the minmax cache is used, only useful context will be created. + Thous with the minmax range excluding the level will not be created and + will have a `NULL` reference in the context array. + + :param level: The requested level + :param dim_x: Resulting X dimension of context array + :param dim_x: Resulting Y dimension of context array + :param nb_valid_contexts: Resulting number of created contexts + :return: The context array + """ + cdef: + int context_dim_x, context_dim_y + int context_size, valid_contexts + int x, y + int icontext + TileContext* context + TileContext** contexts + + context_dim_x = self._dim_x // self._group_size + (self._dim_x % self._group_size > 0) + context_dim_y = self._dim_y // self._group_size + (self._dim_y % self._group_size > 0) + context_size = context_dim_x * context_dim_y + contexts = <TileContext **>libc.stdlib.malloc(context_size * sizeof(TileContext*)) + libc.string.memset(contexts, 0, context_size * sizeof(TileContext*)) + + valid_contexts = 0 + icontext = 0 + y = 0 + while y < self._dim_y - 1: + x = 0 + while x < self._dim_x - 1: + if self._use_minmax_cache: + if level < self._min_cache[icontext] or level > self._max_cache[icontext]: + icontext += 1 + x += self._group_size + continue + context = self.create_context(x, y, self._group_size, self._group_size) + contexts[icontext] = context + icontext += 1 + valid_contexts += 1 + x += self._group_size + y += self._group_size + + # dereference is not working here... then we uses array index but + # it is not the proper way + dim_x[0] = context_dim_x + dim_y[0] = context_dim_y + nb_valid_contexts[0] = valid_contexts + return contexts + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef TileContext *create_context(self, + int x, + int y, + int dim_x, + int dim_y) nogil: + """ + Allocate and initialize a context. + + :param x: Left location of the context into the image + :param y: Top location of the context into the image + :param dim_x: Size of the context in the X dimension of the image + :param dim_y: Size of the context in the Y dimension of the image + :return: The context + """ + cdef: + TileContext *context + context = new TileContext() + context.pos_x = x + context.pos_y = y + context.dim_x = dim_x + context.dim_y = dim_y + if x + context.dim_x > self._dim_x - 1: + context.dim_x = self._dim_x - 1 - x + if y + context.dim_y > self._dim_y - 1: + context.dim_y = self._dim_y - 1 - y + if context.dim_x <= 0 or context.dim_y <= 0: + del context + return NULL + return context + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void compute_point(self, + cnumpy.uint32_t x, + cnumpy.uint32_t y, + cnumpy.uint8_t edge, + cnumpy.float64_t level, + point_t *result_point) nogil: + """ + Compute the location of a point of the polygons according to the level + and the neighbours. + + :param x: X location of the 4-pixels + :param y: Y location of the 4-pixels + :param edge: Enumeration identifying the 2-pixels to process + :param level: The requested level + :param result_point: Resulting value of the point + """ + cdef: + int dx1, dy1, index1 + int dx2, dy2, index2 + cnumpy.float64_t fx, fy, ff, weight1, weight2 + # Use these to look up the relative positions of the pixels to interpolate + dx1, dy1 = EDGE_TO_POINT[edge][0], EDGE_TO_POINT[edge][1] + dx2, dy2 = EDGE_TO_POINT[edge + 1][0], EDGE_TO_POINT[edge + 1][1] + # Define "strength" of each corner of the cube that we need + index1 = (y + dy1) * self._dim_x + x + dx1 + index2 = (y + dy2) * self._dim_x + x + dx2 + weight1 = 1.0 / (EPSILON + fabs(self._image_ptr[index1] - level)) + weight2 = 1.0 / (EPSILON + fabs(self._image_ptr[index2] - level)) + # Apply a kind of center-of-mass method + fx, fy, ff = 0.0, 0.0, 0.0 + fx += dx1 * weight1 + fy += dy1 * weight1 + ff += weight1 + fx += dx2 * weight2 + fy += dy2 * weight2 + ff += weight2 + fx /= ff + fy /= ff + result_point.x = x + fx + result_point.y = y + fy + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void compute_ipoint(self, + cnumpy.uint32_t x, + cnumpy.uint32_t y, + cnumpy.uint8_t edge, + cnumpy.float64_t level, + coord_t *result_coord) nogil: + """ + Compute the location of pixel which contains the point of the polygons + according to the level and the neighbours. + + This implementation is supposed to be faster than `compute_point` when + we only request the location of the pixel. + + :param x: X location of the 4-pixels + :param y: Y location of the 4-pixels + :param edge: Enumeration identifying the 2-pixels to process + :param level: The requested level + :param result_coord: Resulting location of the pixel + """ + cdef: + int dx1, dy1, index1 + int dx2, dy2, index2 + cnumpy.float64_t fx, fy, ff, weight1, weight2 + # Use these to look up the relative positions of the pixels to interpolate + dx1, dy1 = EDGE_TO_POINT[edge][0], EDGE_TO_POINT[edge][1] + dx2, dy2 = EDGE_TO_POINT[edge + 1][0], EDGE_TO_POINT[edge + 1][1] + # Define "strength" of each corner of the cube that we need + index1 = (y + dy1) * self._dim_x + x + dx1 + index2 = (y + dy2) * self._dim_x + x + dx2 + weight1 = EPSILON + fabs(self._image_ptr[index1] - level) + weight2 = EPSILON + fabs(self._image_ptr[index2] - level) + # Apply a kind of center-of-mass method + if edge == 0: + result_coord.x = x + (weight1 > weight2) + result_coord.y = y + elif edge == 1: + result_coord.x = x + 1 + result_coord.y = y + (weight1 > weight2) + elif edge == 2: + result_coord.x = x + (weight1 < weight2) + result_coord.y = y + 1 + elif edge == 3: + result_coord.x = x + result_coord.y = y + (weight1 < weight2) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef point_index_t create_point_index(self, int yx, cnumpy.uint8_t edge) nogil: + """ + Create a unique identifier for a point of a polygon based on the + pattern location and the edge. + + A index can be shared by different pixel coordinates. For example, + the index of the tuple (x=0, y=0, edge=2) is equal to the one of + (x=1, y=0, edge=0). + + :param yx: Index of the location of the pattern in the image + :param edge: Enumeration identifying the edge of the pixel + :return: An index + """ + if edge == 2: + yx += self._dim_x + edge = 0 + elif edge == 1: + yx += 1 + elif edge == 3: + edge = 1 + + # Reserve the zero value + yx += 1 + + return edge + (yx << 1) + + +cdef class _MarchingSquaresContours(_MarchingSquaresAlgorithm): + """Implementation of the marching squares algorithm to find iso contours. + """ + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void insert_pattern(self, + TileContext *context, + int x, + int y, + int pattern, + cnumpy.float64_t level) nogil: + cdef: + int segment + for segment in range(CELL_TO_EDGE[pattern][0]): + begin_edge = CELL_TO_EDGE[pattern][1 + segment * 2 + 0] + end_edge = CELL_TO_EDGE[pattern][1 + segment * 2 + 1] + self.insert_segment(context, x, y, begin_edge, end_edge, level) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void insert_segment(self, TileContext *context, + int x, int y, + cnumpy.uint8_t begin_edge, + cnumpy.uint8_t end_edge, + cnumpy.float64_t level) nogil: + cdef: + int i, yx + point_t point + point_index_t begin, end + PolygonDescription *description + PolygonDescription *description_begin + PolygonDescription *description_end + map[point_index_t, PolygonDescription*].iterator it_begin + map[point_index_t, PolygonDescription*].iterator it_end + + yx = self._dim_x * y + x + begin = self.create_point_index(yx, begin_edge) + end = self.create_point_index(yx, end_edge) + + it_begin = context.polygons.find(begin) + it_end = context.polygons.find(end) + if it_begin == context.polygons.end() and it_end == context.polygons.end(): + # insert a new polygon + description = new PolygonDescription() + description.begin = begin + description.end = end + self.compute_point(x, y, begin_edge, level, &point) + description.points.push_back(point) + self.compute_point(x, y, end_edge, level, &point) + description.points.push_back(point) + context.polygons[begin] = description + context.polygons[end] = description + elif it_begin == context.polygons.end(): + # insert the beginning point to an existing polygon + self.compute_point(x, y, begin_edge, level, &point) + description = dereference(it_end).second + context.polygons.erase(it_end) + if end == description.begin: + # insert at start + description.points.push_front(point) + description.begin = begin + context.polygons[begin] = description + else: + # insert on tail + description.points.push_back(point) + description.end = begin + context.polygons[begin] = description + elif it_end == context.polygons.end(): + # insert the ending point to an existing polygon + self.compute_point(x, y, end_edge, level, &point) + description = dereference(it_begin).second + context.polygons.erase(it_begin) + if begin == description.begin: + # insert at start + description.points.push_front(point) + description.begin = end + context.polygons[end] = description + else: + # insert on tail + description.points.push_back(point) + description.end = end + context.polygons[end] = description + else: + # merge 2 polygons using this segment + description_begin = dereference(it_begin).second + description_end = dereference(it_end).second + if description_begin == description_end: + # The segment closes a polygon + # FIXME: this intermediate assign is not needed + point = description_begin.points.front() + description_begin.points.push_back(point) + context.polygons.erase(begin) + context.polygons.erase(end) + context.final_polygons.push_back(description_begin) + else: + if ((begin == description_begin.begin or end == description_begin.begin) and + (begin == description_end.end or end == description_end.end)): + # worst case, let's make it faster + description = description_end + description_end = description_begin + description_begin = description + + # FIXME: We can recycle a description instead of creating a new one + description = new PolygonDescription() + + # Make sure the last element of the list is the one to connect + if description_begin.begin == begin or description_begin.begin == end: + # O(n) + description_begin.points.reverse() + description.begin = description_begin.end + else: + description.begin = description_begin.begin + + # O(1) + description.points.splice(description.points.end(), description_begin.points) + + # Make sure the first element of the list is the one to connect + if description_end.end == begin or description_end.end == end: + description_end.points.reverse() + description.end = description_end.begin + else: + description.end = description_end.end + + description.points.splice(description.points.end(), description_end.points) + + context.polygons.erase(it_begin) + context.polygons.erase(it_end) + context.polygons[description.begin] = description + context.polygons[description.end] = description + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void merge_context(self, TileContext *context, TileContext *other) nogil: + cdef: + map[point_index_t, PolygonDescription*].iterator it_begin + map[point_index_t, PolygonDescription*].iterator it_end + map[point_index_t, PolygonDescription*].iterator it + PolygonDescription *description_other + PolygonDescription *description + PolygonDescription *description2 + point_index_t point_index + vector[PolygonDescription*] mergeable_polygons + size_t i + + # merge final polygons + context.final_polygons.splice(context.final_polygons.end(), other.final_polygons) + + # mergeable_polygons.reserve(other.polygons.size() / 2) + it = other.polygons.begin() + while it != other.polygons.end(): + point_index = dereference(it).first + description_other = dereference(it).second + if description_other.begin == point_index: + mergeable_polygons.push_back(description_other) + preincrement(it) + + for i in range(mergeable_polygons.size()): + description_other = mergeable_polygons[i] + it_begin = context.polygons.find(description_other.begin) + it_end = context.polygons.find(description_other.end) + + if it_begin == context.polygons.end() and it_end == context.polygons.end(): + # It's a new polygon + context.polygons[description_other.begin] = description_other + context.polygons[description_other.end] = description_other + elif it_end == context.polygons.end(): + # The head of the polygon have to be merged + description = dereference(it_begin).second + context.polygons.erase(description.begin) + context.polygons.erase(description.end) + if description.begin == description_other.begin: + description.begin = description.end + description.points.reverse() + description.end = description_other.end + # remove the dup element + description_other.points.pop_front() + description.points.splice(description.points.end(), description_other.points) + context.polygons[description.begin] = description + context.polygons[description.end] = description + del description_other + elif it_begin == context.polygons.end(): + # The tail of the polygon have to be merged + description = dereference(it_end).second + context.polygons.erase(description.begin) + context.polygons.erase(description.end) + if description.begin == description_other.end: + description.begin = description.end + description.points.reverse() + description.end = description_other.begin + description_other.points.reverse() + # remove the dup element + description_other.points.pop_front() + description.points.splice(description.points.end(), description_other.points) + context.polygons[description.begin] = description + context.polygons[description.end] = description + del description_other + else: + # Both sides have to be merged + description = dereference(it_begin).second + description2 = dereference(it_end).second + if description == description2: + # It became a closed polygon + context.polygons.erase(description.begin) + context.polygons.erase(description.end) + if description.begin == description_other.begin: + description.begin = description.end + description.points.reverse() + description.end = description_other.end + # remove the dup element + description_other.points.pop_front() + description.points.splice(description.points.end(), description_other.points) + context.final_polygons.push_back(description) + del description_other + else: + context.polygons.erase(description.begin) + context.polygons.erase(description.end) + context.polygons.erase(description2.begin) + context.polygons.erase(description2.end) + if description.begin == description_other.begin: + description.begin = description.end + description.points.reverse() + if description2.end == description_other.end: + description.end = description2.begin + description2.points.reverse() + else: + description.end = description2.end + description_other.points.pop_front() + description2.points.pop_front() + description.points.splice(description.points.end(), description_other.points) + description.points.splice(description.points.end(), description2.points) + context.polygons[description.begin] = description + context.polygons[description.end] = description + del description_other + del description2 + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef extract_polygons(self): + cdef: + size_t i + int i_pixel + cnumpy.uint8_t index + map[point_index_t, PolygonDescription*].iterator it + vector[PolygonDescription*] descriptions + clist[point_t].iterator it_points + PolygonDescription *description + cnumpy.float32_t[:, ::1] polygon + + if self._final_context == NULL: + return [] + + # move all the polygons in a final structure + with nogil: + it = self._final_context.polygons.begin() + while it != self._final_context.polygons.end(): + description = dereference(it).second + if dereference(it).first == description.begin: + # polygones are stored 2 times + # only use one + descriptions.push_back(description) + preincrement(it) + self._final_context.polygons.clear() + + descriptions.insert(descriptions.end(), + self._final_context.final_polygons.begin(), + self._final_context.final_polygons.end()) + self._final_context.final_polygons.clear() + + del self._final_context + self._final_context = NULL + + # create result and clean up allocated memory + polygons = [] + for i in range(descriptions.size()): + description = descriptions[i] + polygon = numpy.empty((description.points.size(), 2), dtype=numpy.float32) + it_points = description.points.begin() + i_pixel = 0 + while it_points != description.points.end(): + polygon[i_pixel, 0] = dereference(it_points).y + polygon[i_pixel, 1] = dereference(it_points).x + i_pixel += 1 + preincrement(it_points) + polygons.append(numpy.asarray(polygon)) + del description + + return polygons + + +cdef class _MarchingSquaresPixels(_MarchingSquaresAlgorithm): + """Implementation of the marching squares algorithm to find pixels of the + image containing points of the polygons of the iso contours. + """ + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void insert_pattern(self, + TileContext *context, + int x, + int y, + int pattern, + cnumpy.float64_t level) nogil: + cdef: + int segment + for segment in range(CELL_TO_EDGE[pattern][0]): + begin_edge = CELL_TO_EDGE[pattern][1 + segment * 2 + 0] + end_edge = CELL_TO_EDGE[pattern][1 + segment * 2 + 1] + self.insert_segment(context, x, y, begin_edge, end_edge, level) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void insert_segment(self, TileContext *context, + int x, int y, + cnumpy.uint8_t begin_edge, + cnumpy.uint8_t end_edge, + cnumpy.float64_t level) nogil: + cdef: + coord_t coord + self.compute_ipoint(x, y, begin_edge, level, &coord) + context.pixels.insert(coord) + self.compute_ipoint(x, y, end_edge, level, &coord) + context.pixels.insert(coord) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void after_marching_squares(self, TileContext *context) nogil: + cdef: + coord_t coord + cset[coord_t].iterator it_coord + cset[coord_t].iterator it_coord_erase + pass + + it_coord = context.pixels.begin() + while it_coord != context.pixels.end(): + coord = dereference(it_coord) + if (coord.x > context.pos_x and coord.x < context.pos_x + context.dim_x - 1 and + coord.y > context.pos_y and coord.y < context.pos_y + context.dim_y - 1): + it_coord_erase = it_coord + preincrement(it_coord) + context.pixels.erase(it_coord_erase) + context.final_pixels.push_back(coord) + else: + preincrement(it_coord) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void merge_context(self, TileContext *context, TileContext *other) nogil: + cdef: + cset[coord_t].iterator it_coord + + # merge final pixels + context.final_pixels.splice(context.final_pixels.end(), other.final_pixels) + + # merge final pixels + # NOTE: This is not declared in Cython + # context.final_pixels.insert(other.final_pixels.begin(), other.final_pixels.end()) + it_coord = other.pixels.begin() + while it_coord != other.pixels.end(): + context.pixels.insert(dereference(it_coord)) + preincrement(it_coord) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef extract_pixels(self): + cdef: + int i, x, y + point_index_t index + cset[coord_t].iterator it + clist[coord_t].iterator it_coord + coord_t coord + cnumpy.int32_t[:, ::1] pixels + + if self._final_context == NULL: + return numpy.empty((0, 2), dtype=numpy.int32) + + # create result + it = self._final_context.pixels.begin() + while it != self._final_context.pixels.end(): + coord = dereference(it) + self._final_context.final_pixels.push_back(coord) + preincrement(it) + + pixels = numpy.empty((self._final_context.final_pixels.size(), 2), dtype=numpy.int32) + i = 0 + + it_coord = self._final_context.final_pixels.begin() + while it_coord != self._final_context.final_pixels.end(): + coord = dereference(it_coord) + pixels[i, 0] = coord.y + pixels[i, 1] = coord.x + i += 1 + preincrement(it_coord) + + del self._final_context + self._final_context = NULL + + return numpy.asarray(pixels) + + +cdef class MarchingSquaresMergeImpl(object): + """ + Marching squares implementation based on a merge of segements and polygons. + + The main logic is based on the common marching squares algorithms. + Segments of the iso-valued contours are identified using a pattern based + on blocks of 2*2 pixels. The image is read sequencially and when a segment + is identified it is inserted at a right place is a set of valid polygons. + This process can grow up polygons on bounds, or merge polygons together. + + The algorithm can take care of a mask. If a pixel is invalidated by a + non-zero value of the mask at it's location, the computation of the pattern + cancelled and no segements are generated. + + This implementation based on merge allow to use divide and conquer + implementation in multi process using OpenMP.The image is subdivised into + many tiles, each one is processed independantly. The result is finally + reduced by consecutives polygon merges. + + The OpenMP group size can also by used to skip part of the image using + pre-computed informations. `use_minmax_cache` can enable the computation of + minimum and maximum pixel levels available on each tile groups. It was + designed to improve the efficiency of the extraction of many contour levels + from the same gradient image. + + Finally the implementation provides an implementation to reach polygons + (:meth:`find_contours`) or pixels (:meth:`find_pixels`) from the iso-valued + data. + + .. code-block:: python + + # Example using a mask + shape = 100, 100 + image = numpy.random.random(shape) + mask = numpy.random.random(shape) < 0.01 + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(level=0.5) + for polygon in polygons: + print(polygon) + + .. code-block:: python + + # Example using multi requests + shape = 1000, 1000 + image = numpy.random.random(shape) + ms = MarchingSquaresMergeImpl(image) + levels = numpy.arange(0, 1, 0.05) + for level in levels: + polygons = ms.find_contours(level=level) + + .. code-block:: python + + # Efficient cache using multi requests + shape = 1000, 1000 + image = numpy.arange(shape[0] * shape[1]) / (shape[0] * shape[1]) + image.shape = shape + ms = MarchingSquaresMergeImpl(image, use_minmax_cache=True) + levels = numpy.arange(0, 1, 0.05) + for level in levels: + polygons = ms.find_contours(level=level) + + :param numpy.ndarray image: Image to process. + If the image is not a continuous array of native float 32bits, the data + will be first normalized. This can reduce efficiency. + :param numpy.ndarray mask: An optional mask (a non-zero value invalidate + the pixels of the image) + If the image is not a continuous array of signed integer 8bits, the + data will be first normalized. This can reduce efficiency. + :param int group_size: Specify the size of the tile to split the + computation with OpenMP. It is also used as tile size to compute the + min/max cache + :param bool use_minmax_cache: If true the min/max cache is enabled. + """ + + cdef cnumpy.float32_t[:, ::1] _image + cdef cnumpy.int8_t[:, ::1] _mask + + cdef cnumpy.float32_t *_image_ptr + cdef cnumpy.int8_t *_mask_ptr + cdef int _dim_x + cdef int _dim_y + cdef int _group_size + cdef bool _use_minmax_cache + + cdef cnumpy.float32_t *_min_cache + cdef cnumpy.float32_t *_max_cache + + cdef _MarchingSquaresContours _contours_algo + cdef _MarchingSquaresPixels _pixels_algo + + def __init__(self, + image, mask=None, + group_size=256, + use_minmax_cache=False): + if not isinstance(image, numpy.ndarray) or len(image.shape) != 2: + raise ValueError("Only 2D arrays are supported.") + if image.shape[0] < 2 or image.shape[1] < 2: + raise ValueError("Input array must be at least 2x2.") + # Force contiguous native array + self._image = numpy.ascontiguousarray(image, dtype='=f4') + self._image_ptr = &self._image[0][0] + if mask is not None: + if not isinstance(mask, numpy.ndarray): + raise ValueError("Only 2D arrays are supported.") + if image.shape != mask.shape: + raise ValueError("Mask size and image size must be the same.") + # Force contiguous native array + self._mask = numpy.ascontiguousarray(mask, dtype='=i1') + self._mask_ptr = &self._mask[0][0] + else: + self._mask = None + self._mask_ptr = NULL + self._group_size = group_size + self._use_minmax_cache = use_minmax_cache + self._min_cache = NULL + self._max_cache = NULL + with nogil: + self._dim_y = self._image.shape[0] + self._dim_x = self._image.shape[1] + self._contours_algo = None + self._pixels_algo = None + + def __dealloc__(self): + if self._min_cache != NULL: + libc.stdlib.free(self._min_cache) + if self._max_cache != NULL: + libc.stdlib.free(self._max_cache) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void _compute_minmax_on_block(self, int block_x, int block_y, int block_index) nogil: + """ + Initialize the minmax cache. + + The cache is computed for each tiles of the image. It reuses the OpenMP + group size for the size of the tile, which allow to skip a full OpenMP + context in case the requested level do not match the cache. + + The minmax is compuded with an overlap of 1 pixel, in order to match + the marching squares algorithm. + + The mask is taking into accound. As result if a tile is fully masked, + the minmax cache result for this tile will have infinit values. + + :param block_x: X location of tile in block unit + :param block_y: Y location of tile in block unit + :param block_index: Index of the tile in the minmax cache structure + """ + cdef: + int x, y + int pos_x, end_x, pos_y, end_y + cnumpy.float32_t minimum, maximum, value + cnumpy.float32_t *image_ptr + cnumpy.int8_t *mask_ptr + + pos_x = block_x * self._group_size + end_x = pos_x + self._group_size + 1 + if end_x > self._dim_x: + end_x = self._dim_x + pos_y = block_y * self._group_size + end_y = pos_y + self._group_size + 1 + if end_y > self._dim_y: + end_y = self._dim_y + + image_ptr = self._image_ptr + (pos_y * self._dim_x + pos_x) + if self._mask_ptr != NULL: + mask_ptr = self._mask_ptr + (pos_y * self._dim_x + pos_x) + else: + mask_ptr = NULL + minimum = INFINITY + maximum = -INFINITY + + for y in range(pos_y, end_y): + for x in range(pos_x, end_x): + if mask_ptr != NULL: + if mask_ptr[0] != 0: + image_ptr += 1 + mask_ptr += 1 + continue + value = image_ptr[0] + if value < minimum: + minimum = value + if value > maximum: + maximum = value + image_ptr += 1 + if mask_ptr != NULL: + mask_ptr += 1 + image_ptr += self._dim_x + pos_x - end_x + if mask_ptr != NULL: + mask_ptr += self._dim_x + pos_x - end_x + + self._min_cache[block_index] = minimum + self._max_cache[block_index] = maximum + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + cdef void _create_minmax_cache(self) nogil: + """ + Create and initialize minmax cache. + """ + cdef: + int icontext, context_x, context_y + int context_dim_x, context_dim_y, context_size + + context_dim_x = self._dim_x // self._group_size + (self._dim_x % self._group_size > 0) + context_dim_y = self._dim_y // self._group_size + (self._dim_y % self._group_size > 0) + context_size = context_dim_x * context_dim_y + + self._min_cache = <cnumpy.float32_t *>libc.stdlib.malloc(context_size * sizeof(cnumpy.float32_t)) + self._max_cache = <cnumpy.float32_t *>libc.stdlib.malloc(context_size * sizeof(cnumpy.float32_t)) + + for icontext in prange(context_size, nogil=True): + context_x = icontext % context_dim_x + context_y = icontext // context_dim_x + self._compute_minmax_on_block(context_x, context_y, icontext) + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + def find_pixels(self, level): + """ + Compute the pixels from the image over the requested iso contours + at this `level`. Pixels are those over the bound of the segments. + + :param float level: Level of the requested iso contours. + :returns: An array of y-x coordinates. + :rtype: numpy.ndarray + """ + if self._use_minmax_cache and self._min_cache == NULL: + self._create_minmax_cache() + + if self._pixels_algo is None: + algo = _MarchingSquaresPixels() + algo._image_ptr = self._image_ptr + algo._mask_ptr = self._mask_ptr + algo._dim_x = self._dim_x + algo._dim_y = self._dim_y + algo._group_size = self._group_size + algo._use_minmax_cache = self._use_minmax_cache + algo._force_sequencial_reduction = COMPILED_WITH_OPENMP == 0 + if self._use_minmax_cache: + algo._min_cache = self._min_cache + algo._max_cache = self._max_cache + self._pixels_algo = algo + else: + algo = self._pixels_algo + + algo.marching_squares(level) + pixels = algo.extract_pixels() + return pixels + + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.cdivision(True) + def find_contours(self, level=None): + """ + Compute the list of polygons of the iso contours at this `level`. + + :param float level: Level of the requested iso contours. + :returns: A list of array containg y-x coordinates of points + :rtype: List[numpy.ndarray] + """ + if self._use_minmax_cache and self._min_cache == NULL: + self._create_minmax_cache() + + if self._contours_algo is None: + algo = _MarchingSquaresContours() + algo._image_ptr = self._image_ptr + algo._mask_ptr = self._mask_ptr + algo._dim_x = self._dim_x + algo._dim_y = self._dim_y + algo._group_size = self._group_size + algo._use_minmax_cache = self._use_minmax_cache + algo._force_sequencial_reduction = COMPILED_WITH_OPENMP == 0 + if self._use_minmax_cache: + algo._min_cache = self._min_cache + algo._max_cache = self._max_cache + self._contours_algo = algo + else: + algo = self._contours_algo + + algo.marching_squares(level) + polygons = algo.extract_polygons() + return polygons diff --git a/src/silx/image/marchingsquares/_skimage.py b/src/silx/image/marchingsquares/_skimage.py new file mode 100644 index 0000000..d49eeb0 --- /dev/null +++ b/src/silx/image/marchingsquares/_skimage.py @@ -0,0 +1,139 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "05/04/2018" + + +import numpy +import skimage.measure + + +class MarchingSquaresSciKitImage(object): + """Reference implementation of a marching squares using sci-kit image. + + It uses `skimage.measure.find_contours` to find iso contours taking care of + an optional mask. As result the computation is not accurate but can be used + as reference for benchmark or for testing the API without compiling the + cython part of silx. + + :param numpy.ndarray image: 2d-image containing the values + :param numpy.ndarray mask: Optional 2d-image containing mask to cancel + mask on part of the image. A `0` means the pixel at this location is + valid, else the pixel from the image will not be used. + """ + + def __init__(self, image, mask=None): + self._image = image + self._mask = mask + + _deltas = [(0.0, 0.0), (0.99, 0.0), (0.0, 0.99), (0.99, 0.99)] + + def _flag_coord_over_mask(self, coord): + """Flag coord over the mask as NaN""" + for dx, dy in self._deltas: + if self._mask[int(coord[0] + dx), int(coord[1] + dy)] != 0: + return float("nan"), float("nan") + return coord + + def find_pixels(self, level): + """ + Compute the pixels from the image over the requested iso contours + at this `level`. + + This implementation have to use `skimage.measure.find_contours` then + it is not accurate nor efficient. + + :param float level: Level of the requested iso contours. + :returns: An array of y-x coordinates. + :rtype: numpy.ndarray + """ + polylines = skimage.measure.find_contours(self._image, level=level) + size = 0 + for polyline in polylines: + size += len(polyline) + result = numpy.empty((size, 2), dtype=numpy.int32) + size = 0 + delta = numpy.array([0.5, 0.5]) + for polyline in polylines: + if len(polyline) == 0: + continue + integer_polyline = numpy.floor(polyline + delta) + result[size:size + len(polyline)] = integer_polyline + size += len(polyline) + + if len(result) == 0: + return result + + if self._mask is not None: + # filter out pixels over the mask + x_dim = self._image.shape[1] + indexes = result[:, 0] * x_dim + result[:, 1] + indexes = indexes.ravel() + mask = self._mask.ravel() + indexes = numpy.unique(indexes) + indexes = indexes[mask[indexes] == 0] + pixels = numpy.concatenate((indexes // x_dim, indexes % x_dim)) + pixels.shape = 2, -1 + pixels = pixels.T + result = pixels + else: + # Note: Cound be done using a single line numpy.unique(result, axis=0) + # But here it supports Debian 8 + x_dim = self._image.shape[1] + indexes = result[:, 0] * x_dim + result[:, 1] + indexes = indexes.ravel() + indexes = numpy.unique(indexes) + pixels = numpy.concatenate((indexes // x_dim, indexes % x_dim)) + pixels.shape = 2, -1 + pixels = pixels.T + result = pixels + return result + + def find_contours(self, level): + """ + Compute the list of polygons of the iso contours at this `level`. + + If no mask is involved, the result is the same as + `skimage.measure.find_contours`. + + If the result have to be filtered with a mask, the result is not + accurate nor efficient. Polygons are not splited, but only points are + filtered out using NaN coordinates. This could create artefacts. + + :param float level: Level of the requested iso contours. + :returns: A list of array containg y-x coordinates of points + :rtype: List[numpy.ndarray] + """ + polylines = skimage.measure.find_contours(self._image, level=level) + if self._mask is None: + return polylines + result = [] + for polyline in polylines: + polyline = map(self._flag_coord_over_mask, polyline) + polyline = list(polyline) + polyline = numpy.array(polyline) + result.append(polyline) + return result diff --git a/src/silx/image/marchingsquares/include/patterns.h b/src/silx/image/marchingsquares/include/patterns.h new file mode 100644 index 0000000..ff86cc1 --- /dev/null +++ b/src/silx/image/marchingsquares/include/patterns.h @@ -0,0 +1,89 @@ +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +/** This header provides static lookup table used by marching square + * algorithms. + */ + +#ifndef __MARCHINGSQUARE_PATTERNS_H__ +#define __MARCHINGSQUARE_PATTERNS_H__ + +/** + * Array containing pixel's coordinate linked together by an edge index. + */ +const unsigned char EDGE_TO_POINT[][2] = { + {0, 0}, + {1, 0}, + {1, 1}, + {0, 1}, + {0, 0} +}; + +/** + * Array of index containing 5 values: {nb, seg1b, seg2e, seg2b, seg2e} + * nb: number of segments (up to 2) + * seg1b: index of the start of the 1st edge + * seg1e: index of the end of the 1st edge + * seg2b: index of the start of the 2nd edge + * seg2e: index of the end of the 2nd edge + */ +const unsigned char CELL_TO_EDGE[][5] = { + {0, 0, 0, 0, 0}, // Case 0: 0000: nothing + {1, 0, 3, 0, 0}, // Case 1: 0001 + {1, 0, 1, 0, 0}, // Case 2: 0010 + {1, 1, 3, 0, 0}, // Case 3: 0011 + + {1, 1, 2, 0, 0}, // Case 4: 0100 + {2, 0, 1, 2, 3}, // Case 5: 0101 > ambiguous + {1, 0, 2, 0, 0}, // Case 6: 0110 + {1, 2, 3, 0, 0}, // Case 7: 0111 + + {1, 2, 3, 0, 0}, // Case 8: 1000 + {1, 0, 2, 0, 0}, // Case 9: 1001 + {2, 0, 3, 1, 2}, // Case 10: 1010 > ambiguous + {1, 1, 2, 0, 0}, // Case 11: 1011 + + {1, 1, 3, 0, 0}, // Case 12: 1100 + {1, 0, 1, 0, 0}, // Case 13: 1101 + {1, 0, 3, 0, 0}, // Case 14: 1110 + {0, 0, 0, 0, 0}, // Case 15: 1111 +}; + + +typedef struct coord_t { + short x; + short y; + + bool operator<(const coord_t& other) const { + if (y < other.y) { + return true; + } else if (y == other.y) { + return x < other.x; + } else { + return false; + } + } +} coord_t; + +#endif /*__MARCHINGSQUARE_PATTERNS_H__*/ diff --git a/src/silx/image/marchingsquares/setup.py b/src/silx/image/marchingsquares/setup.py new file mode 100644 index 0000000..95998ab --- /dev/null +++ b/src/silx/image/marchingsquares/setup.py @@ -0,0 +1,51 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "23/04/2018" + +import os +import numpy +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('marchingsquares', parent_package, top_path) + config.add_subpackage('test') + + silx_include = os.path.join(top_path, "src", "silx", "utils", "include") + config.add_extension('_mergeimpl', + sources=['_mergeimpl.pyx'], + include_dirs=[numpy.get_include(), silx_include], + language='c++', + extra_link_args=['-fopenmp'], + extra_compile_args=['-fopenmp']) + + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/src/silx/image/marchingsquares/test/__init__.py b/src/silx/image/marchingsquares/test/__init__.py new file mode 100644 index 0000000..776bb73 --- /dev/null +++ b/src/silx/image/marchingsquares/test/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# +# Project: silx +# https://github.com/silx-kit/silx +# +# Copyright (C) 2012-2016 European Synchrotron Radiation Facility, Grenoble, France +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. diff --git a/src/silx/image/marchingsquares/test/test_funcapi.py b/src/silx/image/marchingsquares/test/test_funcapi.py new file mode 100644 index 0000000..d1be584 --- /dev/null +++ b/src/silx/image/marchingsquares/test/test_funcapi.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Project: silx +# https://github.com/silx-kit/silx +# +# Copyright (C) 2012-2016 European Synchrotron Radiation Facility, Grenoble, France +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "17/04/2018" + +import unittest +import numpy +import silx.image.marchingsquares + + +class MockMarchingSquares(object): + + last = None + + def __init__(self, image, mask=None): + MockMarchingSquares.last = self + self.events = [] + self.events.append(("image", image)) + self.events.append(("mask", mask)) + + def find_pixels(self, level): + self.events.append(("find_pixels", level)) + return None + + def find_contours(self, level): + self.events.append(("find_contours", level)) + return None + + +class TestFunctionalApi(unittest.TestCase): + """Test that the default functional API is called using the right + parameters to the right location.""" + + def setUp(self): + self.old_impl = silx.image.marchingsquares.MarchingSquaresMergeImpl + silx.image.marchingsquares.MarchingSquaresMergeImpl = MockMarchingSquares + + def tearDown(self): + silx.image.marchingsquares.MarchingSquaresMergeImpl = self.old_impl + del self.old_impl + + def test_default_find_contours(self): + image = numpy.ones((2, 2), dtype=numpy.float32) + mask = numpy.zeros((2, 2), dtype=numpy.int32) + level = 2.5 + silx.image.marchingsquares.find_contours(image=image, level=level, mask=mask) + events = MockMarchingSquares.last.events + self.assertEqual(len(events), 3) + self.assertEqual(events[0][0], "image") + self.assertEqual(events[0][1][0, 0], 1) + self.assertEqual(events[1][0], "mask") + self.assertEqual(events[1][1][0, 0], 0) + self.assertEqual(events[2][0], "find_contours") + self.assertEqual(events[2][1], level) + + def test_default_find_pixels(self): + image = numpy.ones((2, 2), dtype=numpy.float32) + mask = numpy.zeros((2, 2), dtype=numpy.int32) + level = 3.5 + silx.image.marchingsquares.find_pixels(image=image, level=level, mask=mask) + events = MockMarchingSquares.last.events + self.assertEqual(len(events), 3) + self.assertEqual(events[0][0], "image") + self.assertEqual(events[0][1][0, 0], 1) + self.assertEqual(events[1][0], "mask") + self.assertEqual(events[1][1][0, 0], 0) + self.assertEqual(events[2][0], "find_pixels") + self.assertEqual(events[2][1], level) diff --git a/src/silx/image/marchingsquares/test/test_mergeimpl.py b/src/silx/image/marchingsquares/test/test_mergeimpl.py new file mode 100644 index 0000000..07b94b5 --- /dev/null +++ b/src/silx/image/marchingsquares/test/test_mergeimpl.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +# +# Project: silx +# https://github.com/silx-kit/silx +# +# Copyright (C) 2012-2016 European Synchrotron Radiation Facility, Grenoble, France +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "18/04/2018" + +import unittest +import numpy +from .._mergeimpl import MarchingSquaresMergeImpl + + +class TestMergeImplApi(unittest.TestCase): + + def test_image_not_an_array(self): + bad_image = 1 + self.assertRaises(ValueError, MarchingSquaresMergeImpl, bad_image) + + def test_image_bad_dim(self): + bad_image = numpy.array([[[1.0]]]) + self.assertRaises(ValueError, MarchingSquaresMergeImpl, bad_image) + + def test_image_not_big_enough(self): + bad_image = numpy.array([[1.0, 1.0, 1.0, 1.0]]) + self.assertRaises(ValueError, MarchingSquaresMergeImpl, bad_image) + + def test_mask_not_an_array(self): + image = numpy.array([[1.0, 1.0], [1.0, 1.0]]) + bad_mask = 1 + self.assertRaises(ValueError, MarchingSquaresMergeImpl, image, bad_mask) + + def test_mask_not_match(self): + image = numpy.array([[1.0, 1.0], [1.0, 1.0]]) + bad_mask = numpy.array([[1.0, 1.0]]) + self.assertRaises(ValueError, MarchingSquaresMergeImpl, image, bad_mask) + + def test_ok_anyway_bad_type(self): + image = numpy.array([[1.0, 1.0], [1.0, 1.0]], dtype=numpy.int32) + mask = numpy.array([[1.0, 1.0], [1.0, 1.0]], dtype=numpy.float32) + MarchingSquaresMergeImpl(image, mask) + + def test_find_contours_result(self): + image = numpy.zeros((2, 2)) + image[0, 0] = 1 + ms = MarchingSquaresMergeImpl(image) + polygons = ms.find_contours(0.5) + self.assertIsInstance(polygons, list) + self.assertTrue(len(polygons), 1) + self.assertIsInstance(polygons[0], numpy.ndarray) + self.assertEqual(polygons[0].shape[1], 2) + self.assertEqual(polygons[0].dtype.kind, "f") + + def test_find_pixels_result(self): + image = numpy.zeros((2, 2)) + image[0, 0] = 1 + ms = MarchingSquaresMergeImpl(image) + pixels = ms.find_pixels(0.5) + self.assertIsInstance(pixels, numpy.ndarray) + self.assertEqual(pixels.shape[1], 2) + self.assertEqual(pixels.dtype.kind, "i") + + def test_find_contours_empty_result(self): + image = numpy.zeros((2, 2)) + ms = MarchingSquaresMergeImpl(image) + polygons = ms.find_contours(0.5) + self.assertIsInstance(polygons, list) + self.assertEqual(len(polygons), 0) + + def test_find_pixels_empty_result(self): + image = numpy.zeros((2, 2)) + ms = MarchingSquaresMergeImpl(image) + pixels = ms.find_pixels(0.5) + self.assertIsInstance(pixels, numpy.ndarray) + self.assertEqual(pixels.shape[1], 2) + self.assertEqual(pixels.shape[0], 0) + self.assertEqual(pixels.dtype.kind, "i") + + def test_find_contours_yx_result(self): + image = numpy.zeros((2, 2)) + image[1, 0] = 1 + ms = MarchingSquaresMergeImpl(image) + polygons = ms.find_contours(0.5) + polygon = polygons[0] + self.assertTrue((polygon == (0.5, 0)).any()) + self.assertTrue((polygon == (1, 0.5)).any()) + + def test_find_pixels_yx_result(self): + image = numpy.zeros((2, 2)) + image[1, 0] = 1 + ms = MarchingSquaresMergeImpl(image) + pixels = ms.find_pixels(0.5) + self.assertTrue((pixels == (1, 0)).any()) + + +class TestMergeImplContours(unittest.TestCase): + + def test_merge_segments(self): + image = numpy.zeros((4, 4)) + image[(2, 3), :] = 1 + ms = MarchingSquaresMergeImpl(image) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 1) + + def test_merge_segments_2(self): + image = numpy.zeros((4, 4)) + image[(2, 3), :] = 1 + image[2, 2] = 0 + ms = MarchingSquaresMergeImpl(image) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 1) + + def test_merge_tiles(self): + image = numpy.zeros((4, 4)) + image[(2, 3), :] = 1 + ms = MarchingSquaresMergeImpl(image, group_size=2) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 1) + + def test_fully_masked(self): + image = numpy.zeros((5, 5)) + image[(2, 3), :] = 1 + mask = numpy.ones((5, 5)) + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 0) + + def test_fully_masked_minmax(self): + """This invalidates all the tiles. The route is not the same.""" + image = numpy.zeros((5, 5)) + image[(2, 3), :] = 1 + mask = numpy.ones((5, 5)) + ms = MarchingSquaresMergeImpl(image, mask, group_size=2, use_minmax_cache=True) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 0) + + def test_masked_segments(self): + image = numpy.zeros((5, 5)) + image[(2, 3, 4), :] = 1 + mask = numpy.zeros((5, 5)) + mask[:, 2] = 1 + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 2) + + def test_closed_polygon(self): + image = numpy.zeros((5, 5)) + image[2, 2] = 1 + image[1, 2] = 1 + image[3, 2] = 1 + image[2, 1] = 1 + image[2, 3] = 1 + mask = None + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(0.9) + self.assertEqual(len(polygons), 1) + self.assertEqual(list(polygons[0][0]), list(polygons[0][-1])) + + def test_closed_polygon_between_tiles(self): + image = numpy.zeros((5, 5)) + image[2, 2] = 1 + image[1, 2] = 1 + image[3, 2] = 1 + image[2, 1] = 1 + image[2, 3] = 1 + mask = None + ms = MarchingSquaresMergeImpl(image, mask, group_size=2) + polygons = ms.find_contours(0.9) + self.assertEqual(len(polygons), 1) + self.assertEqual(list(polygons[0][0]), list(polygons[0][-1])) + + def test_open_polygon(self): + image = numpy.zeros((5, 5)) + image[2, 2] = 1 + image[1, 2] = 1 + image[3, 2] = 1 + image[2, 1] = 1 + image[2, 3] = 1 + mask = numpy.zeros((5, 5)) + mask[1, 1] = 1 + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(0.9) + self.assertEqual(len(polygons), 1) + self.assertNotEqual(list(polygons[0][0]), list(polygons[0][-1])) + + def test_ambiguous_pattern(self): + image = numpy.zeros((6, 8)) + image[(3, 4), :] = 1 + image[:, (0, -1)] = 0 + image[3, 3] = -0.001 + image[4, 4] = 0.0 + mask = None + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 2) + + def test_ambiguous_pattern_2(self): + image = numpy.zeros((6, 8)) + image[(3, 4), :] = 1 + image[:, (0, -1)] = 0 + image[3, 3] = +0.001 + image[4, 4] = 0.0 + mask = None + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 1) + + def count_closed_polygons(self, polygons): + closed = 0 + for polygon in polygons: + if list(polygon[0]) == list(polygon[-1]): + closed += 1 + return closed + + def test_image(self): + # example from skimage + x, y = numpy.ogrid[-numpy.pi:numpy.pi:100j, -numpy.pi:numpy.pi:100j] + image = numpy.sin(numpy.exp((numpy.sin(x)**3 + numpy.cos(y)**2))) + mask = None + ms = MarchingSquaresMergeImpl(image, mask) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 11) + self.assertEqual(self.count_closed_polygons(polygons), 3) + + def test_image_tiled(self): + # example from skimage + x, y = numpy.ogrid[-numpy.pi:numpy.pi:100j, -numpy.pi:numpy.pi:100j] + image = numpy.sin(numpy.exp((numpy.sin(x)**3 + numpy.cos(y)**2))) + mask = None + ms = MarchingSquaresMergeImpl(image, mask, group_size=50) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 11) + self.assertEqual(self.count_closed_polygons(polygons), 3) + + def test_image_tiled_minmax(self): + # example from skimage + x, y = numpy.ogrid[-numpy.pi:numpy.pi:100j, -numpy.pi:numpy.pi:100j] + image = numpy.sin(numpy.exp((numpy.sin(x)**3 + numpy.cos(y)**2))) + mask = None + ms = MarchingSquaresMergeImpl(image, mask, group_size=50, use_minmax_cache=True) + polygons = ms.find_contours(0.5) + self.assertEqual(len(polygons), 11) + self.assertEqual(self.count_closed_polygons(polygons), 3) |