1# -*- coding: utf-8 -*-
2"""
3Image[] and image related functions.
4
5Note that you (currently) need scikit-image installed in order for this module to work.
6"""
7from mathics.version import __version__  # noqa used in loading to check consistency.
8from mathics.builtin.base import Builtin, AtomBuiltin, Test, BoxConstruct, String
9from mathics.core.expression import (
10    Atom,
11    Expression,
12    Integer,
13    Integer0,
14    Integer1,
15    Rational,
16    Real,
17    MachineReal,
18    Symbol,
19    SymbolNull,
20    SymbolList,
21    SymbolRule,
22    from_python,
23)
24from mathics.builtin.drawing.colors import (
25    convert as convert_color,
26    colorspaces as known_colorspaces,
27)
28
29import base64
30import functools
31import itertools
32import math
33from collections import defaultdict
34
35_image_requires = ("numpy", "PIL")
36
37_skimage_requires = _image_requires + ("skimage", "scipy", "matplotlib", "networkx")
38
39try:
40    import warnings
41
42    import PIL
43    import PIL.ImageEnhance
44    import PIL.ImageOps
45    import PIL.ImageFilter
46    from PIL.ExifTags import TAGS as ExifTags
47
48    import numpy
49
50    _enabled = True
51except ImportError:
52    _enabled = False
53
54from io import BytesIO
55
56
57class _ImageBuiltin(Builtin):
58    requires = _image_requires
59
60
61class _ImageTest(Test):
62    requires = _image_requires
63
64
65class _SkimageBuiltin(_ImageBuiltin):
66    requires = _skimage_requires
67
68
69# helpers
70
71
72def pixels_as_float(pixels):
73    dtype = pixels.dtype
74    if dtype in (numpy.float32, numpy.float64):
75        return pixels
76    elif dtype == numpy.uint8:
77        return pixels.astype(numpy.float32) / 255.0
78    elif dtype == numpy.uint16:
79        return pixels.astype(numpy.float32) / 65535.0
80    elif dtype == numpy.bool:
81        return pixels.astype(numpy.float32)
82    else:
83        raise NotImplementedError
84
85
86def pixels_as_ubyte(pixels):
87    dtype = pixels.dtype
88    if dtype in (numpy.float32, numpy.float64):
89        pixels = numpy.maximum(numpy.minimum(pixels, 1.0), 0.0)
90        return (pixels * 255.0).astype(numpy.uint8)
91    elif dtype == numpy.uint8:
92        return pixels
93    elif dtype == numpy.uint16:
94        return (pixels / 256).astype(numpy.uint8)
95    elif dtype == numpy.bool:
96        return pixels.astype(numpy.uint8) * 255
97    else:
98        raise NotImplementedError
99
100
101def pixels_as_uint(pixels):
102    dtype = pixels.dtype
103    if dtype in (numpy.float32, numpy.float64):
104        pixels = numpy.maximum(numpy.minimum(pixels, 1.0), 0.0)
105        return (pixels * 65535.0).astype(numpy.uint16)
106    elif dtype == numpy.uint8:
107        return pixels.astype(numpy.uint16) * 256
108    elif dtype == numpy.uint16:
109        return pixels
110    elif dtype == numpy.bool:
111        return pixels.astype(numpy.uint8) * 65535
112    else:
113        raise NotImplementedError
114
115
116def matrix_to_numpy(a):
117    def matrix():
118        for y in a.leaves:
119            yield [x.round_to_float() for x in y.leaves]
120
121    return numpy.array(list(matrix()))
122
123
124def numpy_to_matrix(pixels):
125    channels = pixels.shape[2]
126    if channels == 1:
127        return pixels[:, :, 0].tolist()
128    else:
129        return pixels.tolist()
130
131
132def numpy_flip(pixels, axis):
133    f = (numpy.flipud, numpy.fliplr)[axis]
134    return f(pixels)
135
136
137def convolve(in1, in2, fixed=True):
138    # a very much boiled down version scipy.signal.signaltools.fftconvolve with added padding, see
139    # https://github.com/scipy/scipy/blob/master/scipy/signal/signaltools.py; please see the Scipy
140    # LICENSE in the accompanying files.
141
142    in1 = numpy.asarray(in1)
143    in2 = numpy.asarray(in2)
144
145    padding = numpy.array(in2.shape) // 2
146    if fixed:  # add "Fixed" padding?
147        in1 = numpy.pad(in1, padding, "edge")
148
149    s1 = numpy.array(in1.shape)
150    s2 = numpy.array(in2.shape)
151    shape = s1 + s2 - 1
152
153    sp1 = numpy.fft.rfftn(in1, shape)
154    sp2 = numpy.fft.rfftn(in2, shape)
155    ret = numpy.fft.irfftn(sp1 * sp2, shape)
156
157    excess = (numpy.array(ret.shape) - s1) // 2 + padding
158    return ret[tuple(slice(p, -p) for p in excess)]
159
160
161# import and export
162
163
164class _Exif:
165    _names = {  # names overriding the ones given by Pillow
166        37385: "FlashInfo",
167        40960: "FlashpixVersion",
168        40962: "PixelXDimension",
169        40963: "PixelYDimension",
170    }
171
172    @staticmethod
173    def extract(im, evaluation):
174        if hasattr(im, "_getexif"):
175            exif = im._getexif()
176            if not exif:
177                return
178
179            for k, v in sorted(exif.items(), key=lambda x: x[0]):
180                name = ExifTags.get(k)
181                if not name:
182                    continue
183
184                # EXIF has the following types: Short, Long, Rational, Ascii, Byte
185                # (see http://www.exiv2.org/tags.html). we detect the type from the
186                # Python type Pillow gives us and do the appropiate MMA handling.
187
188                if isinstance(v, tuple) and len(v) == 2:  # Rational
189                    value = Rational(v[0], v[1])
190                    if name == "FocalLength":
191                        value = value.round(2)
192                    else:
193                        value = Expression("Simplify", value).evaluate(evaluation)
194                elif isinstance(v, bytes):  # Byte
195                    value = String(" ".join(["%d" % x for x in v]))
196                elif isinstance(v, (int, str)):  # Short, Long, Ascii
197                    value = v
198                else:
199                    continue
200
201                yield Expression(SymbolRule, String(_Exif._names.get(k, name)), value)
202
203
204class ImageImport(_ImageBuiltin):
205    """
206    ## Image
207    >> Import["ExampleData/Einstein.jpg"]
208     = -Image-
209    #> Import["ExampleData/sunflowers.jpg"]
210     = -Image-
211    >> Import["ExampleData/MadTeaParty.gif"]
212     = -Image-
213    >> Import["ExampleData/moon.tif"]
214     = -Image-
215    #> Import["ExampleData/lena.tif"]
216     = -Image-
217    """
218
219    def apply(self, path, evaluation):
220        """ImageImport[path_?StringQ]"""
221        pillow = PIL.Image.open(path.get_string_value())
222        pixels = numpy.asarray(pillow)
223        is_rgb = len(pixels.shape) >= 3 and pixels.shape[2] >= 3
224        exif = Expression(SymbolList, *list(_Exif.extract(pillow, evaluation)))
225
226        image = Image(pixels, "RGB" if is_rgb else "Grayscale")
227        return Expression(
228            "List",
229            Expression(SymbolRule, String("Image"), image),
230            Expression(SymbolRule, String("ColorSpace"), String(image.color_space)),
231            Expression(
232                SymbolRule, String("ImageSize"), from_python(image.dimensions())
233            ),
234            Expression(SymbolRule, String("RawExif"), exif),
235        )
236
237
238class ImageExport(_ImageBuiltin):
239    messages = {"noimage": "only an Image[] can be exported into an image file"}
240
241    def apply(self, path, expr, opts, evaluation):
242        """ImageExport[path_?StringQ, expr_, opts___]"""
243        if isinstance(expr, Image):
244            expr.pil().save(path.get_string_value())
245            return SymbolNull
246        else:
247            return evaluation.message("ImageExport", "noimage")
248
249
250# image math
251
252
253class _ImageArithmetic(_ImageBuiltin):
254    messages = {"bddarg": "Expecting a number, image, or graphics instead of `1`."}
255
256    @staticmethod
257    def convert_Image(image):
258        assert isinstance(image, Image)
259        return pixels_as_float(image.pixels)
260
261    @staticmethod
262    def convert_args(*args):
263        images = []
264        for arg in args:
265            if isinstance(arg, Image):
266                images.append(_ImageArithmetic.convert_Image(arg))
267            elif isinstance(arg, (Integer, Rational, Real)):
268                images.append(float(arg.to_python()))
269            else:
270                return None, arg
271        return images, None
272
273    @staticmethod
274    def _reduce(iterable, ufunc):
275        result = None
276        for i in iterable:
277            if result is None:
278                # ufunc is destructive so copy first
279                result = numpy.copy(i)
280            else:
281                # e.g. result *= i
282                ufunc(result, i, result)
283        return result
284
285    def apply(self, image, args, evaluation):
286        "%(name)s[image_Image, args__]"
287        images, arg = self.convert_args(image, *args.get_sequence())
288        if images is None:
289            return evaluation.message(self.get_name(), "bddarg", arg)
290        ufunc = getattr(numpy, self.get_name(True)[5:].lower())
291        result = self._reduce(images, ufunc).clip(0, 1)
292        return Image(result, image.color_space)
293
294
295class ImageAdd(_ImageArithmetic):
296    """
297    <dl>
298    <dt>'ImageAdd[$image$, $expr_1$, $expr_2$, ...]'
299      <dd>adds all $expr_i$ to $image$ where each $expr_i$ must be an image or a real number.
300    </dl>
301
302    >> i = Image[{{0, 0.5, 0.2, 0.1, 0.9}, {1.0, 0.1, 0.3, 0.8, 0.6}}];
303
304    >> ImageAdd[i, 0.5]
305     = -Image-
306
307    >> ImageAdd[i, i]
308     = -Image-
309
310    #> ImageAdd[i, 0.2, i, 0.1]
311     = -Image-
312
313    #> ImageAdd[i, x]
314     : Expecting a number, image, or graphics instead of x.
315     = ImageAdd[-Image-, x]
316
317    >> ein = Import["ExampleData/Einstein.jpg"];
318    >> noise = RandomImage[{-0.1, 0.1}, ImageDimensions[ein]];
319    >> ImageAdd[noise, ein]
320     = -Image-
321
322    >> lena = Import["ExampleData/lena.tif"];
323    >> noise = RandomImage[{-0.2, 0.2}, ImageDimensions[lena], ColorSpace -> "RGB"];
324    >> ImageAdd[noise, lena]
325     = -Image-
326    """
327
328
329class ImageSubtract(_ImageArithmetic):
330    """
331    <dl>
332    <dt>'ImageSubtract[$image$, $expr_1$, $expr_2$, ...]'
333      <dd>subtracts all $expr_i$ from $image$ where each $expr_i$ must be an image or a real number.
334    </dl>
335
336    >> i = Image[{{0, 0.5, 0.2, 0.1, 0.9}, {1.0, 0.1, 0.3, 0.8, 0.6}}];
337
338    >> ImageSubtract[i, 0.2]
339     = -Image-
340
341    >> ImageSubtract[i, i]
342     = -Image-
343
344    #> ImageSubtract[i, 0.2, i, 0.1]
345     = -Image-
346
347    #> ImageSubtract[i, x]
348     : Expecting a number, image, or graphics instead of x.
349     = ImageSubtract[-Image-, x]
350    """
351
352
353class ImageMultiply(_ImageArithmetic):
354    """
355    <dl>
356    <dt>'ImageMultiply[$image$, $expr_1$, $expr_2$, ...]'
357      <dd>multiplies all $expr_i$ with $image$ where each $expr_i$ must be an image or a real number.
358    </dl>
359
360    >> i = Image[{{0, 0.5, 0.2, 0.1, 0.9}, {1.0, 0.1, 0.3, 0.8, 0.6}}];
361
362    >> ImageMultiply[i, 0.2]
363     = -Image-
364
365    >> ImageMultiply[i, i]
366     = -Image-
367
368    #> ImageMultiply[i, 0.2, i, 0.1]
369     = -Image-
370
371    #> ImageMultiply[i, x]
372     : Expecting a number, image, or graphics instead of x.
373     = ImageMultiply[-Image-, x]
374
375    >> ein = Import["ExampleData/Einstein.jpg"];
376    >> noise = RandomImage[{0.7, 1.3}, ImageDimensions[ein]];
377    >> ImageMultiply[noise, ein]
378     = -Image-
379    """
380
381
382class RandomImage(_ImageBuiltin):
383    """
384    <dl>
385    <dt>'RandomImage[$max$]'
386      <dd>creates an image of random pixels with values 0 to $max$.
387    <dt>'RandomImage[{$min$, $max$}]'
388      <dd>creates an image of random pixels with values $min$ to $max$.
389    <dt>'RandomImage[..., $size$]'
390      <dd>creates an image of the given $size$.
391    </dl>
392
393    >> RandomImage[1, {100, 100}]
394     = -Image-
395
396    #> RandomImage[0.5]
397     = -Image-
398    #> RandomImage[{0.1, 0.9}]
399     = -Image-
400    #> RandomImage[0.9, {400, 600}]
401     = -Image-
402    #> RandomImage[{0.1, 0.5}, {400, 600}]
403     = -Image-
404
405    #> RandomImage[{0.1, 0.5}, {400, 600}, ColorSpace -> "RGB"]
406     = -Image-
407    """
408
409    options = {"ColorSpace": "Automatic"}
410
411    rules = {
412        "RandomImage[]": "RandomImage[{0, 1}, {150, 150}]",
413        "RandomImage[max_?RealNumberQ]": "RandomImage[{0, max}, {150, 150}]",
414        "RandomImage[{minval_?RealNumberQ, maxval_?RealNumberQ}]": "RandomImage[{minval, maxval}, {150, 150}]",
415        "RandomImage[max_?RealNumberQ, {w_Integer, h_Integer}]": "RandomImage[{0, max}, {w, h}]",
416    }
417
418    messages = {
419        "bddim": "The specified dimension `1` should be a pair of positive integers.",
420        "imgcstype": "`1` is an invalid color space specification.",
421    }
422
423    def apply(self, minval, maxval, w, h, evaluation, options):
424        "RandomImage[{minval_?RealNumberQ, maxval_?RealNumberQ}, {w_Integer, h_Integer}, OptionsPattern[RandomImage]]"
425        color_space = self.get_option(options, "ColorSpace", evaluation)
426        if (
427            isinstance(color_space, Symbol)
428            and color_space.get_name() == "System`Automatic"
429        ):
430            cs = "Grayscale"
431        else:
432            cs = color_space.get_string_value()
433        size = [w.get_int_value(), h.get_int_value()]
434        if size[0] <= 0 or size[1] <= 0:
435            return evaluation.message("RandomImage", "bddim", from_python(size))
436        minrange, maxrange = minval.round_to_float(), maxval.round_to_float()
437
438        if cs == "Grayscale":
439            data = (
440                numpy.random.rand(size[1], size[0]) * (maxrange - minrange) + minrange
441            )
442        elif cs == "RGB":
443            data = (
444                numpy.random.rand(size[1], size[0], 3) * (maxrange - minrange)
445                + minrange
446            )
447        else:
448            return evaluation.message("RandomImage", "imgcstype", color_space)
449        return Image(data, cs)
450
451
452# simple image manipulation
453
454
455class ImageResize(_ImageBuiltin):
456    """
457    <dl>
458    <dt>'ImageResize[$image$, $width$]'
459      <dd>
460    <dt>'ImageResize[$image$, {$width$, $height$}]'
461      <dd>
462    </dl>
463
464    >> ein = Import["ExampleData/Einstein.jpg"];
465    >> ImageDimensions[ein]
466     = {615, 768}
467    >> ImageResize[ein, {400, 600}]
468     = -Image-
469    #> ImageDimensions[%]
470     = {400, 600}
471
472    >> ImageResize[ein, 256]
473     = -Image-
474    >> ImageDimensions[%]
475     = {256, 320}
476
477    The default sampling method is Bicubic
478    >> ImageResize[ein, 256, Resampling -> "Bicubic"]
479     = -Image-
480    #> ImageDimensions[%]
481     = {256, 320}
482    >> ImageResize[ein, 256, Resampling -> "Nearest"]
483     = -Image-
484    #> ImageDimensions[%]
485     = {256, 320}
486    >> ImageResize[ein, 256, Resampling -> "Gaussian"]
487     = -Image-
488    #> ImageDimensions[%]
489     = {256, 320}
490    #> ImageResize[ein, {256, 256}, Resampling -> "Gaussian"]
491     : Gaussian resampling needs to maintain aspect ratio.
492     = ImageResize[-Image-, {256, 256}, Resampling -> Gaussian]
493    #> ImageResize[ein, 256, Resampling -> "Invalid"]
494     : Invalid resampling method Invalid.
495     = ImageResize[-Image-, 256, Resampling -> Invalid]
496
497    #> ImageDimensions[ImageResize[ein, {256}]]
498     = {256, 256}
499
500    #> ImageResize[ein, {x}]
501     : The size {x} is not a valid image size specification.
502     = ImageResize[-Image-, {x}]
503    #> ImageResize[ein, x]
504     : The size x is not a valid image size specification.
505     = ImageResize[-Image-, x]
506    """
507
508    options = {"Resampling": "Automatic"}
509
510    messages = {
511        "imgrssz": "The size `1` is not a valid image size specification.",
512        "imgrsm": "Invalid resampling method `1`.",
513        "gaussaspect": "Gaussian resampling needs to maintain aspect ratio.",
514        "skimage": "Please install scikit-image to use Resampling -> Gaussian.",
515    }
516
517    def _get_image_size_spec(self, old_size, new_size):
518        predefined_sizes = {
519            "System`Tiny": 75,
520            "System`Small": 150,
521            "System`Medium": 300,
522            "System`Large": 450,
523            "System`Automatic": 0,  # placeholder
524        }
525        result = new_size.round_to_float()
526        if result is not None:
527            result = int(result)
528            if result <= 0:
529                return None
530            return result
531
532        if isinstance(new_size, Symbol):
533            name = new_size.get_name()
534            if name == "System`All":
535                return old_size
536            return predefined_sizes.get(name, None)
537        if new_size.has_form("Scaled", 1):
538            s = new_size.leaves[0].round_to_float()
539            if s is None:
540                return None
541            return max(1, old_size * s)  # handle negative s values silently
542        return None
543
544    def apply_resize_width(self, image, s, evaluation, options):
545        "ImageResize[image_Image, s_, OptionsPattern[ImageResize]]"
546        old_w = image.pixels.shape[1]
547        if s.has_form("List", 1):
548            width = s.leaves[0]
549        else:
550            width = s
551        w = self._get_image_size_spec(old_w, width)
552        if w is None:
553            return evaluation.message("ImageResize", "imgrssz", s)
554        if s.has_form("List", 1):
555            height = width
556        else:
557            height = Symbol("Automatic")
558        return self.apply_resize_width_height(image, width, height, evaluation, options)
559
560    def apply_resize_width_height(self, image, width, height, evaluation, options):
561        "ImageResize[image_Image, {width_, height_}, OptionsPattern[ImageResize]]"
562        # resampling method
563        resampling = self.get_option(options, "Resampling", evaluation)
564        if (
565            isinstance(resampling, Symbol)
566            and resampling.get_name() == "System`Automatic"
567        ):
568            resampling_name = "Bicubic"
569        else:
570            resampling_name = resampling.get_string_value()
571
572        # find new size
573        old_w, old_h = image.pixels.shape[1], image.pixels.shape[0]
574        w = self._get_image_size_spec(old_w, width)
575        h = self._get_image_size_spec(old_h, height)
576        if h is None or w is None:
577            return evaluation.message(
578                "ImageResize", "imgrssz", Expression(SymbolList, width, height)
579            )
580
581        # handle Automatic
582        old_aspect_ratio = old_w / old_h
583        if w == 0 and h == 0:
584            # if both width and height are Automatic then use old values
585            w, h = old_w, old_h
586        elif w == 0:
587            w = max(1, h * old_aspect_ratio)
588        elif h == 0:
589            h = max(1, w / old_aspect_ratio)
590
591        if resampling_name != "Gaussian":
592            # Gaussian need to unrounded values to compute scaling ratios.
593            # round to closest pixel for other methods.
594            h, w = int(round(h)), int(round(w))
595
596        # perform the resize
597        if resampling_name == "Nearest":
598            return image.filter(
599                lambda im: im.resize((w, h), resample=PIL.Image.NEAREST)
600            )
601        elif resampling_name == "Bicubic":
602            return image.filter(
603                lambda im: im.resize((w, h), resample=PIL.Image.BICUBIC)
604            )
605        elif resampling_name != "Gaussian":
606            return evaluation.message("ImageResize", "imgrsm", resampling)
607
608        try:
609            from skimage import transform
610
611            multichannel = image.pixels.ndim == 3
612
613            sy = h / old_h
614            sx = w / old_w
615            if sy > sx:
616                err = abs((sy * old_w) - (sx * old_w))
617                s = sy
618            else:
619                err = abs((sy * old_h) - (sx * old_h))
620                s = sx
621            if err > 1.5:
622                # TODO overcome this limitation
623                return evaluation.message("ImageResize", "gaussaspect")
624            elif s > 1:
625                pixels = transform.pyramid_expand(
626                    image.pixels, upscale=s, multichannel=multichannel
627                ).clip(0, 1)
628            else:
629                pixels = transform.pyramid_reduce(
630                    image.pixels, multichannel=multichannel, downscale=(1.0 / s)
631                ).clip(0, 1)
632
633            return Image(pixels, image.color_space)
634        except ImportError:
635            evaluation.message("ImageResize", "skimage")
636
637
638class ImageReflect(_ImageBuiltin):
639    """
640    <dl>
641    <dt>'ImageReflect[$image$]'
642      <dd>Flips $image$ top to bottom.
643    <dt>'ImageReflect[$image$, $side$]'
644      <dd>Flips $image$ so that $side$ is interchanged with its opposite.
645    <dt>'ImageReflect[$image$, $side_1$ -> $side_2$]'
646      <dd>Flips $image$ so that $side_1$ is interchanged with $side_2$.
647    </dl>
648
649    >> ein = Import["ExampleData/Einstein.jpg"];
650    >> ImageReflect[ein]
651     = -Image-
652    >> ImageReflect[ein, Left]
653     = -Image-
654    >> ImageReflect[ein, Left -> Top]
655     = -Image-
656
657    #> ein == ImageReflect[ein, Left -> Left] == ImageReflect[ein, Right -> Right] == ImageReflect[ein, Top -> Top] == ImageReflect[ein, Bottom -> Bottom]
658     = True
659    #> ImageReflect[ein, Left -> Right] == ImageReflect[ein, Right -> Left] == ImageReflect[ein, Left] == ImageReflect[ein, Right]
660     = True
661    #> ImageReflect[ein, Bottom -> Top] == ImageReflect[ein, Top -> Bottom] == ImageReflect[ein, Top] == ImageReflect[ein, Bottom]
662     = True
663    #> ImageReflect[ein, Left -> Top] == ImageReflect[ein, Right -> Bottom]     (* Transpose *)
664     = True
665    #> ImageReflect[ein, Left -> Bottom] == ImageReflect[ein, Right -> Top]     (* Anti-Transpose *)
666     = True
667
668    #> ImageReflect[ein, x -> Top]
669     : x -> Top is not a valid 2D reflection specification.
670     = ImageReflect[-Image-, x -> Top]
671    """
672
673    rules = {
674        "ImageReflect[image_Image]": "ImageReflect[image, Top -> Bottom]",
675        "ImageReflect[image_Image, Top|Bottom]": "ImageReflect[image, Top -> Bottom]",
676        "ImageReflect[image_Image, Left|Right]": "ImageReflect[image, Left -> Right]",
677    }
678
679    messages = {"bdrfl2": "`1` is not a valid 2D reflection specification."}
680
681    def apply(self, image, orig, dest, evaluation):
682        "ImageReflect[image_Image, Rule[orig_, dest_]]"
683        if isinstance(orig, Symbol) and isinstance(dest, Symbol):
684            specs = [orig.get_name(), dest.get_name()]
685            specs.sort()  # `Top -> Bottom` is the same as `Bottom -> Top`
686
687        anti_transpose = lambda i: numpy.flipud(numpy.transpose(numpy.flipud(i)))
688        no_op = lambda i: i
689
690        method = {
691            ("System`Bottom", "System`Top"): numpy.flipud,
692            ("System`Left", "System`Right"): numpy.fliplr,
693            ("System`Left", "System`Top"): numpy.transpose,
694            ("System`Right", "System`Top"): anti_transpose,
695            ("System`Bottom", "System`Left"): anti_transpose,
696            ("System`Bottom", "System`Right"): numpy.transpose,
697            ("System`Bottom", "System`Bottom"): no_op,
698            ("System`Top", "System`Top"): no_op,
699            ("System`Left", "System`Left"): no_op,
700            ("System`Right", "System`Right"): no_op,
701        }.get(tuple(specs), None)
702
703        if method is None:
704            return evaluation.message(
705                "ImageReflect", "bdrfl2", Expression(SymbolRule, orig, dest)
706            )
707
708        return Image(method(image.pixels), image.color_space)
709
710
711class ImageRotate(_ImageBuiltin):
712    """
713    <dl>
714    <dt>'ImageRotate[$image$]'
715      <dd>Rotates $image$ 90 degrees counterclockwise.
716    <dt>'ImageRotate[$image$, $theta$]'
717      <dd>Rotates $image$ by a given angle $theta$
718    </dl>
719
720    >> ein = Import["ExampleData/Einstein.jpg"];
721
722    >> ImageRotate[ein]
723     = -Image-
724
725    >> ImageRotate[ein, 45 Degree]
726     = -Image-
727
728    >> ImageRotate[ein, Pi / 2]
729     = -Image-
730
731    #> ImageRotate[ein, ein]
732     : Angle -Image- should be a real number, one of Top, Bottom, Left, Right, or a rule from one to another.
733     = ImageRotate[-Image-, -Image-]
734    """
735
736    rules = {"ImageRotate[i_Image]": "ImageRotate[i, 90 Degree]"}
737
738    messages = {
739        "imgang": "Angle `1` should be a real number, one of Top, Bottom, Left, Right, or a rule from one to another."
740    }
741
742    def apply(self, image, angle, evaluation):
743        "ImageRotate[image_Image, angle_]"
744        py_angle = angle.round_to_float(evaluation)
745        if py_angle is None:
746            return evaluation.message("ImageRotate", "imgang", angle)
747
748        def rotate(im):
749            return im.rotate(
750                180 * py_angle / math.pi, resample=PIL.Image.BICUBIC, expand=True
751            )
752
753        return image.filter(rotate)
754
755
756class ImagePartition(_ImageBuiltin):
757    """
758    <dl>
759    <dt>'ImagePartition[$image$, $s$]'
760      <dd>Partitions an image into an array of $s$ x $s$ pixel subimages.
761    <dt>'ImagePartition[$image$, {$w$, $h$}]'
762      <dd>Partitions an image into an array of $w$ x $h$ pixel subimages.
763    </dl>
764
765    >> lena = Import["ExampleData/lena.tif"];
766    >> ImageDimensions[lena]
767     = {512, 512}
768    >> ImagePartition[lena, 256]
769     = {{-Image-, -Image-}, {-Image-, -Image-}}
770
771    >> ImagePartition[lena, {512, 128}]
772     = {{-Image-}, {-Image-}, {-Image-}, {-Image-}}
773
774    #> ImagePartition[lena, 257]
775     = {{-Image-}}
776    #> ImagePartition[lena, 512]
777     = {{-Image-}}
778    #> ImagePartition[lena, 513]
779     = {}
780    #> ImagePartition[lena, {256, 300}]
781     = {{-Image-, -Image-}}
782
783    #> ImagePartition[lena, {0, 300}]
784     : {0, 300} is not a valid size specification for image partitions.
785     = ImagePartition[-Image-, {0, 300}]
786    """
787
788    rules = {"ImagePartition[i_Image, s_Integer]": "ImagePartition[i, {s, s}]"}
789
790    messages = {"arg2": "`1` is not a valid size specification for image partitions."}
791
792    def apply(self, image, w, h, evaluation):
793        "ImagePartition[image_Image, {w_Integer, h_Integer}]"
794        w = w.get_int_value()
795        h = h.get_int_value()
796        if w <= 0 or h <= 0:
797            return evaluation.message("ImagePartition", "arg2", from_python([w, h]))
798        pixels = image.pixels
799        shape = pixels.shape
800
801        # drop blocks less than w x h
802        parts = []
803        for yi in range(shape[0] // h):
804            row = []
805            for xi in range(shape[1] // w):
806                p = pixels[yi * h : (yi + 1) * h, xi * w : (xi + 1) * w]
807                row.append(Image(p, image.color_space))
808            if row:
809                parts.append(row)
810        return from_python(parts)
811
812
813# simple image filters
814
815
816class ImageAdjust(_ImageBuiltin):
817    """
818    <dl>
819    <dt>'ImageAdjust[$image$]'
820      <dd>adjusts the levels in $image$.
821    <dt>'ImageAdjust[$image$, $c$]'
822      <dd>adjusts the contrast in $image$ by $c$.
823    <dt>'ImageAdjust[$image$, {$c$, $b$}]'
824      <dd>adjusts the contrast $c$, and brightness $b$ in $image$.
825    <dt>'ImageAdjust[$image$, {$c$, $b$, $g$}]'
826      <dd>adjusts the contrast $c$, brightness $b$, and gamma $g$ in $image$.
827    </dl>
828
829    >> lena = Import["ExampleData/lena.tif"];
830    >> ImageAdjust[lena]
831     = -Image-
832    """
833
834    rules = {
835        "ImageAdjust[image_Image, c_?RealNumberQ]": "ImageAdjust[image, {c, 0, 1}]",
836        "ImageAdjust[image_Image, {c_?RealNumberQ, b_?RealNumberQ}]": "ImageAdjust[image, {c, b, 1}]",
837    }
838
839    def apply_auto(self, image, evaluation):
840        "ImageAdjust[image_Image]"
841        pixels = pixels_as_float(image.pixels)
842
843        # channel limits
844        axis = (0, 1)
845        cmaxs, cmins = pixels.max(axis=axis), pixels.min(axis=axis)
846
847        # normalise channels
848        scales = cmaxs - cmins
849        if not scales.shape:
850            scales = numpy.array([scales])
851        scales[scales == 0.0] = 1
852        pixels -= cmins
853        pixels /= scales
854        return Image(pixels, image.color_space)
855
856    def apply_contrast_brightness_gamma(self, image, c, b, g, evaluation):
857        "ImageAdjust[image_Image, {c_?RealNumberQ, b_?RealNumberQ, g_?RealNumberQ}]"
858
859        im = image.pil()
860
861        # gamma
862        g = g.round_to_float()
863        if g != 1:
864            im = PIL.ImageEnhance.Color(im).enhance(g)
865
866        # brightness
867        b = b.round_to_float()
868        if b != 0:
869            im = PIL.ImageEnhance.Brightness(im).enhance(b + 1)
870
871        # contrast
872        c = c.round_to_float()
873        if c != 0:
874            im = PIL.ImageEnhance.Contrast(im).enhance(c + 1)
875
876        return Image(numpy.array(im), image.color_space)
877
878
879class Blur(_ImageBuiltin):
880    """
881    <dl>
882    <dt>'Blur[$image$]'
883      <dd>gives a blurred version of $image$.
884    <dt>'Blur[$image$, $r$]'
885      <dd>blurs $image$ with a kernel of size $r$.
886    </dl>
887
888    >> lena = Import["ExampleData/lena.tif"];
889    >> Blur[lena]
890     = -Image-
891    >> Blur[lena, 5]
892     = -Image-
893    """
894
895    rules = {
896        "Blur[image_Image]": "Blur[image, 2]",
897        "Blur[image_Image, r_?RealNumberQ]": "ImageConvolve[image, BoxMatrix[r] / Total[Flatten[BoxMatrix[r]]]]",
898    }
899
900
901class Sharpen(_ImageBuiltin):
902    """
903    <dl>
904    <dt>'Sharpen[$image$]'
905      <dd>gives a sharpened version of $image$.
906    <dt>'Sharpen[$image$, $r$]'
907      <dd>sharpens $image$ with a kernel of size $r$.
908    </dl>
909
910    >> lena = Import["ExampleData/lena.tif"];
911    >> Sharpen[lena]
912     = -Image-
913    >> Sharpen[lena, 5]
914     = -Image-
915    """
916
917    rules = {"Sharpen[i_Image]": "Sharpen[i, 2]"}
918
919    def apply(self, image, r, evaluation):
920        "Sharpen[image_Image, r_?RealNumberQ]"
921        f = PIL.ImageFilter.UnsharpMask(r.round_to_float())
922        return image.filter(lambda im: im.filter(f))
923
924
925class GaussianFilter(_ImageBuiltin):
926    """
927    <dl>
928    <dt>'GaussianFilter[$image$, $r$]'
929      <dd>blurs $image$ using a Gaussian blur filter of radius $r$.
930    </dl>
931
932    >> lena = Import["ExampleData/lena.tif"];
933    >> GaussianFilter[lena, 2.5]
934     = -Image-
935    """
936
937    messages = {"only3": "GaussianFilter only supports up to three channels."}
938
939    def apply_radius(self, image, radius, evaluation):
940        "GaussianFilter[image_Image, radius_?RealNumberQ]"
941        if len(image.pixels.shape) > 2 and image.pixels.shape[2] > 3:
942            return evaluation.message("GaussianFilter", "only3")
943        else:
944            f = PIL.ImageFilter.GaussianBlur(radius.round_to_float())
945            return image.filter(lambda im: im.filter(f))
946
947
948# morphological image filters
949
950
951class PillowImageFilter(_ImageBuiltin):
952    def compute(self, image, f):
953        return image.filter(lambda im: im.filter(f))
954
955
956class MinFilter(PillowImageFilter):
957    """
958    <dl>
959    <dt>'MinFilter[$image$, $r$]'
960      <dd>gives $image$ with a minimum filter of radius $r$ applied on it. This always
961      picks the smallest value in the filter's area.
962    </dl>
963
964    >> lena = Import["ExampleData/lena.tif"];
965    >> MinFilter[lena, 5]
966     = -Image-
967    """
968
969    def apply(self, image, r, evaluation):
970        "MinFilter[image_Image, r_Integer]"
971        return self.compute(image, PIL.ImageFilter.MinFilter(1 + 2 * r.get_int_value()))
972
973
974class MaxFilter(PillowImageFilter):
975    """
976    <dl>
977    <dt>'MaxFilter[$image$, $r$]'
978      <dd>gives $image$ with a maximum filter of radius $r$ applied on it. This always
979      picks the largest value in the filter's area.
980    </dl>
981
982    >> lena = Import["ExampleData/lena.tif"];
983    >> MaxFilter[lena, 5]
984     = -Image-
985    """
986
987    def apply(self, image, r, evaluation):
988        "MaxFilter[image_Image, r_Integer]"
989        return self.compute(image, PIL.ImageFilter.MaxFilter(1 + 2 * r.get_int_value()))
990
991
992class MedianFilter(PillowImageFilter):
993    """
994    <dl>
995    <dt>'MedianFilter[$image$, $r$]'
996      <dd>gives $image$ with a median filter of radius $r$ applied on it. This always
997      picks the median value in the filter's area.
998    </dl>
999
1000    >> lena = Import["ExampleData/lena.tif"];
1001    >> MedianFilter[lena, 5]
1002     = -Image-
1003    """
1004
1005    def apply(self, image, r, evaluation):
1006        "MedianFilter[image_Image, r_Integer]"
1007        return self.compute(
1008            image, PIL.ImageFilter.MedianFilter(1 + 2 * r.get_int_value())
1009        )
1010
1011
1012class EdgeDetect(_SkimageBuiltin):
1013    """
1014    <dl>
1015    <dt>'EdgeDetect[$image$]'
1016      <dd>returns an image showing the edges in $image$.
1017    </dl>
1018
1019    >> lena = Import["ExampleData/lena.tif"];
1020    >> EdgeDetect[lena]
1021     = -Image-
1022    >> EdgeDetect[lena, 5]
1023     = -Image-
1024    >> EdgeDetect[lena, 4, 0.5]
1025     = -Image-
1026    """
1027
1028    rules = {
1029        "EdgeDetect[i_Image]": "EdgeDetect[i, 2, 0.2]",
1030        "EdgeDetect[i_Image, r_?RealNumberQ]": "EdgeDetect[i, r, 0.2]",
1031    }
1032
1033    def apply(self, image, r, t, evaluation):
1034        "EdgeDetect[image_Image, r_?RealNumberQ, t_?RealNumberQ]"
1035        import skimage.feature
1036
1037        pixels = image.grayscale().pixels
1038        return Image(
1039            skimage.feature.canny(
1040                pixels.reshape(pixels.shape[:2]),
1041                sigma=r.round_to_float() / 2,
1042                low_threshold=0.5 * t.round_to_float(),
1043                high_threshold=t.round_to_float(),
1044            ),
1045            "Grayscale",
1046        )
1047
1048
1049def _matrix(rows):
1050    return Expression(SymbolList, *[Expression(SymbolList, *r) for r in rows])
1051
1052
1053class BoxMatrix(_ImageBuiltin):
1054    """
1055    <dl>
1056    <dt>'BoxMatrix[$s]'
1057      <dd>Gives a box shaped kernel of size 2 $s$ + 1.
1058    </dl>
1059
1060    >> BoxMatrix[3]
1061     = {{1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}}
1062    """
1063
1064    def apply(self, r, evaluation):
1065        "BoxMatrix[r_?RealNumberQ]"
1066        py_r = abs(r.round_to_float())
1067        s = int(math.floor(1 + 2 * py_r))
1068        return _matrix([[Integer1] * s] * s)
1069
1070
1071class DiskMatrix(_ImageBuiltin):
1072    """
1073    <dl>
1074    <dt>'DiskMatrix[$s]'
1075      <dd>Gives a disk shaped kernel of size 2 $s$ + 1.
1076    </dl>
1077
1078    >> DiskMatrix[3]
1079     = {{0, 0, 1, 1, 1, 0, 0}, {0, 1, 1, 1, 1, 1, 0}, {1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1, 1}, {0, 1, 1, 1, 1, 1, 0}, {0, 0, 1, 1, 1, 0, 0}}
1080    """
1081
1082    def apply(self, r, evaluation):
1083        "DiskMatrix[r_?RealNumberQ]"
1084        py_r = abs(r.round_to_float())
1085        s = int(math.floor(0.5 + py_r))
1086
1087        m = (Integer0, Integer1)
1088        r_sqr = (py_r + 0.5) * (py_r + 0.5)
1089
1090        def rows():
1091            for y in range(-s, s + 1):
1092                yield [m[int((x) * (x) + (y) * (y) <= r_sqr)] for x in range(-s, s + 1)]
1093
1094        return _matrix(rows())
1095
1096
1097class DiamondMatrix(_ImageBuiltin):
1098    """
1099    <dl>
1100    <dt>'DiamondMatrix[$s]'
1101      <dd>Gives a diamond shaped kernel of size 2 $s$ + 1.
1102    </dl>
1103
1104    >> DiamondMatrix[3]
1105     = {{0, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 1, 1, 0, 0}, {0, 1, 1, 1, 1, 1, 0}, {1, 1, 1, 1, 1, 1, 1}, {0, 1, 1, 1, 1, 1, 0}, {0, 0, 1, 1, 1, 0, 0}, {0, 0, 0, 1, 0, 0, 0}}
1106    """
1107
1108    def apply(self, r, evaluation):
1109        "DiamondMatrix[r_?RealNumberQ]"
1110        py_r = abs(r.round_to_float())
1111        t = int(math.floor(0.5 + py_r))
1112
1113        zero = Integer0
1114        one = Integer1
1115
1116        def rows():
1117            for d in range(0, t):
1118                p = [zero] * (t - d)
1119                yield p + ([one] * (1 + d * 2)) + p
1120
1121            yield [one] * (2 * t + 1)
1122
1123            for d in reversed(range(0, t)):
1124                p = [zero] * (t - d)
1125                yield p + ([one] * (1 + d * 2)) + p
1126
1127        return _matrix(rows())
1128
1129
1130class ImageConvolve(_ImageBuiltin):
1131    """
1132    <dl>
1133    <dt>'ImageConvolve[$image$, $kernel$]'
1134      <dd>Computes the convolution of $image$ using $kernel$.
1135    </dl>
1136
1137    >> img = Import["ExampleData/lena.tif"];
1138    >> ImageConvolve[img, DiamondMatrix[5] / 61]
1139     = -Image-
1140    >> ImageConvolve[img, DiskMatrix[5] / 97]
1141     = -Image-
1142    >> ImageConvolve[img, BoxMatrix[5] / 121]
1143     = -Image-
1144    """
1145
1146    def apply(self, image, kernel, evaluation):
1147        "%(name)s[image_Image, kernel_?MatrixQ]"
1148        numpy_kernel = matrix_to_numpy(kernel)
1149        pixels = pixels_as_float(image.pixels)
1150        shape = pixels.shape[:2]
1151        channels = []
1152        for c in (pixels[:, :, i] for i in range(pixels.shape[2])):
1153            channels.append(convolve(c.reshape(shape), numpy_kernel, fixed=True))
1154        return Image(numpy.dstack(channels), image.color_space)
1155
1156
1157class _MorphologyFilter(_SkimageBuiltin):
1158
1159    messages = {
1160        "grayscale": "Your image has been converted to grayscale as color images are not supported yet."
1161    }
1162
1163    rules = {"%(name)s[i_Image, r_?RealNumberQ]": "%(name)s[i, BoxMatrix[r]]"}
1164
1165    def apply(self, image, k, evaluation):
1166        "%(name)s[image_Image, k_?MatrixQ]"
1167        if image.color_space != "Grayscale":
1168            image = image.grayscale()
1169            evaluation.message(self.get_name(), "grayscale")
1170        import skimage.morphology
1171
1172        f = getattr(skimage.morphology, self.get_name(True).lower())
1173        shape = image.pixels.shape[:2]
1174        img = f(image.pixels.reshape(shape), matrix_to_numpy(k))
1175        return Image(img, "Grayscale")
1176
1177
1178class Dilation(_MorphologyFilter):
1179    """
1180    <dl>
1181    <dt>'Dilation[$image$, $ker$]'
1182      <dd>Gives the morphological dilation of $image$ with respect to structuring element $ker$.
1183    </dl>
1184
1185    >> ein = Import["ExampleData/Einstein.jpg"];
1186    >> Dilation[ein, 2.5]
1187     = -Image-
1188    """
1189
1190
1191class Erosion(_MorphologyFilter):
1192    """
1193    <dl>
1194    <dt>'Erosion[$image$, $ker$]'
1195      <dd>Gives the morphological erosion of $image$ with respect to structuring element $ker$.
1196    </dl>
1197
1198    >> ein = Import["ExampleData/Einstein.jpg"];
1199    >> Erosion[ein, 2.5]
1200     = -Image-
1201    """
1202
1203
1204class Opening(_MorphologyFilter):
1205    """
1206    <dl>
1207    <dt>'Opening[$image$, $ker$]'
1208      <dd>Gives the morphological opening of $image$ with respect to structuring element $ker$.
1209    </dl>
1210
1211    >> ein = Import["ExampleData/Einstein.jpg"];
1212    >> Opening[ein, 2.5]
1213     = -Image-
1214    """
1215
1216
1217class Closing(_MorphologyFilter):
1218    """
1219    <dl>
1220    <dt>'Closing[$image$, $ker$]'
1221      <dd>Gives the morphological closing of $image$ with respect to structuring element $ker$.
1222    </dl>
1223
1224    >> ein = Import["ExampleData/Einstein.jpg"];
1225    >> Closing[ein, 2.5]
1226     = -Image-
1227    """
1228
1229
1230class MorphologicalComponents(_SkimageBuiltin):
1231
1232    rules = {"MorphologicalComponents[i_Image]": "MorphologicalComponents[i, 0]"}
1233
1234    def apply(self, image, t, evaluation):
1235        "MorphologicalComponents[image_Image, t_?RealNumberQ]"
1236        pixels = pixels_as_ubyte(
1237            pixels_as_float(image.grayscale().pixels) > t.round_to_float()
1238        )
1239        import skimage.measure
1240
1241        return from_python(
1242            skimage.measure.label(pixels, background=0, connectivity=2).tolist()
1243        )
1244
1245
1246# color space
1247
1248
1249class ImageColorSpace(_ImageBuiltin):
1250    """
1251    <dl>
1252    <dt>'ImageColorSpace[$image$]'
1253        <dd>gives $image$'s color space, e.g. "RGB" or "CMYK".
1254    </dl>
1255
1256    >> img = Import["ExampleData/lena.tif"];
1257    >> ImageColorSpace[img]
1258     = RGB
1259    """
1260
1261    def apply(self, image, evaluation):
1262        "ImageColorSpace[image_Image]"
1263        return String(image.color_space)
1264
1265
1266class ColorConvert(Builtin):
1267    """
1268    <dl>
1269    <dt>'ColorConvert[$c$, $colspace$]'
1270        <dd>returns the representation of $c$ in the color space $colspace$. $c$
1271        may be a color or an image.
1272    </dl>
1273
1274    Valid values for $colspace$ are:
1275
1276    CMYK: convert to CMYKColor
1277    Grayscale: convert to GrayLevel
1278    HSB: convert to Hue
1279    LAB: concert to LABColor
1280    LCH: convert to LCHColor
1281    LUV: convert to LUVColor
1282    RGB: convert to RGBColor
1283    XYZ: convert to XYZColor
1284    """
1285
1286    messages = {
1287        "ccvinput": "`` should be a color.",
1288        "imgcstype": "`` is not a valid color space.",
1289    }
1290
1291    def apply(self, input, colorspace, evaluation):
1292        "ColorConvert[input_, colorspace_String]"
1293
1294        if isinstance(input, Image):
1295            return input.color_convert(colorspace.get_string_value())
1296        else:
1297            from mathics.builtin.graphics import (
1298                expression_to_color,
1299                color_to_expression,
1300            )
1301
1302            py_color = expression_to_color(input)
1303            if py_color is None:
1304                evaluation.message("ColorConvert", "ccvinput", input)
1305                return
1306
1307            py_colorspace = colorspace.get_string_value()
1308            converted_components = convert_color(
1309                py_color.components, py_color.color_space, py_colorspace
1310            )
1311
1312            if converted_components is None:
1313                evaluation.message("ColorConvert", "imgcstype", colorspace)
1314                return
1315
1316            return color_to_expression(converted_components, py_colorspace)
1317
1318
1319class ColorQuantize(_ImageBuiltin):
1320    """
1321    <dl>
1322    <dt>'ColorQuantize[$image$, $n$]'
1323      <dd>gives a version of $image$ using only $n$ colors.
1324    </dl>
1325
1326    >> img = Import["ExampleData/lena.tif"];
1327    >> ColorQuantize[img, 6]
1328     = -Image-
1329
1330    #> ColorQuantize[img, 0]
1331     : Positive integer expected at position 2 in ColorQuantize[-Image-, 0].
1332     = ColorQuantize[-Image-, 0]
1333    #> ColorQuantize[img, -1]
1334     : Positive integer expected at position 2 in ColorQuantize[-Image-, -1].
1335     = ColorQuantize[-Image-, -1]
1336    """
1337
1338    messages = {"intp": "Positive integer expected at position `2` in `1`."}
1339
1340    def apply(self, image, n, evaluation):
1341        "ColorQuantize[image_Image, n_Integer]"
1342        if n.get_int_value() <= 0:
1343            return evaluation.message(
1344                "ColorQuantize", "intp", Expression("ColorQuantize", image, n), 2
1345            )
1346        converted = image.color_convert("RGB")
1347        if converted is None:
1348            return
1349        pixels = pixels_as_ubyte(converted.pixels)
1350        im = PIL.Image.fromarray(pixels).quantize(n.get_int_value())
1351        im = im.convert("RGB")
1352        return Image(numpy.array(im), "RGB")
1353
1354
1355class Threshold(_SkimageBuiltin):
1356    """
1357    <dl>
1358    <dt>'Threshold[$image$]'
1359      <dd>gives a value suitable for binarizing $image$.
1360    </dl>
1361
1362    The option "Method" may be "Cluster" (use Otsu's threshold), "Median", or "Mean".
1363
1364    >> img = Import["ExampleData/lena.tif"];
1365    >> Threshold[img]
1366     = 0.456739
1367    >> Binarize[img, %]
1368     = -Image-
1369    >> Threshold[img, Method -> "Mean"]
1370     = 0.486458
1371    >> Threshold[img, Method -> "Median"]
1372     = 0.504726
1373    """
1374
1375    options = {"Method": '"Cluster"'}
1376
1377    messages = {
1378        "illegalmethod": "Method `` is not supported.",
1379        "skimage": "Please install scikit-image to use Method -> Cluster.",
1380    }
1381
1382    def apply(self, image, evaluation, options):
1383        "Threshold[image_Image, OptionsPattern[Threshold]]"
1384        pixels = image.grayscale().pixels
1385
1386        method = self.get_option(options, "Method", evaluation)
1387        method_name = (
1388            method.get_string_value()
1389            if isinstance(method, String)
1390            else method.to_python()
1391        )
1392        if method_name == "Cluster":
1393            import skimage.filters
1394
1395            threshold = skimage.filters.threshold_otsu(pixels)
1396        elif method_name == "Median":
1397            threshold = numpy.median(pixels)
1398        elif method_name == "Mean":
1399            threshold = numpy.mean(pixels)
1400        else:
1401            return evaluation.message("Threshold", "illegalmethod", method)
1402
1403        return MachineReal(float(threshold))
1404
1405
1406class Binarize(_SkimageBuiltin):
1407    """
1408    <dl>
1409    <dt>'Binarize[$image$]'
1410      <dd>gives a binarized version of $image$, in which each pixel is either 0 or 1.
1411    <dt>'Binarize[$image$, $t$]'
1412      <dd>map values $x$ > $t$ to 1, and values $x$ <= $t$ to 0.
1413    <dt>'Binarize[$image$, {$t1$, $t2$}]'
1414      <dd>map $t1$ < $x$ < $t2$ to 1, and all other values to 0.
1415    </dl>
1416
1417    >> img = Import["ExampleData/lena.tif"];
1418    >> Binarize[img]
1419     = -Image-
1420    >> Binarize[img, 0.7]
1421     = -Image-
1422    >> Binarize[img, {0.2, 0.6}]
1423     = -Image-
1424    """
1425
1426    def apply(self, image, evaluation):
1427        "Binarize[image_Image]"
1428        image = image.grayscale()
1429        thresh = Expression("Threshold", image).evaluate(evaluation).round_to_float()
1430        if thresh is not None:
1431            return Image(image.pixels > thresh, "Grayscale")
1432
1433    def apply_t(self, image, t, evaluation):
1434        "Binarize[image_Image, t_?RealNumberQ]"
1435        pixels = image.grayscale().pixels
1436        return Image(pixels > t.round_to_float(), "Grayscale")
1437
1438    def apply_t1_t2(self, image, t1, t2, evaluation):
1439        "Binarize[image_Image, {t1_?RealNumberQ, t2_?RealNumberQ}]"
1440        pixels = image.grayscale().pixels
1441        mask1 = pixels > t1.round_to_float()
1442        mask2 = pixels < t2.round_to_float()
1443        return Image(mask1 * mask2, "Grayscale")
1444
1445
1446class ColorNegate(_ImageBuiltin):
1447    """
1448    <dl>
1449      <dt>'ColorNegate[$image$]'
1450      <dd>returns the negative of $image$ in which colors have been negated.
1451
1452      <dt>'ColorNegate[$color$]'
1453      <dd>returns the negative of a color.
1454
1455      Yellow is RGBColor[1.0, 1.0, 0.0]
1456      >> ColorNegate[Yellow]
1457       = RGBColor[0., 0., 1.]
1458    </dl>
1459    """
1460
1461    def apply_for_image(self, image, evaluation):
1462        "ColorNegate[image_Image]"
1463        return image.filter(lambda im: PIL.ImageOps.invert(im))
1464
1465    def apply_for_color(self, color, evaluation):
1466        "ColorNegate[color_RGBColor]"
1467        # Get components
1468        r, g, b = [leaf.to_python() for leaf in color.leaves]
1469        # Invert
1470        r, g, b = (1.0 - r, 1.0 - g, 1.0 - b)
1471        # Reconstitute
1472        return Expression("RGBColor", Real(r), Real(g), Real(b))
1473
1474
1475class ColorSeparate(_ImageBuiltin):
1476    """
1477    <dl>
1478    <dt>'ColorSeparate[$image$]'
1479      <dd>Gives each channel of $image$ as a separate grayscale image.
1480    </dl>
1481    """
1482
1483    def apply(self, image, evaluation):
1484        "ColorSeparate[image_Image]"
1485        images = []
1486        pixels = image.pixels
1487        if len(pixels.shape) < 3:
1488            images.append(pixels)
1489        else:
1490            for i in range(pixels.shape[2]):
1491                images.append(Image(pixels[:, :, i], "Grayscale"))
1492        return Expression(SymbolList, *images)
1493
1494
1495class ColorCombine(_ImageBuiltin):
1496    """
1497    <dl>
1498    <dt>'ColorCombine[$channels$, $colorspace$]'
1499      <dd>Gives an image with $colorspace$ and the respective components described by the given channels.
1500    </dl>
1501
1502    >> ColorCombine[{{{1, 0}, {0, 0.75}}, {{0, 1}, {0, 0.25}}, {{0, 0}, {1, 0.5}}}, "RGB"]
1503     = -Image-
1504    """
1505
1506    def apply(self, channels, colorspace, evaluation):
1507        "ColorCombine[channels_List, colorspace_String]"
1508
1509        py_colorspace = colorspace.get_string_value()
1510        if py_colorspace not in known_colorspaces:
1511            return
1512
1513        numpy_channels = []
1514        for channel in channels.leaves:
1515            if not Expression("MatrixQ", channel).evaluate(evaluation).is_true():
1516                return
1517            numpy_channels.append(matrix_to_numpy(channel))
1518
1519        if not numpy_channels:
1520            return
1521
1522        if not all(x.shape == numpy_channels[0].shape for x in numpy_channels[1:]):
1523            return
1524
1525        return Image(numpy.dstack(numpy_channels), py_colorspace)
1526
1527
1528def _linearize(a):
1529    # this uses a vectorized binary search to compute
1530    # strictly sequential indices for all values in a.
1531
1532    orig_shape = a.shape
1533    a = a.reshape((functools.reduce(lambda x, y: x * y, a.shape),))  # 1 dimension
1534
1535    u = numpy.unique(a)
1536    n = len(u)
1537
1538    lower = numpy.ndarray(a.shape, dtype=numpy.int)
1539    lower.fill(0)
1540    upper = numpy.ndarray(a.shape, dtype=numpy.int)
1541    upper.fill(n - 1)
1542
1543    h = numpy.sort(u)
1544    q = n  # worst case partition size
1545
1546    while q > 2:
1547        m = numpy.right_shift(lower + upper, 1)
1548        f = a <= h[m]
1549        # (lower, m) vs (m + 1, upper)
1550        lower = numpy.where(f, lower, m + 1)
1551        upper = numpy.where(f, m, upper)
1552        q = (q + 1) // 2
1553
1554    return numpy.where(a == h[lower], lower, upper).reshape(orig_shape), n
1555
1556
1557class Colorize(_ImageBuiltin):
1558    """
1559    <dl>
1560    <dt>'Colorize[$values$]'
1561      <dd>returns an image where each number in the rectangular matrix $values$ is a pixel and each
1562      occurence of the same number is displayed in the same unique color, which is different from the
1563      colors of all non-identical numbers.
1564    <dt>'Colorize[$image$]'
1565      <dd>gives a colorized version of $image$.
1566    </dl>
1567
1568    >> Colorize[{{1.3, 2.1, 1.5}, {1.3, 1.3, 2.1}, {1.3, 2.1, 1.5}}]
1569     = -Image-
1570
1571    >> Colorize[{{1, 2}, {2, 2}, {2, 3}}, ColorFunction -> (Blend[{White, Blue}, #]&)]
1572     = -Image-
1573    """
1574
1575    options = {"ColorFunction": "Automatic"}
1576
1577    messages = {
1578        "cfun": "`1` is neither a gradient ColorData nor a pure function suitable as ColorFunction."
1579    }
1580
1581    def apply(self, values, evaluation, options):
1582        "Colorize[values_, OptionsPattern[%(name)s]]"
1583
1584        if isinstance(values, Image):
1585            pixels = values.grayscale().pixels
1586            matrix = pixels_as_ubyte(pixels.reshape(pixels.shape[:2]))
1587        else:
1588            if not Expression("MatrixQ", values).evaluate(evaluation).is_true():
1589                return
1590            matrix = matrix_to_numpy(values)
1591
1592        a, n = _linearize(matrix)
1593        # the maximum value for n is the number of pixels in a, which is acceptable and never too large.
1594
1595        color_function = self.get_option(options, "ColorFunction", evaluation)
1596        if (
1597            isinstance(color_function, Symbol)
1598            and color_function.get_name() == "System`Automatic"
1599        ):
1600            color_function = String("LakeColors")
1601
1602        from mathics.builtin.drawing.plot import gradient_palette
1603
1604        cmap = gradient_palette(color_function, n, evaluation)
1605        if not cmap:
1606            evaluation.message("Colorize", "cfun", color_function)
1607            return
1608
1609        s = (a.shape[0], a.shape[1], 1)
1610        p = numpy.transpose(numpy.array([cmap[i] for i in range(n)])[:, 0:3])
1611        return Image(
1612            numpy.concatenate([p[i][a].reshape(s) for i in range(3)], axis=2),
1613            color_space="RGB",
1614        )
1615
1616
1617class DominantColors(_ImageBuiltin):
1618    """
1619    <dl>
1620    <dt>'DominantColors[$image$]'
1621      <dd>gives a list of colors which are dominant in the given image.
1622    <dt>'DominantColors[$image$, $n$]'
1623      <dd>returns at most $n$ colors.
1624    <dt>'DominantColors[$image$, $n$, $prop$]'
1625      <dd>returns the given property $prop$, which may be "Color" (return RGB colors), "LABColor" (return
1626      LAB colors), "Count" (return the number of pixels a dominant color covers), "Coverage" (return the
1627      fraction of the image a dominant color covers), or "CoverageImage" (return a black and white image
1628      indicating with white the parts that are covered by a dominant color).
1629    </dl>
1630
1631    The option "ColorCoverage" specifies the minimum amount of coverage needed to include a dominant color
1632    in the result.
1633
1634    The option "MinColorDistance" specifies the distance (in LAB color space) up to which colors are merged
1635    and thus regarded as belonging to the same dominant color.
1636
1637    >> img = Import["ExampleData/lena.tif"]
1638     = -Image-
1639
1640    >> DominantColors[img]
1641     = {RGBColor[0.827451, 0.537255, 0.486275], RGBColor[0.87451, 0.439216, 0.45098], RGBColor[0.341176, 0.0705882, 0.254902], RGBColor[0.690196, 0.266667, 0.309804], RGBColor[0.533333, 0.192157, 0.298039], RGBColor[0.878431, 0.760784, 0.721569]}
1642
1643    >> DominantColors[img, 3]
1644     = {RGBColor[0.827451, 0.537255, 0.486275], RGBColor[0.87451, 0.439216, 0.45098], RGBColor[0.341176, 0.0705882, 0.254902]}
1645
1646    >> DominantColors[img, 3, "Coverage"]
1647     = {28579 / 131072, 751 / 4096, 23841 / 131072}
1648
1649    >> DominantColors[img, 3, "CoverageImage"]
1650     = {-Image-, -Image-, -Image-}
1651
1652    >> DominantColors[img, 3, "Count"]
1653     = {57158, 48064, 47682}
1654
1655    >> DominantColors[img, 2, "LABColor"]
1656     = {LABColor[0.646831, 0.279785, 0.193184], LABColor[0.608465, 0.443559, 0.195911]}
1657
1658    >> DominantColors[img, MinColorDistance -> 0.5]
1659     = {RGBColor[0.87451, 0.439216, 0.45098], RGBColor[0.341176, 0.0705882, 0.254902]}
1660
1661    >> DominantColors[img, ColorCoverage -> 0.15]
1662     = {RGBColor[0.827451, 0.537255, 0.486275], RGBColor[0.87451, 0.439216, 0.45098], RGBColor[0.341176, 0.0705882, 0.254902]}
1663    """
1664
1665    rules = {
1666        "DominantColors[image_Image, n_Integer, options___]": 'DominantColors[image, n, "Color", options]',
1667        "DominantColors[image_Image, options___]": 'DominantColors[image, 256, "Color", options]',
1668    }
1669
1670    options = {"ColorCoverage": "Automatic", "MinColorDistance": "Automatic"}
1671
1672    def apply(self, image, n, prop, evaluation, options):
1673        "DominantColors[image_Image, n_Integer, prop_String, OptionsPattern[%(name)s]]"
1674
1675        py_prop = prop.get_string_value()
1676        if py_prop not in ("Color", "LABColor", "Count", "Coverage", "CoverageImage"):
1677            return
1678
1679        color_coverage = self.get_option(options, "ColorCoverage", evaluation)
1680        min_color_distance = self.get_option(options, "MinColorDistance", evaluation)
1681
1682        if (
1683            isinstance(min_color_distance, Symbol)
1684            and min_color_distance.get_name() == "System`Automatic"
1685        ):
1686            py_min_color_distance = 0.15
1687        else:
1688            py_min_color_distance = min_color_distance.round_to_float()
1689            if py_min_color_distance is None:
1690                return
1691
1692        if (
1693            isinstance(color_coverage, Symbol)
1694            and color_coverage.get_name() == "System`Automatic"
1695        ):
1696            py_min_color_coverage = 0.05
1697            py_max_color_coverage = 1.0
1698        elif color_coverage.has_form("List", 2):
1699            py_min_color_coverage = color_coverage.leaves[0].round_to_float()
1700            py_max_color_coverage = color_coverage.leaves[1].round_to_float()
1701        else:
1702            py_min_color_coverage = color_coverage.round_to_float()
1703            py_max_color_coverage = 1.0
1704
1705        if py_min_color_coverage is None or py_max_color_coverage is None:
1706            return
1707
1708        at_most = n.get_int_value()
1709
1710        if at_most > 256:
1711            return
1712
1713        # reduce complexity by reducing to 256 colors. this is not uncommon; see Kiranyaz et al.,
1714        # "Perceptual Dominant Color Extraction by Multidimensional Particle Swarm Optimization":
1715        # "to reduce the computational complexity [...] a preprocessing step, which creates a
1716        # limited color palette in RGB color domain, is first performed."
1717
1718        im = (
1719            image.color_convert("RGB")
1720            .pil()
1721            .convert("P", palette=PIL.Image.ADAPTIVE, colors=256)
1722        )
1723        pixels = numpy.array(list(im.getdata()))
1724
1725        flat = numpy.array(list(im.getpalette())) / 255.0  # float values now
1726        rgb_palette = [flat[i : i + 3] for i in range(0, len(flat), 3)]  # group by 3
1727        lab_palette = [
1728            numpy.array(x) for x in convert_color(rgb_palette, "RGB", "LAB", False)
1729        ]
1730
1731        bins = numpy.bincount(pixels, minlength=len(rgb_palette))
1732        num_pixels = im.size[0] * im.size[1]
1733
1734        from mathics.algorithm.clusters import (
1735            agglomerate,
1736            PrecomputedDistances,
1737            FixedDistanceCriterion,
1738        )
1739
1740        norm = numpy.linalg.norm
1741
1742        def df(i, j):
1743            return norm(lab_palette[i] - lab_palette[j])
1744
1745        lab_distances = [df(i, j) for i in range(len(lab_palette)) for j in range(i)]
1746
1747        if py_prop == "LABColor":
1748            out_palette = lab_palette
1749            out_palette_head = "LABColor"
1750        else:
1751            out_palette = rgb_palette
1752            out_palette_head = "RGBColor"
1753
1754        dominant = agglomerate(
1755            (out_palette, bins),
1756            (FixedDistanceCriterion, {"merge_limit": py_min_color_distance}),
1757            PrecomputedDistances(lab_distances),
1758            mode="dominant",
1759        )
1760
1761        def result():
1762            min_count = max(0, int(num_pixels * py_min_color_coverage))
1763            max_count = min(num_pixels, int(num_pixels * py_max_color_coverage))
1764
1765            for prototype, count, members in dominant:
1766                if max_count >= count > min_count:
1767                    if py_prop == "Count":
1768                        yield Integer(count)
1769                    elif py_prop == "Coverage":
1770                        yield Rational(int(count), num_pixels)
1771                    elif py_prop == "CoverageImage":
1772                        mask = numpy.ndarray(shape=pixels.shape, dtype=numpy.bool)
1773                        mask.fill(0)
1774                        for i in members:
1775                            mask = mask | (pixels == i)
1776                        yield Image(mask.reshape(tuple(reversed(im.size))), "Grayscale")
1777                    else:
1778                        yield Expression(out_palette_head, *prototype)
1779
1780        return Expression(SymbolList, *itertools.islice(result(), 0, at_most))
1781
1782
1783# pixel access
1784
1785
1786class ImageData(_ImageBuiltin):
1787    """
1788    <dl>
1789    <dt>'ImageData[$image$]'
1790      <dd>gives a list of all color values of $image$ as a matrix.
1791    <dt>'ImageData[$image$, $stype$]'
1792      <dd>gives a list of color values in type $stype$.
1793    </dl>
1794
1795    >> img = Image[{{0.2, 0.4}, {0.9, 0.6}, {0.5, 0.8}}];
1796    >> ImageData[img]
1797     = {{0.2, 0.4}, {0.9, 0.6}, {0.5, 0.8}}
1798
1799    >> ImageData[img, "Byte"]
1800     = {{51, 102}, {229, 153}, {127, 204}}
1801
1802    >> ImageData[Image[{{0, 1}, {1, 0}, {1, 1}}], "Bit"]
1803     = {{0, 1}, {1, 0}, {1, 1}}
1804
1805    #> ImageData[img, "Bytf"]
1806     : Unsupported pixel format "Bytf".
1807     = ImageData[-Image-, Bytf]
1808    """
1809
1810    rules = {"ImageData[image_Image]": 'ImageData[image, "Real"]'}
1811
1812    messages = {"pixelfmt": 'Unsupported pixel format "``".'}
1813
1814    def apply(self, image, stype, evaluation):
1815        "ImageData[image_Image, stype_String]"
1816        pixels = image.pixels
1817        stype = stype.get_string_value()
1818        if stype == "Real":
1819            pixels = pixels_as_float(pixels)
1820        elif stype == "Byte":
1821            pixels = pixels_as_ubyte(pixels)
1822        elif stype == "Bit16":
1823            pixels = pixels_as_uint(pixels)
1824        elif stype == "Bit":
1825            pixels = pixels.astype(numpy.int)
1826        else:
1827            return evaluation.message("ImageData", "pixelfmt", stype)
1828        return from_python(numpy_to_matrix(pixels))
1829
1830
1831class ImageTake(_ImageBuiltin):
1832    """
1833    <dl>
1834    <dt>'ImageTake[$image$, $n$]'
1835      <dd>gives the first $n$ rows of $image$.
1836    <dt>'ImageTake[$image$, -$n$]'
1837      <dd>gives the last $n$ rows of $image$.
1838    <dt>'ImageTake[$image$, {$r1$, $r2$}]'
1839      <dd>gives rows $r1$, ..., $r2$ of $image$.
1840    <dt>'ImageTake[$image$, {$r1$, $r2$}, {$c1$, $c2$}]'
1841      <dd>gives a cropped version of $image$.
1842    </dl>
1843    """
1844
1845    def apply(self, image, n, evaluation):
1846        "ImageTake[image_Image, n_Integer]"
1847        py_n = n.get_int_value()
1848        if py_n >= 0:
1849            pixels = image.pixels[:py_n]
1850        elif py_n < 0:
1851            pixels = image.pixels[py_n:]
1852        return Image(pixels, image.color_space)
1853
1854    def _slice(self, image, i1, i2, axis):
1855        n = image.pixels.shape[axis]
1856        py_i1 = min(max(i1.get_int_value() - 1, 0), n - 1)
1857        py_i2 = min(max(i2.get_int_value() - 1, 0), n - 1)
1858
1859        def flip(pixels):
1860            if py_i1 > py_i2:
1861                return numpy_flip(pixels, axis)
1862            else:
1863                return pixels
1864
1865        return slice(min(py_i1, py_i2), 1 + max(py_i1, py_i2)), flip
1866
1867    def apply_rows(self, image, r1, r2, evaluation):
1868        "ImageTake[image_Image, {r1_Integer, r2_Integer}]"
1869        s, f = self._slice(image, r1, r2, 0)
1870        return Image(f(image.pixels[s]), image.color_space)
1871
1872    def apply_rows_cols(self, image, r1, r2, c1, c2, evaluation):
1873        "ImageTake[image_Image, {r1_Integer, r2_Integer}, {c1_Integer, c2_Integer}]"
1874        sr, fr = self._slice(image, r1, r2, 0)
1875        sc, fc = self._slice(image, c1, c2, 1)
1876        return Image(fc(fr(image.pixels[sr, sc])), image.color_space)
1877
1878
1879class PixelValue(_ImageBuiltin):
1880    """
1881    <dl>
1882    <dt>'PixelValue[$image$, {$x$, $y$}]'
1883      <dd>gives the value of the pixel at position {$x$, $y$} in $image$.
1884    </dl>
1885
1886    >> lena = Import["ExampleData/lena.tif"];
1887    >> PixelValue[lena, {1, 1}]
1888     = {0.321569, 0.0862745, 0.223529}
1889    #> {82 / 255, 22 / 255, 57 / 255} // N  (* pixel byte values from bottom left corner *)
1890     = {0.321569, 0.0862745, 0.223529}
1891
1892    #> PixelValue[lena, {0, 1}];
1893     : Padding not implemented for PixelValue.
1894    #> PixelValue[lena, {512, 1}]
1895     = {0.72549, 0.290196, 0.317647}
1896    #> PixelValue[lena, {513, 1}];
1897     : Padding not implemented for PixelValue.
1898    #> PixelValue[lena, {1, 0}];
1899     : Padding not implemented for PixelValue.
1900    #> PixelValue[lena, {1, 512}]
1901     = {0.886275, 0.537255, 0.490196}
1902    #> PixelValue[lena, {1, 513}];
1903     : Padding not implemented for PixelValue.
1904    """
1905
1906    messages = {"nopad": "Padding not implemented for PixelValue."}
1907
1908    def apply(self, image, x, y, evaluation):
1909        "PixelValue[image_Image, {x_?RealNumberQ, y_?RealNumberQ}]"
1910        x = int(x.round_to_float())
1911        y = int(y.round_to_float())
1912        height = image.pixels.shape[0]
1913        width = image.pixels.shape[1]
1914        if not (1 <= x <= width and 1 <= y <= height):
1915            return evaluation.message("PixelValue", "nopad")
1916        pixel = pixels_as_float(image.pixels)[height - y, x - 1]
1917        if isinstance(pixel, (numpy.ndarray, numpy.generic, list)):
1918            return Expression(SymbolList, *[MachineReal(float(x)) for x in list(pixel)])
1919        else:
1920            return MachineReal(float(pixel))
1921
1922
1923class PixelValuePositions(_ImageBuiltin):
1924    """
1925    <dl>
1926    <dt>'PixelValuePositions[$image$, $val$]'
1927      <dd>gives the positions of all pixels in $image$ that have value $val$.
1928    </dl>
1929
1930    >> PixelValuePositions[Image[{{0, 1}, {1, 0}, {1, 1}}], 1]
1931     = {{1, 1}, {1, 2}, {2, 1}, {2, 3}}
1932
1933    >> PixelValuePositions[Image[{{0.2, 0.4}, {0.9, 0.6}, {0.3, 0.8}}], 0.5, 0.15]
1934     = {{2, 2}, {2, 3}}
1935
1936    >> img = Import["ExampleData/lena.tif"];
1937    >> PixelValuePositions[img, 3 / 255, 0.5 / 255]
1938     = {{180, 192, 2}, {181, 192, 2}, {181, 193, 2}, {188, 204, 2}, {265, 314, 2}, {364, 77, 2}, {365, 72, 2}, {365, 73, 2}, {365, 77, 2}, {366, 70, 2}, {367, 65, 2}}
1939    >> PixelValue[img, {180, 192}]
1940     = {0.25098, 0.0117647, 0.215686}
1941    """
1942
1943    rules = {
1944        "PixelValuePositions[image_Image, val_?RealNumberQ]": "PixelValuePositions[image, val, 0]"
1945    }
1946
1947    def apply(self, image, val, d, evaluation):
1948        "PixelValuePositions[image_Image, val_?RealNumberQ, d_?RealNumberQ]"
1949        val = val.round_to_float()
1950        d = d.round_to_float()
1951
1952        positions = numpy.argwhere(
1953            numpy.isclose(pixels_as_float(image.pixels), val, atol=d, rtol=0)
1954        )
1955
1956        # python indexes from 0 at top left -> indices from 1 starting at bottom left
1957        # if single channel then ommit channel indices
1958        height = image.pixels.shape[0]
1959        if image.pixels.shape[2] == 1:
1960            result = sorted((j + 1, height - i) for i, j, k in positions.tolist())
1961        else:
1962            result = sorted(
1963                (j + 1, height - i, k + 1) for i, j, k in positions.tolist()
1964            )
1965        return Expression(SymbolList, *(Expression(SymbolList, *arg) for arg in result))
1966
1967
1968# image attribute queries
1969
1970
1971class ImageDimensions(_ImageBuiltin):
1972    """
1973    <dl>
1974    <dt>'ImageDimensions[$image$]'
1975      <dd>Returns the dimensions of $image$ in pixels.
1976    </dl>
1977
1978    >> lena = Import["ExampleData/lena.tif"];
1979    >> ImageDimensions[lena]
1980     = {512, 512}
1981
1982    >> ImageDimensions[RandomImage[1, {50, 70}]]
1983     = {50, 70}
1984
1985    #> Image[{{0, 1}, {1, 0}, {1, 1}}] // ImageDimensions
1986     = {2, 3}
1987    #> Image[{{0.2, 0.4}, {0.9, 0.6}, {0.3, 0.8}}] // ImageDimensions
1988     = {2, 3}
1989    """
1990
1991    def apply(self, image, evaluation):
1992        "ImageDimensions[image_Image]"
1993        return Expression(SymbolList, *image.dimensions())
1994
1995
1996class ImageAspectRatio(_ImageBuiltin):
1997    """
1998    <dl>
1999    <dt>'ImageAspectRatio[$image$]'
2000      <dd>gives the aspect ratio of $image$.
2001    </dl>
2002
2003    >> img = Import["ExampleData/lena.tif"];
2004    >> ImageAspectRatio[img]
2005     = 1
2006
2007    >> ImageAspectRatio[Image[{{0, 1}, {1, 0}, {1, 1}}]]
2008     = 3 / 2
2009    """
2010
2011    def apply(self, image, evaluation):
2012        "ImageAspectRatio[image_Image]"
2013        dim = image.dimensions()
2014        return Expression("Divide", Integer(dim[1]), Integer(dim[0]))
2015
2016
2017class ImageChannels(_ImageBuiltin):
2018    """
2019    <dl>
2020    <dt>'ImageChannels[$image$]'
2021      <dd>gives the number of channels in $image$.
2022    </dl>
2023
2024    >> ImageChannels[Image[{{0, 1}, {1, 0}}]]
2025     = 1
2026
2027    >> img = Import["ExampleData/lena.tif"];
2028    >> ImageChannels[img]
2029     = 3
2030    """
2031
2032    def apply(self, image, evaluation):
2033        "ImageChannels[image_Image]"
2034        return Integer(image.channels())
2035
2036
2037class ImageType(_ImageBuiltin):
2038    """
2039    <dl>
2040    <dt>'ImageType[$image$]'
2041      <dd>gives the interval storage type of $image$, e.g. "Real", "Bit32", or "Bit".
2042    </dl>
2043
2044    >> img = Import["ExampleData/lena.tif"];
2045    >> ImageType[img]
2046     = Byte
2047
2048    >> ImageType[Image[{{0, 1}, {1, 0}}]]
2049     = Real
2050
2051    >> ImageType[Binarize[img]]
2052     = Bit
2053
2054    """
2055
2056    def apply(self, image, evaluation):
2057        "ImageType[image_Image]"
2058        return String(image.storage_type())
2059
2060
2061class BinaryImageQ(_ImageTest):
2062    """
2063    <dl>
2064    <dt>'BinaryImageQ[$image]'
2065      <dd>returns True if the pixels of $image are binary bit values, and False otherwise.
2066    </dl>
2067
2068    >> img = Import["ExampleData/lena.tif"];
2069    S> BinaryImageQ[img]
2070     = False
2071
2072    S> BinaryImageQ[Binarize[img]]
2073     = True
2074    """
2075
2076    def test(self, expr):
2077        return isinstance(expr, Image) and expr.storage_type() == "Bit"
2078
2079
2080# Image core classes
2081
2082
2083def _image_pixels(matrix):
2084    try:
2085        pixels = numpy.array(matrix, dtype="float64")
2086    except ValueError:  # irregular array, e.g. {{0, 1}, {0, 1, 1}}
2087        return None
2088    shape = pixels.shape
2089    if len(shape) == 2 or (len(shape) == 3 and shape[2] in (1, 3, 4)):
2090        return pixels
2091    else:
2092        return None
2093
2094
2095class ImageQ(_ImageTest):
2096    """
2097    <dl>
2098    <dt>'ImageQ[Image[$pixels]]'
2099      <dd>returns True if $pixels has dimensions from which an Image can be constructed, and False otherwise.
2100    </dl>
2101
2102    >> ImageQ[Image[{{0, 1}, {1, 0}}]]
2103     = True
2104
2105    >> ImageQ[Image[{{{0, 0, 0}, {0, 1, 0}}, {{0, 1, 0}, {0, 1, 1}}}]]
2106     = True
2107
2108    >> ImageQ[Image[{{{0, 0, 0}, {0, 1}}, {{0, 1, 0}, {0, 1, 1}}}]]
2109     = False
2110
2111    >> ImageQ[Image[{1, 0, 1}]]
2112     = False
2113
2114    >> ImageQ["abc"]
2115     = False
2116    """
2117
2118    def test(self, expr):
2119        return isinstance(expr, Image)
2120
2121
2122class ImageBox(BoxConstruct):
2123    def boxes_to_text(self, leaves=None, **options):
2124        return "-Image-"
2125
2126    def boxes_to_mathml(self, leaves=None, **options):
2127        if leaves is None:
2128            leaves = self._leaves
2129        # see https://tools.ietf.org/html/rfc2397
2130        return '<mglyph src="%s" width="%dpx" height="%dpx" />' % (
2131            leaves[0].get_string_value(),
2132            leaves[1].get_int_value(),
2133            leaves[2].get_int_value(),
2134        )
2135
2136    def boxes_to_tex(self, leaves=None, **options):
2137        return "-Image-"
2138
2139
2140class Image(Atom):
2141    def __init__(self, pixels, color_space, metadata={}, **kwargs):
2142        super(Image, self).__init__(**kwargs)
2143        if len(pixels.shape) == 2:
2144            pixels = pixels.reshape(list(pixels.shape) + [1])
2145        self.pixels = pixels
2146        self.color_space = color_space
2147        self.metadata = metadata
2148
2149    def filter(self, f):  # apply PIL filters component-wise
2150        pixels = self.pixels
2151        n = pixels.shape[2]
2152        channels = [
2153            f(PIL.Image.fromarray(c, "L")) for c in (pixels[:, :, i] for i in range(n))
2154        ]
2155        return Image(numpy.dstack(channels), self.color_space)
2156
2157    def pil(self):
2158        # see http://pillow.readthedocs.io/en/3.1.x/handbook/concepts.html#concept-modes
2159        n = self.channels()
2160
2161        if n == 1:
2162            dtype = self.pixels.dtype
2163
2164            if dtype in (numpy.float32, numpy.float64):
2165                pixels = self.pixels.astype(numpy.float32)
2166                mode = "F"
2167            elif dtype == numpy.uint32:
2168                pixels = self.pixels
2169                mode = "I"
2170            else:
2171                pixels = pixels_as_ubyte(self.pixels)
2172                mode = "L"
2173
2174            pixels = pixels.reshape(pixels.shape[:2])
2175        elif n == 3:
2176            if self.color_space == "LAB":
2177                mode = "LAB"
2178                pixels = self.pixels
2179            elif self.color_space == "HSB":
2180                mode = "HSV"
2181                pixels = self.pixels
2182            elif self.color_space == "RGB":
2183                mode = "RGB"
2184                pixels = self.pixels
2185            else:
2186                mode = "RGB"
2187                pixels = self.color_convert("RGB").pixels
2188
2189            pixels = pixels_as_ubyte(pixels)
2190        elif n == 4:
2191            if self.color_space == "CMYK":
2192                mode = "CMYK"
2193                pixels = self.pixels
2194            elif self.color_space == "RGB":
2195                mode = "RGBA"
2196                pixels = self.pixels
2197            else:
2198                mode = "RGBA"
2199                pixels = self.color_convert("RGB").pixels
2200
2201            pixels = pixels_as_ubyte(pixels)
2202        else:
2203            raise NotImplementedError
2204
2205        return PIL.Image.fromarray(pixels, mode)
2206
2207    def color_convert(self, to_color_space, preserve_alpha=True):
2208        if to_color_space == self.color_space and preserve_alpha:
2209            return self
2210        else:
2211            pixels = pixels_as_float(self.pixels)
2212            converted = convert_color(
2213                pixels, self.color_space, to_color_space, preserve_alpha
2214            )
2215            if converted is None:
2216                return None
2217            return Image(converted, to_color_space)
2218
2219    def grayscale(self):
2220        return self.color_convert("Grayscale")
2221
2222    def atom_to_boxes(self, f, evaluation):
2223        pixels = pixels_as_ubyte(self.color_convert("RGB", True).pixels)
2224        shape = pixels.shape
2225
2226        width = shape[1]
2227        height = shape[0]
2228        scaled_width = width
2229        scaled_height = height
2230
2231        if len(shape) >= 3 and shape[2] == 4:
2232            pixels_format = "RGBA"
2233        else:
2234            pixels_format = "RGB"
2235
2236        pillow = PIL.Image.fromarray(pixels, pixels_format)
2237
2238        # if the image is very small, scale it up using nearest neighbour.
2239        min_size = 128
2240        if width < min_size and height < min_size:
2241            scale = min_size / max(width, height)
2242            scaled_width = int(scale * width)
2243            scaled_height = int(scale * height)
2244            pillow = pillow.resize(
2245                (scaled_height, scaled_width), resample=PIL.Image.NEAREST
2246            )
2247
2248        with warnings.catch_warnings():
2249            warnings.simplefilter("ignore")
2250
2251            stream = BytesIO()
2252            pillow.save(stream, format="png")
2253            stream.seek(0)
2254            contents = stream.read()
2255            stream.close()
2256
2257        encoded = base64.b64encode(contents)
2258        encoded = b"data:image/png;base64," + encoded
2259
2260        return ImageBox(String(encoded), Integer(scaled_width), Integer(scaled_height))
2261
2262    def __str__(self):
2263        return "-Image-"
2264
2265    def do_copy(self):
2266        return Image(self.pixels, self.color_space, self.metadata)
2267
2268    def default_format(self, evaluation, form):
2269        return "-Image-"
2270
2271    def get_sort_key(self, pattern_sort=False):
2272        if pattern_sort:
2273            return super(Image, self).get_sort_key(True)
2274        else:
2275            return hash(self)
2276
2277    def sameQ(self, other) -> bool:
2278        """Mathics SameQ"""
2279        if not isinstance(other, Image):
2280            return False
2281        if self.color_space != other.color_space or self.metadata != other.metadata:
2282            return False
2283        return numpy.array_equal(self.pixels, other.pixels)
2284
2285    def to_python(self, *args, **kwargs):
2286        return self.pixels
2287
2288    def __hash__(self):
2289        return hash(
2290            (
2291                "Image",
2292                self.pixels.tobytes(),
2293                self.color_space,
2294                frozenset(self.metadata.items()),
2295            )
2296        )
2297
2298    def dimensions(self):
2299        shape = self.pixels.shape
2300        return shape[1], shape[0]
2301
2302    def channels(self):
2303        return self.pixels.shape[2]
2304
2305    def storage_type(self):
2306        dtype = self.pixels.dtype
2307        if dtype in (numpy.float32, numpy.float64):
2308            return "Real"
2309        elif dtype == numpy.uint32:
2310            return "Bit32"
2311        elif dtype == numpy.uint16:
2312            return "Bit16"
2313        elif dtype == numpy.uint8:
2314            return "Byte"
2315        elif dtype == numpy.bool:
2316            return "Bit"
2317        else:
2318            return str(dtype)
2319
2320    def options(self):
2321        return Expression(
2322            "List",
2323            Expression(SymbolRule, String("ColorSpace"), String(self.color_space)),
2324            Expression(SymbolRule, String("MetaInformation"), self.metadata),
2325        )
2326
2327
2328class ImageAtom(AtomBuiltin):
2329    """
2330    #> Image[{{{1,1,0},{0,1,1}}, {{1,0,1},{1,1,0}}}]
2331     = -Image-
2332
2333    #> Image[{{{0,0,0,0.25},{0,0,0,0.5}}, {{0,0,0,0.5},{0,0,0,0.75}}}]
2334     = -Image-
2335    """
2336
2337    requires = _image_requires
2338
2339    def apply_create(self, array, evaluation):
2340        "Image[array_]"
2341        pixels = _image_pixels(array.to_python())
2342        if pixels is not None:
2343            shape = pixels.shape
2344            is_rgb = len(shape) == 3 and shape[2] in (3, 4)
2345            return Image(pixels.clip(0, 1), "RGB" if is_rgb else "Grayscale")
2346        else:
2347            return Expression("Image", array)
2348
2349
2350# complex operations
2351
2352
2353class TextRecognize(Builtin):
2354    """
2355    <dl>
2356    <dt>'TextRecognize[{$image$}]'
2357      <dd>Recognizes text in $image$ and returns it as string.
2358    </dl>
2359    """
2360
2361    requires = _image_requires + ("pyocr",)
2362
2363    messages = {
2364        "tool": "No text recognition tools were found in the paths available to the Mathics kernel.",
2365        "langinv": "No language data for `1` is available.",
2366        "lang": "Language `1` is not supported in your installation of `2`. Please install it.",
2367    }
2368
2369    options = {"Language": '"English"'}
2370
2371    def apply(self, image, evaluation, options):
2372        "TextRecognize[image_Image, OptionsPattern[%(name)s]]"
2373        import pyocr
2374        from mathics.builtin.codetables import iso639_3
2375
2376        language = self.get_option(options, "Language", evaluation)
2377        if not isinstance(language, String):
2378            return
2379        py_language = language.get_string_value()
2380        py_language_code = iso639_3.get(py_language)
2381
2382        if py_language_code is None:
2383            evaluation.message("TextRecognize", "langcode", py_language)
2384            return
2385
2386        tools = pyocr.get_available_tools()
2387        if not tools:
2388            evaluation.message("TextRecognize", "tool")
2389            return
2390        best_tool = tools[0]
2391
2392        langs = best_tool.get_available_languages()
2393        if py_language_code not in langs:
2394            # if we use Tesseract, then this means copying the necessary language files from
2395            # https://github.com/tesseract-ocr/tessdatainstalling to tessdata, which is
2396            # usually located at /usr/share/tessdata or similar, but there's no API to query
2397            # the exact location, so we cannot, for now, give a better message.
2398
2399            evaluation.message(
2400                "TextRecognize", "lang", py_language, best_tool.get_name()
2401            )
2402            return
2403
2404        import pyocr.builders
2405
2406        text = best_tool.image_to_string(
2407            image.pil(), lang=py_language_code, builder=pyocr.builders.TextBuilder()
2408        )
2409
2410        if isinstance(text, (list, tuple)):
2411            text = "\n".join(text)
2412
2413        return String(text)
2414
2415
2416class WordCloud(Builtin):
2417    """
2418    <dl>
2419    <dt>'WordCloud[{$word1$, $word2$, ...}]'
2420      <dd>Gives a word cloud with the given list of words.
2421    <dt>'WordCloud[{$weight1$ -> $word1$, $weight2$ -> $word2$, ...}]'
2422      <dd>Gives a word cloud with the words weighted using the given weights.
2423    <dt>'WordCloud[{$weight1$, $weight2$, ...} -> {$word1$, $word2$, ...}]'
2424      <dd>Also gives a word cloud with the words weighted using the given weights.
2425    <dt>'WordCloud[{{$word1$, $weight1$}, {$word2$, $weight2$}, ...}]'
2426      <dd>Gives a word cloud with the words weighted using the given weights.
2427    </dl>
2428
2429    >> WordCloud[StringSplit[Import["ExampleData/EinsteinSzilLetter.txt"]]]
2430     = -Image-
2431
2432    >> WordCloud[Range[50] -> ToString /@ Range[50]]
2433     = -Image-
2434    """
2435
2436    requires = _image_requires + ("wordcloud",)
2437
2438    options = {"IgnoreCase": "True", "ImageSize": "Automatic", "MaxItems": "Automatic"}
2439
2440    # this is the palettable.colorbrewer.qualitative.Dark2_8 palette
2441    default_colors = (
2442        (27, 158, 119),
2443        (217, 95, 2),
2444        (117, 112, 179),
2445        (231, 41, 138),
2446        (102, 166, 30),
2447        (230, 171, 2),
2448        (166, 118, 29),
2449        (102, 102, 102),
2450    )
2451
2452    def apply_words_weights(self, weights, words, evaluation, options):
2453        "WordCloud[weights_List -> words_List, OptionsPattern[%(name)s]]"
2454        if len(weights.leaves) != len(words.leaves):
2455            return
2456
2457        def weights_and_words():
2458            for weight, word in zip(weights.leaves, words.leaves):
2459                yield weight.round_to_float(), word.get_string_value()
2460
2461        return self._word_cloud(weights_and_words(), evaluation, options)
2462
2463    def apply_words(self, words, evaluation, options):
2464        "WordCloud[words_List, OptionsPattern[%(name)s]]"
2465
2466        if not words:
2467            return
2468        elif isinstance(words.leaves[0], String):
2469
2470            def weights_and_words():
2471                for word in words.leaves:
2472                    yield 1, word.get_string_value()
2473
2474        else:
2475
2476            def weights_and_words():
2477                for word in words.leaves:
2478                    if len(word.leaves) != 2:
2479                        raise ValueError
2480
2481                    head_name = word.get_head_name()
2482                    if head_name == "System`Rule":
2483                        weight, s = word.leaves
2484                    elif head_name == "System`List":
2485                        s, weight = word.leaves
2486                    else:
2487                        raise ValueError
2488
2489                    yield weight.round_to_float(), s.get_string_value()
2490
2491        try:
2492            return self._word_cloud(weights_and_words(), evaluation, options)
2493        except ValueError:
2494            return
2495
2496    def _word_cloud(self, words, evaluation, options):
2497        ignore_case = self.get_option(options, "IgnoreCase", evaluation) == Symbol(
2498            "True"
2499        )
2500
2501        freq = defaultdict(int)
2502        for py_weight, py_word in words:
2503            if py_word is None or py_weight is None:
2504                return
2505            key = py_word.lower() if ignore_case else py_word
2506            freq[key] += py_weight
2507
2508        max_items = self.get_option(options, "MaxItems", evaluation)
2509        if isinstance(max_items, Integer):
2510            py_max_items = max_items.get_int_value()
2511        else:
2512            py_max_items = 200
2513
2514        image_size = self.get_option(options, "ImageSize", evaluation)
2515        if image_size == Symbol("Automatic"):
2516            py_image_size = (800, 600)
2517        elif (
2518            image_size.get_head_name() == "System`List" and len(image_size.leaves) == 2
2519        ):
2520            py_image_size = []
2521            for leaf in image_size.leaves:
2522                if not isinstance(leaf, Integer):
2523                    return
2524                py_image_size.append(leaf.get_int_value())
2525        elif isinstance(image_size, Integer):
2526            size = image_size.get_int_value()
2527            py_image_size = (size, size)
2528        else:
2529            return
2530
2531        # inspired by http://minimaxir.com/2016/05/wordclouds/
2532        import random
2533        import os
2534
2535        def color_func(
2536            word, font_size, position, orientation, random_state=None, **kwargs
2537        ):
2538            return self.default_colors[random.randint(0, 7)]
2539
2540        font_base_path = os.path.dirname(os.path.abspath(__file__)) + "/../fonts/"
2541
2542        font_path = os.path.realpath(font_base_path + "AmaticSC-Bold.ttf")
2543        if not os.path.exists(font_path):
2544            font_path = None
2545
2546        from wordcloud import WordCloud
2547
2548        wc = WordCloud(
2549            width=py_image_size[0],
2550            height=py_image_size[1],
2551            font_path=font_path,
2552            max_font_size=300,
2553            mode="RGB",
2554            background_color="white",
2555            max_words=py_max_items,
2556            color_func=color_func,
2557            random_state=42,
2558            stopwords=set(),
2559        )
2560        wc.generate_from_frequencies(freq)
2561
2562        image = wc.to_image()
2563        return Image(numpy.array(image), "RGB")
2564