diff --git a/.appveyor.yml b/.appveyor.yml index 442f0f445166..b5efd3af0256 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -60,7 +60,7 @@ install: # if conda-forge gets a new pyqt, it might be nice to install it as well to have more backends # https://github.com/conda-forge/conda-forge.github.io/issues/157#issuecomment-223536381 - conda create -q -n test-environment python=%PYTHON_VERSION% - freetype=2.6 "libpng>=1.6.21,<1.7" zlib=1.2 tk=8.5 + freetype=2.6 zlib=1.2 tk=8.5 pip setuptools numpy sphinx tornado - activate test-environment - echo %PYTHON_VERSION% %TARGET_ARCH% @@ -81,11 +81,9 @@ test_script: # Now build the thing.. - set LINK=/LIBPATH:%cd%\lib - pip install -ve . - # these should show no z, png, or freetype dll... + # these should show no z or freetype dll... - set "DUMPBIN=%VS140COMNTOOLS%\..\..\VC\bin\dumpbin.exe" - 'if x%MPLSTATICBUILD% == xTrue "%DUMPBIN%" /DEPENDENTS lib\matplotlib\ft2font*.pyd | findstr freetype.*.dll && exit /b 1 || exit /b 0' - - 'if x%MPLSTATICBUILD% == xTrue "%DUMPBIN%" /DEPENDENTS lib\matplotlib\_png*.pyd | findstr z.*.dll && exit /b 1 || exit /b 0' - - 'if x%MPLSTATICBUILD% == xTrue "%DUMPBIN%" /DEPENDENTS lib\matplotlib\_png*.pyd | findstr png.*.dll && exit /b 1 || exit /b 0' # this are optional dependencies so that we don't skip so many tests... - if x%TEST_ALL% == xyes conda install -q ffmpeg inkscape miktex pillow diff --git a/INSTALL.rst b/INSTALL.rst index 3d64becf80f5..2f68290c0a09 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -99,7 +99,7 @@ toolchain is prefixed. This may be used for cross compiling. :: export PKG_CONFIG=x86_64-pc-linux-gnu-pkg-config Once you have satisfied the requirements detailed below (mainly -Python, NumPy, libpng and FreeType), you can build Matplotlib. +Python, NumPy, and FreeType), you can build Matplotlib. :: cd matplotlib @@ -121,7 +121,6 @@ Matplotlib requires the following dependencies: * `Python `_ (>= 3.6) * `FreeType `_ (>= 2.3) -* `libpng `_ (>= 1.2) * `NumPy `_ (>= 1.11) * `setuptools `_ * `cycler `_ (>= 0.10.0) @@ -177,8 +176,8 @@ etc., you can install the following: .. _pkg-config: https://www.freedesktop.org/wiki/Software/pkg-config/ If not using pkg-config (in particular on Windows), you may need to set the - include path (to the FreeType, libpng, and zlib headers) and link path (to - the FreeType, libpng, and zlib libraries) explicitly, if they are not in + include path (to the FreeType and zlib headers) and link path (to + the FreeType and zlib libraries) explicitly, if they are not in standard locations. This can be done using standard environment variables -- on Linux and OSX: @@ -195,8 +194,8 @@ etc., you can install the following: set LINK=/LIBPATH:C:\directory\containing\freetype.lib ... where ``...`` means "also give, in the same format, the directories - containing ``png.h`` and ``zlib.h`` for the include path, and for - ``libpng.so``/``png.lib`` and ``libz.so``/``z.lib`` for the link path." + containing ``zlib.h`` for the include path, and for + ``libz.so``/``z.lib`` for the link path." .. note:: @@ -237,20 +236,20 @@ Building on macOS ----------------- The build situation on macOS is complicated by the various places one -can get the libpng and FreeType requirements (MacPorts, Fink, +can get FreeType (MacPorts, Fink, /usr/X11R6), the different architectures (e.g., x86, ppc, universal), and the different macOS versions (e.g., 10.4 and 10.5). We recommend that you build the way we do for the macOS release: get the source from the tarball or the git repository and install the required dependencies through a third-party package manager. Two widely used package managers are Homebrew, and MacPorts. -The following example illustrates how to install libpng and FreeType using +The following example illustrates how to install FreeType using ``brew``:: - brew install libpng freetype pkg-config + brew install freetype pkg-config If you are using MacPorts, execute the following instead:: - port install libpng freetype pkgconfig + port install freetype pkgconfig After installing the above requirements, install Matplotlib from source by executing:: @@ -274,7 +273,7 @@ https://packaging.python.org/guides/packaging-binary-extensions/#setting-up-a-bu for how to set up a build environment. Since there is no canonical Windows package manager, the methods for building -FreeType, zlib, and libpng from source code are documented as a build script +FreeType and zlib from source code are documented as a build script at `matplotlib-winbuild `_. There are a few possibilities to build Matplotlib on Windows: @@ -290,17 +289,16 @@ Wheel builds using conda packages ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This is a wheel build, but we use conda packages to get all the requirements. -The binary requirements (png, FreeType,...) are statically linked and therefore -not needed during the wheel install. +FreeType is statically linked and therefore not needed during the wheel install. Set up the conda environment. Note, if you want a qt backend, add ``pyqt`` to the list of conda packages. :: - conda create -n "matplotlib_build" python=3.7 numpy python-dateutil pyparsing tornado cycler tk libpng zlib freetype + conda create -n "matplotlib_build" python=3.7 numpy python-dateutil pyparsing tornado cycler tk zlib freetype conda activate matplotlib_build - # force the build against static libpng and zlib libraries + # force the build against static zlib libraries set MPLSTATICBUILD=True python setup.py bdist_wheel diff --git a/ci/azure-pipelines-steps.yml b/ci/azure-pipelines-steps.yml index f8b2a58fe3eb..5331806440d7 100644 --- a/ci/azure-pipelines-steps.yml +++ b/ci/azure-pipelines-steps.yml @@ -21,12 +21,8 @@ steps: displayName: 'Use latest available Nuget' - script: | - nuget install libpng-msvc14-x64 -ExcludeVersion -OutputDirectory "$(build.BinariesDirectory)" nuget install zlib-msvc14-x64 -ExcludeVersion -OutputDirectory "$(build.BinariesDirectory)" - echo ##vso[task.prependpath]$(build.BinariesDirectory)\libpng-msvc14-x64\build\native\bin_release echo ##vso[task.prependpath]$(build.BinariesDirectory)\zlib-msvc14-x64\build\native\bin_release - echo ##vso[task.setvariable variable=CL]/I$(build.BinariesDirectory)\libpng-msvc14-x64\build\native\include /I$(build.BinariesDirectory)\zlib-msvc14-x64\build\native\include - echo ##vso[task.setvariable variable=LINK]/LIBPATH:$(build.BinariesDirectory)\libpng-msvc14-x64\build\native\lib_release /LIBPATH:$(build.BinariesDirectory)\zlib-msvc14-x64\build\native\lib_release displayName: 'Install dependencies with nuget' diff --git a/doc/api/next_api_changes/development.rst b/doc/api/next_api_changes/development.rst index 1cb1679c48c1..389cff4f1067 100644 --- a/doc/api/next_api_changes/development.rst +++ b/doc/api/next_api_changes/development.rst @@ -1,2 +1,13 @@ Development changes ------------------- + +Matplotlib now uses Pillow to save and read pngs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The builtin png encoder and decoder has been removed, and Pillow is now a +dependency. Note that when reading 16-bit RGB(A) images, Pillow truncates them +to 8-bit precision, whereas the old builtin decoder kept the full precision. + +The deprecated wx backend (not wxagg!) now always uses wx's builtin jpeg and +tiff support rather than relying on Pillow for writing these formats; this +behavior is consistent with wx's png output. diff --git a/doc/devel/min_dep_policy.rst b/doc/devel/min_dep_policy.rst index c27761deff0d..19ddd13672aa 100644 --- a/doc/devel/min_dep_policy.rst +++ b/doc/devel/min_dep_policy.rst @@ -60,7 +60,7 @@ minimum Python and numpy. System and C-dependencies ========================= -For system or c-dependencies (libpng, freetype, GUI frameworks, latex, +For system or C-dependencies (FreeType, GUI frameworks, LaTeX, gs, ffmpeg) support as old as practical. These can be difficult to install for end-users and we want to be usable on as many systems as possible. We will bump these on a case-by-case basis. diff --git a/examples/misc/image_thumbnail_sgskip.py b/examples/misc/image_thumbnail_sgskip.py index ae82e616743b..85ebbdc08b61 100644 --- a/examples/misc/image_thumbnail_sgskip.py +++ b/examples/misc/image_thumbnail_sgskip.py @@ -4,10 +4,10 @@ =============== You can use matplotlib to generate thumbnails from existing images. -matplotlib natively supports PNG files on the input side, and other -image types transparently if your have PIL installed - +Matplotlib relies on Pillow_ for reading images, and thus supports all formats +supported by Pillow. +.. _Pillow: http://python-pillow.org/ """ # build thumbnails of all images in a directory diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 762572b211f1..ff818ab9eb06 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -543,10 +543,6 @@ def cleanup(self): class PillowWriter(MovieWriter): @classmethod def isAvailable(cls): - try: - import PIL - except ImportError: - return False return True def __init__(self, *args, **kwargs): diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index f52e43be4aa3..47b10a327e86 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -53,42 +53,37 @@ from matplotlib.transforms import Affine2D from matplotlib.path import Path -try: - from PIL import PILLOW_VERSION - from distutils.version import LooseVersion - if LooseVersion(PILLOW_VERSION) >= "3.4": - _has_pil = True - else: - _has_pil = False - del PILLOW_VERSION -except ImportError: - _has_pil = False _log = logging.getLogger(__name__) - _default_filetypes = { - 'ps': 'Postscript', 'eps': 'Encapsulated Postscript', + 'jpg': 'Joint Photographic Experts Group', + 'jpeg': 'Joint Photographic Experts Group', 'pdf': 'Portable Document Format', 'pgf': 'PGF code for LaTeX', 'png': 'Portable Network Graphics', + 'ps': 'Postscript', 'raw': 'Raw RGBA bitmap', 'rgba': 'Raw RGBA bitmap', 'svg': 'Scalable Vector Graphics', - 'svgz': 'Scalable Vector Graphics' + 'svgz': 'Scalable Vector Graphics', + 'tif': 'Tagged Image File Format', + 'tiff': 'Tagged Image File Format', } - - _default_backends = { - 'ps': 'matplotlib.backends.backend_ps', 'eps': 'matplotlib.backends.backend_ps', + 'jpg': 'matplotlib.backends.backend_agg', + 'jpeg': 'matplotlib.backends.backend_agg', 'pdf': 'matplotlib.backends.backend_pdf', 'pgf': 'matplotlib.backends.backend_pgf', 'png': 'matplotlib.backends.backend_agg', + 'ps': 'matplotlib.backends.backend_ps', 'raw': 'matplotlib.backends.backend_agg', 'rgba': 'matplotlib.backends.backend_agg', 'svg': 'matplotlib.backends.backend_svg', 'svgz': 'matplotlib.backends.backend_svg', + 'tif': 'matplotlib.backends.backend_agg', + 'tiff': 'matplotlib.backends.backend_agg', } @@ -1604,17 +1599,6 @@ class FigureCanvasBase: fixed_dpi = None filetypes = _default_filetypes - if _has_pil: - # JPEG support - register_backend('jpg', 'matplotlib.backends.backend_agg', - 'Joint Photographic Experts Group') - register_backend('jpeg', 'matplotlib.backends.backend_agg', - 'Joint Photographic Experts Group') - # TIFF support - register_backend('tif', 'matplotlib.backends.backend_agg', - 'Tagged Image File Format') - register_backend('tiff', 'matplotlib.backends.backend_agg', - 'Tagged Image File Format') @cbook._classproperty def supports_blit(cls): diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 73dea192b392..2d3ec989f1c3 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -8,7 +8,7 @@ * linewidth * lines, rectangles, ellipses * clipping to a rectangle - * output to RGBA and PNG, optionally JPEG and TIFF + * output to RGBA and Pillow-supported image formats * alpha blending * DPI scaling properly - everything scales properly (dashes, linewidths, etc) * draw polygon @@ -30,6 +30,8 @@ from math import radians, cos, sin import numpy as np +from PIL import Image +from PIL.PngImagePlugin import PngInfo from matplotlib import cbook, rcParams, __version__ from matplotlib.backend_bases import ( @@ -41,13 +43,8 @@ from matplotlib.path import Path from matplotlib.transforms import Bbox, BboxBase from matplotlib import colors as mcolors - from matplotlib.backends._backend_agg import RendererAgg as _RendererAgg -from matplotlib.backend_bases import _has_pil - -if _has_pil: - from PIL import Image backend_version = 'v2.2' @@ -498,43 +495,32 @@ def print_png(self, filename_or_obj, *args, https://www.w3.org/TR/2003/REC-PNG-20031110/#11keywords pil_kwargs : dict, optional - If set to a non-None value, use Pillow to save the figure instead - of Matplotlib's builtin PNG support, and pass these keyword - arguments to `PIL.Image.save`. + Keyword arguments passed to `PIL.Image.save`. If the 'pnginfo' key is present, it completely overrides *metadata*, including the default 'Software' key. """ - from matplotlib import _png if metadata is None: metadata = {} + if pil_kwargs is None: + pil_kwargs = {} metadata = { "Software": f"matplotlib version{__version__}, http://matplotlib.org/", **metadata, } - FigureCanvasAgg.draw(self) - if pil_kwargs is not None: - from PIL import Image - from PIL.PngImagePlugin import PngInfo - # Only use the metadata kwarg if pnginfo is not set, because the - # semantics of duplicate keys in pnginfo is unclear. - if "pnginfo" not in pil_kwargs: - pnginfo = PngInfo() - for k, v in metadata.items(): - pnginfo.add_text(k, v) - pil_kwargs["pnginfo"] = pnginfo - pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) - (Image.fromarray(np.asarray(self.buffer_rgba())) - .save(filename_or_obj, format="png", **pil_kwargs)) - - else: - renderer = self.get_renderer() - with cbook.open_file_cm(filename_or_obj, "wb") as fh: - _png.write_png(renderer._renderer, fh, - self.figure.dpi, metadata=metadata) + # Only use the metadata kwarg if pnginfo is not set, because the + # semantics of duplicate keys in pnginfo is unclear. + if "pnginfo" not in pil_kwargs: + pnginfo = PngInfo() + for k, v in metadata.items(): + pnginfo.add_text(k, v) + pil_kwargs["pnginfo"] = pnginfo + pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) + (Image.fromarray(np.asarray(self.buffer_rgba())) + .save(filename_or_obj, format="png", **pil_kwargs)) def print_to_buffer(self): FigureCanvasAgg.draw(self) @@ -542,76 +528,74 @@ def print_to_buffer(self): return (bytes(renderer.buffer_rgba()), (int(renderer.width), int(renderer.height))) - if _has_pil: - - # Note that these methods should typically be called via savefig() and - # print_figure(), and the latter ensures that `self.figure.dpi` already - # matches the dpi kwarg (if any). - - @cbook._delete_parameter("3.2", "dryrun") - def print_jpg(self, filename_or_obj, *args, dryrun=False, - pil_kwargs=None, **kwargs): - """ - Write the figure to a JPEG file. - - Parameters - ---------- - filename_or_obj : str or PathLike or file-like object - The file to write to. - - Other Parameters - ---------------- - quality : int, default: :rc:`savefig.jpeg_quality` - The image quality, on a scale from 1 (worst) to 95 (best). - Values above 95 should be avoided; 100 disables portions of - the JPEG compression algorithm, and results in large files - with hardly any gain in image quality. - - optimize : bool, default: False - Whether the encoder should make an extra pass over the image - in order to select optimal encoder settings. - - progressive : bool, default: False - Whether the image should be stored as a progressive JPEG file. - - pil_kwargs : dict, optional - Additional keyword arguments that are passed to - `PIL.Image.save` when saving the figure. These take precedence - over *quality*, *optimize* and *progressive*. - """ - FigureCanvasAgg.draw(self) - if dryrun: - return - # The image is pasted onto a white background image to handle - # transparency. - image = Image.fromarray(np.asarray(self.buffer_rgba())) - background = Image.new('RGB', image.size, "white") - background.paste(image, image) - if pil_kwargs is None: - pil_kwargs = {} - for k in ["quality", "optimize", "progressive"]: - if k in kwargs: - pil_kwargs.setdefault(k, kwargs[k]) - pil_kwargs.setdefault("quality", rcParams["savefig.jpeg_quality"]) - pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) - return background.save( - filename_or_obj, format='jpeg', **pil_kwargs) - - print_jpeg = print_jpg - - @cbook._delete_parameter("3.2", "dryrun") - def print_tif(self, filename_or_obj, *args, dryrun=False, - pil_kwargs=None, **kwargs): - FigureCanvasAgg.draw(self) - if dryrun: - return - if pil_kwargs is None: - pil_kwargs = {} - pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) - return (Image.fromarray(np.asarray(self.buffer_rgba())) - .save(filename_or_obj, format='tiff', **pil_kwargs)) - - print_tiff = print_tif + # Note that these methods should typically be called via savefig() and + # print_figure(), and the latter ensures that `self.figure.dpi` already + # matches the dpi kwarg (if any). + + @cbook._delete_parameter("3.2", "dryrun") + def print_jpg(self, filename_or_obj, *args, dryrun=False, pil_kwargs=None, + **kwargs): + """ + Write the figure to a JPEG file. + + Parameters + ---------- + filename_or_obj : str or PathLike or file-like object + The file to write to. + + Other Parameters + ---------------- + quality : int, default: :rc:`savefig.jpeg_quality` + The image quality, on a scale from 1 (worst) to 95 (best). + Values above 95 should be avoided; 100 disables portions of + the JPEG compression algorithm, and results in large files + with hardly any gain in image quality. + + optimize : bool, default: False + Whether the encoder should make an extra pass over the image + in order to select optimal encoder settings. + + progressive : bool, default: False + Whether the image should be stored as a progressive JPEG file. + + pil_kwargs : dict, optional + Additional keyword arguments that are passed to + `PIL.Image.save` when saving the figure. These take precedence + over *quality*, *optimize* and *progressive*. + """ + FigureCanvasAgg.draw(self) + if dryrun: + return + # The image is "pasted" onto a white background image to safely + # handle any transparency + image = Image.fromarray(np.asarray(self.buffer_rgba())) + background = Image.new("RGB", image.size, "white") + background.paste(image, image) + if pil_kwargs is None: + pil_kwargs = {} + for k in ["quality", "optimize", "progressive"]: + if k in kwargs: + pil_kwargs.setdefault(k, kwargs[k]) + pil_kwargs.setdefault("quality", rcParams["savefig.jpeg_quality"]) + pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) + return background.save( + filename_or_obj, format='jpeg', **pil_kwargs) + + print_jpeg = print_jpg + + @cbook._delete_parameter("3.2", "dryrun") + def print_tif(self, filename_or_obj, *args, dryrun=False, pil_kwargs=None, + **kwargs): + FigureCanvasAgg.draw(self) + if dryrun: + return + if pil_kwargs is None: + pil_kwargs = {} + pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) + return (Image.fromarray(np.asarray(self.buffer_rgba())) + .save(filename_or_obj, format='tiff', **pil_kwargs)) + + print_tiff = print_tif @_Backend.export diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 50b1c0913d36..fb5e90a9f64a 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -20,6 +20,7 @@ import zlib import numpy as np +from PIL import Image from matplotlib import _text_layout, cbook, __version__, rcParams from matplotlib._pylab_helpers import Gcf @@ -39,7 +40,6 @@ from matplotlib.path import Path from matplotlib.dates import UTC from matplotlib import _path -from matplotlib import _png from matplotlib import ttconv from . import _backend_pdf_ps @@ -1440,7 +1440,9 @@ def _writePng(self, data): predictors with Flate compression. """ buffer = BytesIO() - _png.write_png(data, buffer) + if data.shape[-1] == 1: + data = data.squeeze(axis=-1) + Image.fromarray(data).save(buffer, format="png") buffer.seek(8) while True: length, type = struct.unpack(b'!L4s', buffer.read(8)) diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index 12bdc68ebb19..8cc344c7f8d7 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -12,8 +12,10 @@ import tempfile import weakref +from PIL import Image + import matplotlib as mpl -from matplotlib import _png, cbook, font_manager as fm, __version__, rcParams +from matplotlib import cbook, font_manager as fm, __version__, rcParams from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase) @@ -658,12 +660,10 @@ def draw_image(self, gc, x, y, im, transform=None): return # save the images to png files - path = os.path.dirname(self.fh.name) - fname = os.path.splitext(os.path.basename(self.fh.name))[0] - fname_img = "%s-img%d.png" % (fname, self.image_counter) + path = pathlib.Path(self.fh.name) + fname_img = "%s-img%d.png" % (path.stem, self.image_counter) + Image.fromarray(im[::-1]).save(path.parent / fname_img) self.image_counter += 1 - with pathlib.Path(path, fname_img).open("wb") as file: - _png.write_png(im[::-1], file) # reference the image in the pgf picture writeln(self.fh, r"\begin{pgfscope}") diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index df1b572f0a01..493bb362d12d 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -2,13 +2,14 @@ import base64 import gzip import hashlib -import io +from io import BytesIO, StringIO, TextIOWrapper import itertools import logging import re import uuid import numpy as np +from PIL import Image from matplotlib import cbook, __version__, rcParams from matplotlib.backend_bases import ( @@ -21,7 +22,6 @@ from matplotlib.path import Path from matplotlib import _path from matplotlib.transforms import Affine2D, Affine2DBase -from matplotlib import _png _log = logging.getLogger(__name__) @@ -242,7 +242,7 @@ def flush(self): def generate_transform(transform_list=[]): if len(transform_list): - output = io.StringIO() + output = StringIO() for type, value in transform_list: if (type == 'scale' and (value == (1,) or value == (1, 1)) or type == 'translate' and value == (0, 0) @@ -258,7 +258,7 @@ def generate_transform(transform_list=[]): def generate_css(attrib={}): if attrib: - output = io.StringIO() + output = StringIO() attrib = sorted(attrib.items()) for k, v in attrib: k = escape_attrib(k) @@ -821,11 +821,12 @@ def draw_image(self, gc, x, y, im, transform=None): if url is not None: self.writer.start('a', attrib={'xlink:href': url}) if rcParams['svg.image_inline']: - buf = _png.write_png(im, None) - oid = oid or self._make_id('image', buf) + buf = BytesIO() + Image.fromarray(im).save(buf, format="png") + oid = oid or self._make_id('image', buf.getvalue()) attrib['xlink:href'] = ( "data:image/png;base64,\n" + - base64.b64encode(buf).decode('ascii')) + base64.b64encode(buf.getvalue()).decode('ascii')) else: if self.basename is None: raise ValueError("Cannot save image data to filesystem when " @@ -833,8 +834,7 @@ def draw_image(self, gc, x, y, im, transform=None): filename = '{}.image{}.png'.format( self.basename, next(self._image_counter)) _log.info('Writing image file for inclusion: %s', filename) - with open(filename, 'wb') as file: - _png.write_png(im, file) + Image.fromarray(im).save(filename) oid = oid or 'Im_' + self._make_id('image', filename) attrib['xlink:href'] = filename @@ -1190,7 +1190,7 @@ def print_svg(self, filename, *args, **kwargs): if cbook.file_requires_unicode(fh): detach = False else: - fh = io.TextIOWrapper(fh, 'utf-8') + fh = TextIOWrapper(fh, 'utf-8') detach = True result = self._print_svg(filename, fh, **kwargs) diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index c000e537e136..139892f27704 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -11,16 +11,17 @@ # application, implemented with tornado. import datetime -from io import StringIO +from io import BytesIO, StringIO import json import logging import os from pathlib import Path import numpy as np +from PIL import Image import tornado -from matplotlib import backend_bases, cbook, _png +from matplotlib import backend_bases, cbook from matplotlib.backends import backend_agg from matplotlib.backend_bases import _Backend @@ -194,18 +195,15 @@ def get_diff_image(self): diff = buff != last_buffer output = np.where(diff, buff, 0) - # TODO: We should write a new version of write_png that - # handles the differencing inline - buff = _png.write_png( - output.view(dtype=np.uint8).reshape(output.shape + (4,)), - None, compression=6, filter=_png.PNG_FILTER_NONE) - + buf = BytesIO() + data = output.view(dtype=np.uint8).reshape((*output.shape, 4)) + Image.fromarray(data).save(buf, format="png") # Swap the renderer frames self._renderer, self._last_renderer = ( self._last_renderer, renderer) self._force_full = False self._png_is_old = False - return buff + return buf.getvalue() def get_renderer(self, cleared=None): # Mirrors super.get_renderer, but caches the old one diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 4c9cdc44c2cb..73b60708c5fb 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -17,7 +17,7 @@ from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, MouseButton, NavigationToolbar2, RendererBase, StatusbarBase, TimerBase, - ToolContainerBase, _has_pil, cursors) + ToolContainerBase, cursors) from matplotlib import cbook, rcParams, backend_tools from matplotlib._pylab_helpers import Gcf @@ -926,11 +926,10 @@ def draw(self, drawDC=None): def print_bmp(self, filename, *args, **kwargs): return self._print_image(filename, wx.BITMAP_TYPE_BMP, *args, **kwargs) - if not _has_pil: - def print_jpeg(self, filename, *args, **kwargs): - return self._print_image(filename, wx.BITMAP_TYPE_JPEG, - *args, **kwargs) - print_jpg = print_jpeg + def print_jpeg(self, filename, *args, **kwargs): + return self._print_image(filename, wx.BITMAP_TYPE_JPEG, + *args, **kwargs) + print_jpg = print_jpeg def print_pcx(self, filename, *args, **kwargs): return self._print_image(filename, wx.BITMAP_TYPE_PCX, *args, **kwargs) @@ -938,11 +937,9 @@ def print_pcx(self, filename, *args, **kwargs): def print_png(self, filename, *args, **kwargs): return self._print_image(filename, wx.BITMAP_TYPE_PNG, *args, **kwargs) - if not _has_pil: - def print_tiff(self, filename, *args, **kwargs): - return self._print_image(filename, wx.BITMAP_TYPE_TIF, - *args, **kwargs) - print_tif = print_tiff + def print_tiff(self, filename, *args, **kwargs): + return self._print_image(filename, wx.BITMAP_TYPE_TIF, *args, **kwargs) + print_tif = print_tiff def print_xpm(self, filename, *args, **kwargs): return self._print_image(filename, wx.BITMAP_TYPE_XPM, *args, **kwargs) diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index eeb2215b341e..2890b5793344 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -12,6 +12,7 @@ import urllib.parse import numpy as np +import PIL.PngImagePlugin from matplotlib import rcParams import matplotlib.artist as martist @@ -19,14 +20,11 @@ import matplotlib.colors as mcolors import matplotlib.cm as cm import matplotlib.cbook as cbook - # For clarity, names from _image are given explicitly in this module: import matplotlib._image as _image - # For user convenience, the names from _image are also imported into # the image namespace: from matplotlib._image import * - from matplotlib.transforms import (Affine2D, BboxBase, Bbox, BboxTransform, IdentityTransform, TransformedBbox) @@ -664,11 +662,9 @@ def contains(self, mouseevent): def write_png(self, fname): """Write the image to png file with fname""" - from matplotlib import _png im = self.to_rgba(self._A[::-1] if self.origin == 'lower' else self._A, bytes=True, norm=True) - with open(fname, "wb") as file: - _png.write_png(im, file) + PIL.Image.fromarray(im).save(fname, format="png") def set_data(self, A): """ @@ -680,13 +676,8 @@ def set_data(self, A): ---------- A : array-like or `PIL.Image.Image` """ - try: - from PIL import Image - except ImportError: - pass - else: - if isinstance(A, Image.Image): - A = pil_to_array(A) # Needed e.g. to apply png palette. + if isinstance(A, PIL.Image.Image): + A = pil_to_array(A) # Needed e.g. to apply png palette. self._A = cbook.safe_masked_invalid(A, copy=True) if (self._A.dtype != np.uint8 and @@ -1395,15 +1386,6 @@ def imread(fname, format=None): - (M, N) for grayscale images. - (M, N, 3) for RGB images. - (M, N, 4) for RGBA images. - - Notes - ----- - Matplotlib can only read PNGs natively. Further image formats are - supported via the optional dependency on Pillow. Note, URL strings - are not compatible with Pillow. Check the `Pillow documentation`_ - for more information. - - .. _Pillow documentation: http://pillow.readthedocs.io/en/latest/ """ if format is None: if isinstance(fname, str): @@ -1429,24 +1411,18 @@ def imread(fname, format=None): ext = 'png' else: ext = format - if ext != 'png': - try: # Try to load the image with PIL. - from PIL import Image - except ImportError: - raise ValueError('Only know how to handle PNG; with Pillow ' - 'installed, Matplotlib can handle more images') - with Image.open(fname) as image: - return pil_to_array(image) - from matplotlib import _png + img_open = ( + PIL.PngImagePlugin.PngImageFile if ext == 'png' else PIL.Image.open) if isinstance(fname, str): parsed = urllib.parse.urlparse(fname) - # If fname is a URL, download the data - if len(parsed.scheme) > 1: + if len(parsed.scheme) > 1: # Pillow doesn't handle URLs directly. from urllib import request - fd = BytesIO(request.urlopen(fname).read()) - return _png.read_png(fd) - with cbook.open_file_cm(fname, "rb") as file: - return _png.read_png(file) + with urllib.request.urlopen(fname) as response: + return imread(response, format=ext) + with img_open(fname) as image: + return (_pil_png_to_float_array(image) + if isinstance(image, PIL.PngImagePlugin.PngImageFile) else + pil_to_array(image)) def imsave(fname, arr, vmin=None, vmax=None, cmap=None, format=None, @@ -1488,15 +1464,11 @@ def imsave(fname, arr, vmin=None, vmax=None, cmap=None, format=None, format, see the documentation of the respective backends for more information. pil_kwargs : dict, optional - If set to a non-None value, always use Pillow to save the figure - (regardless of the output format), and pass these keyword arguments to - `PIL.Image.save`. - - If the 'pnginfo' key is present, it completely overrides - *metadata*, including the default 'Software' key. + Keyword arguments passed to `PIL.Image.save`. If the 'pnginfo' key is + present, it completely overrides *metadata*, including the default + 'Software' key. """ from matplotlib.figure import Figure - from matplotlib import _png if isinstance(fname, os.PathLike): fname = os.fspath(fname) if format is None: @@ -1522,44 +1494,32 @@ def imsave(fname, arr, vmin=None, vmax=None, cmap=None, format=None, if origin == "lower": arr = arr[::-1] rgba = sm.to_rgba(arr, bytes=True) - if format == "png" and pil_kwargs is None: - with cbook.open_file_cm(fname, "wb") as file: - _png.write_png(rgba, file, dpi=dpi, metadata=metadata) - else: - try: - from PIL import Image - from PIL.PngImagePlugin import PngInfo - except ImportError as exc: - if pil_kwargs is not None: - raise ImportError("Setting 'pil_kwargs' requires Pillow") - else: - raise ImportError(f"Saving to {format} requires Pillow") - if pil_kwargs is None: - pil_kwargs = {} - pil_shape = (rgba.shape[1], rgba.shape[0]) - image = Image.frombuffer( - "RGBA", pil_shape, rgba, "raw", "RGBA", 0, 1) - if format == "png" and metadata is not None: - # cf. backend_agg's print_png. - pnginfo = PngInfo() - for k, v in metadata.items(): - pnginfo.add_text(k, v) - pil_kwargs["pnginfo"] = pnginfo - if format in ["jpg", "jpeg"]: - format = "jpeg" # Pillow doesn't recognize "jpg". - color = tuple( - int(x * 255) - for x in mcolors.to_rgb(rcParams["savefig.facecolor"])) - background = Image.new("RGB", pil_shape, color) - background.paste(image, image) - image = background - pil_kwargs.setdefault("format", format) - pil_kwargs.setdefault("dpi", (dpi, dpi)) - image.save(fname, **pil_kwargs) + if pil_kwargs is None: + pil_kwargs = {} + pil_shape = (rgba.shape[1], rgba.shape[0]) + image = PIL.Image.frombuffer( + "RGBA", pil_shape, rgba, "raw", "RGBA", 0, 1) + if format == "png" and metadata is not None: + # cf. backend_agg's print_png. + pnginfo = PIL.PngImagePlugin.PngInfo() + for k, v in metadata.items(): + pnginfo.add_text(k, v) + pil_kwargs["pnginfo"] = pnginfo + if format in ["jpg", "jpeg"]: + format = "jpeg" # Pillow doesn't recognize "jpg". + color = tuple( + int(x * 255) + for x in mcolors.to_rgb(rcParams["savefig.facecolor"])) + background = PIL.Image.new("RGB", pil_shape, color) + background.paste(image, image) + image = background + pil_kwargs.setdefault("format", format) + pil_kwargs.setdefault("dpi", (dpi, dpi)) + image.save(fname, **pil_kwargs) def pil_to_array(pilImage): - """Load a `PIL image`_ and return it as a numpy array. + """Load a `PIL image`_ and return it as a numpy int array. .. _PIL image: https://pillow.readthedocs.io/en/latest/reference/Image.html @@ -1572,7 +1532,6 @@ def pil_to_array(pilImage): - (M, N) for grayscale images. - (M, N, 3) for RGB images. - (M, N, 4) for RGBA images. - """ if pilImage.mode in ['RGBA', 'RGBX', 'RGB', 'L']: # return MxNx4 RGBA, MxNx3 RBA, or MxN luminance array @@ -1593,6 +1552,36 @@ def pil_to_array(pilImage): return np.asarray(pilImage) # return MxNx4 RGBA array +def _pil_png_to_float_array(pil_png): + """Convert a PIL `PNGImageFile` to a 0-1 float array.""" + # Unlike pil_to_array this converts to 0-1 float32s for backcompat with the + # old libpng-based loader. + # The supported rawmodes are from PIL.PngImagePlugin._MODES. When + # mode == "RGB(A)", the 16-bit raw data has already been coarsened to 8-bit + # by Pillow. + mode = pil_png.mode + rawmode = pil_png.png.im_rawmode + if rawmode == "1": # Grayscale. + return np.asarray(pil_png, np.float32) + if rawmode == "L;2": # Grayscale. + return np.divide(pil_png, 2**2 - 1, dtype=np.float32) + if rawmode == "L;4": # Grayscale. + return np.divide(pil_png, 2**4 - 1, dtype=np.float32) + if rawmode == "L": # Grayscale. + return np.divide(pil_png, 2**8 - 1, dtype=np.float32) + if rawmode == "I;16B": # Grayscale. + return np.divide(pil_png, 2**16 - 1, dtype=np.float32) + if mode == "RGB": # RGB. + return np.divide(pil_png, 2**8 - 1, dtype=np.float32) + if mode == "P": # Palette. + return np.divide(pil_png.convert("RGBA"), 2**8 - 1, dtype=np.float32) + if mode == "LA": # Grayscale + alpha. + return np.divide(pil_png.convert("RGBA"), 2**8 - 1, dtype=np.float32) + if mode == "RGBA": # RGBA. + return np.divide(pil_png, 2**8 - 1, dtype=np.float32) + raise ValueError(f"Unknown PIL rawmode: {rawmode}") + + def thumbnail(infile, thumbfile, scale=0.1, interpolation='bilinear', preview=False): """ diff --git a/lib/matplotlib/mathtext.py b/lib/matplotlib/mathtext.py index 3c1ba5750cf4..bec7e03843a8 100644 --- a/lib/matplotlib/mathtext.py +++ b/lib/matplotlib/mathtext.py @@ -24,6 +24,7 @@ import unicodedata import numpy as np +from PIL import Image from pyparsing import ( Combine, Empty, FollowedBy, Forward, Group, Literal, oneOf, OneOrMore, Optional, ParseBaseException, ParseFatalException, ParserElement, @@ -3430,11 +3431,9 @@ def to_png(self, filename, texstr, color='black', dpi=120, fontsize=14): depth : int Offset of the baseline from the bottom of the image, in pixels. """ - from matplotlib import _png rgba, depth = self.to_rgba( texstr, color=color, dpi=dpi, fontsize=fontsize) - with open(filename, "wb") as file: - _png.write_png(rgba, file) + Image.fromarray(rgba).save(filename, format="png") return depth def get_depth(self, texstr, dpi=120, fontsize=14): diff --git a/lib/matplotlib/testing/compare.py b/lib/matplotlib/testing/compare.py index baae5114faf6..81577e4803b6 100644 --- a/lib/matplotlib/testing/compare.py +++ b/lib/matplotlib/testing/compare.py @@ -14,6 +14,7 @@ from tempfile import TemporaryFile import numpy as np +import PIL import matplotlib as mpl from matplotlib.testing.exceptions import ImageComparisonFailure @@ -316,6 +317,10 @@ def calculate_rms(expected_image, actual_image): return np.sqrt(((expected_image - actual_image).astype(float) ** 2).mean()) +# NOTE: compare_image and save_diff_image assume that the image does not have +# 16-bit depth, as Pillow converts these to RGB incorrectly. + + def compare_images(expected, actual, tol, in_decorator=False): """ Compare two "image" files checking differences within a tolerance. @@ -365,8 +370,6 @@ def compare_images(expected, actual, tol, in_decorator=False): compare_images(img1, img2, 0.001) """ - from matplotlib import _png - actual = os.fspath(actual) if not os.path.exists(actual): raise Exception("Output image %s does not exist." % actual) @@ -383,10 +386,8 @@ def compare_images(expected, actual, tol, in_decorator=False): expected = convert(expected, cache=True) # open the image files and remove the alpha channel (if it exists) - with open(expected, "rb") as expected_file: - expected_image = _png.read_png_int(expected_file)[:, :, :3] - with open(actual, "rb") as actual_file: - actual_image = _png.read_png_int(actual_file)[:, :, :3] + expected_image = np.asarray(PIL.Image.open(expected).convert("RGB")) + actual_image = np.asarray(PIL.Image.open(actual).convert("RGB")) actual_image, expected_image = crop_to_same( actual, actual_image, expected, expected_image) @@ -436,11 +437,8 @@ def save_diff_image(expected, actual, output): File path to save difference image to. ''' # Drop alpha channels, similarly to compare_images. - from matplotlib import _png - with open(expected, "rb") as expected_file: - expected_image = _png.read_png(expected_file)[..., :3] - with open(actual, "rb") as actual_file: - actual_image = _png.read_png(actual_file)[..., :3] + expected_image = np.asarray(PIL.Image.open(expected).convert("RGB")) + actual_image = np.asarray(PIL.Image.open(actual).convert("RGB")) actual_image, expected_image = crop_to_same( actual, actual_image, expected, expected_image) expected_image = np.array(expected_image).astype(float) @@ -466,5 +464,4 @@ def save_diff_image(expected, actual, output): # Hard-code the alpha channel to fully solid save_image_np[:, :, 3] = 255 - with open(output, "wb") as output_file: - _png.write_png(save_image_np, output_file) + PIL.Image.fromarray(save_image_np).save(output, format="png") diff --git a/lib/matplotlib/tests/baseline_images/test_png/pngsuite.png b/lib/matplotlib/tests/baseline_images/test_png/pngsuite.png index d97265215a4c..e8ba8c51be42 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_png/pngsuite.png and b/lib/matplotlib/tests/baseline_images/test_png/pngsuite.png differ diff --git a/lib/matplotlib/tests/test_basic.py b/lib/matplotlib/tests/test_basic.py index e8e8da6c5f4c..111998a5cbd3 100644 --- a/lib/matplotlib/tests/test_basic.py +++ b/lib/matplotlib/tests/test_basic.py @@ -42,7 +42,6 @@ def test_lazy_imports(): import matplotlib.backend_bases import matplotlib.pyplot - assert 'matplotlib._png' not in sys.modules assert 'matplotlib._tri' not in sys.modules assert 'matplotlib._qhull' not in sys.modules assert 'matplotlib._contour' not in sys.modules diff --git a/lib/matplotlib/tests/test_png.py b/lib/matplotlib/tests/test_png.py index 8f355d2cc463..3369452cfb89 100644 --- a/lib/matplotlib/tests/test_png.py +++ b/lib/matplotlib/tests/test_png.py @@ -1,9 +1,7 @@ from io import BytesIO import glob import os -from pathlib import Path -import numpy as np import pytest from matplotlib.testing.decorators import image_comparison @@ -33,15 +31,6 @@ def test_pngsuite(): plt.gca().set_xlim(0, len(files)) -def test_imread_png_uint16(): - from matplotlib import _png - with (Path(__file__).parent - / 'baseline_images/test_png/uint16.png').open('rb') as file: - img = _png.read_png_int(file) - assert (img.dtype == np.uint16) - assert np.sum(img.flatten()) == 134184960 - - def test_truncated_file(tmpdir): d = tmpdir.mkdir('test') fname = str(d.join('test.png')) diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 1e788a64c4cd..3847a1a09358 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -398,13 +398,11 @@ def make_png(self, tex, fontsize, dpi): def get_grey(self, tex, fontsize=None, dpi=None): """Return the alpha channel.""" - from matplotlib import _png key = tex, self.get_font_config(), fontsize, dpi alpha = self.grey_arrayd.get(key) if alpha is None: pngfile = self.make_png(tex, fontsize, dpi) - with open(os.path.join(self.texcache, pngfile), "rb") as file: - X = _png.read_png(file) + X = mpl.image.imread(os.path.join(self.texcache, pngfile)) self.grey_arrayd[key] = alpha = X[:, :, -1] return alpha diff --git a/setup.py b/setup.py index 760e84af1261..4de989563478 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,6 @@ setupext.LibAgg(), setupext.FreeType(), setupext.FT2Font(), - setupext.Png(), setupext.Qhull(), setupext.Image(), setupext.TTConv(), @@ -262,6 +261,7 @@ def run(self): "cycler>=0.10", "kiwisolver>=1.0.1", "numpy>=1.11", + "pillow", "pyparsing>=2.0.1,!=2.0.4,!=2.1.2,!=2.1.6", "python-dateutil>=2.1", ], diff --git a/setupext.py b/setupext.py index 74e0ffb96461..10ae966364de 100644 --- a/setupext.py +++ b/setupext.py @@ -207,8 +207,6 @@ def deplib(libname): return libname known_libs = { - # TODO: support versioned libpng on build system rewrite - 'libpng16': ('libpng16', '_static'), 'z': ('zlib', 'static'), } @@ -492,11 +490,9 @@ def add_flags(self, ext, add_sources=True): for x in agg_sources) -# For FreeType2 and libpng, we add a separate checkdep_foo.c source to at the -# top of the extension sources. This file is compiled first and immediately -# aborts the compilation either with "foo.h: No such file or directory" if the -# header is not found, or an appropriate error message if the header indicates -# a too-old version. +# First compile checkdep_freetype2.c, which aborts the compilation either +# with "foo.h: No such file or directory" if the header is not found, or an +# appropriate error message if the header indicates a too-old version. class FreeType(SetupPackage): @@ -642,30 +638,6 @@ def get_extension(self): return ext -class Png(SetupPackage): - name = "png" - - def get_extension(self): - sources = [ - 'src/checkdep_libpng.c', - 'src/_png.cpp', - 'src/mplutils.cpp', - ] - ext = Extension('matplotlib._png', sources) - pkg_config_setup_extension( - ext, 'libpng', - atleast_version='1.2', - alt_exec=['libpng-config', '--ldflags'], - default_libraries=( - ['png', 'z'] if os.name == 'posix' else - # libpng upstream names their lib libpng16.lib, not png.lib. - [deplib('libpng16'), deplib('z')] if os.name == 'nt' else - [] - )) - add_numpy_flags(ext) - return ext - - class Qhull(SetupPackage): name = "qhull" diff --git a/src/_png.cpp b/src/_png.cpp deleted file mode 100644 index 0cc883df658e..000000000000 --- a/src/_png.cpp +++ /dev/null @@ -1,683 +0,0 @@ -/* -*- mode: c++; c-basic-offset: 4 -*- */ - -/* For linux, png.h must be imported before Python.h because - png.h needs to be the one to define setjmp. - Undefining _POSIX_C_SOURCE and _XOPEN_SOURCE stops a couple - of harmless warnings. -*/ -#define PY_SSIZE_T_CLEAN - -extern "C" { -# include -# ifdef _POSIX_C_SOURCE -# undef _POSIX_C_SOURCE -# endif -# ifndef _AIX -# ifdef _XOPEN_SOURCE -# undef _XOPEN_SOURCE -# endif -# endif -} - -#include "numpy_cpp.h" - -# include -# include "Python.h" - - -// As reported in [3082058] build _png.so on aix -#ifdef _AIX -#undef jmpbuf -#endif - -struct buffer_t { - PyObject *str; - size_t cursor; - size_t size; -}; - - -static void write_png_data_buffer(png_structp png_ptr, png_bytep data, png_size_t length) -{ - buffer_t *buff = (buffer_t *)png_get_io_ptr(png_ptr); - if (buff->cursor + length < buff->size) { - memcpy(PyBytes_AS_STRING(buff->str) + buff->cursor, data, length); - buff->cursor += length; - } -} - -static void flush_png_data_buffer(png_structp png_ptr) -{ - -} - -static void write_png_data(png_structp png_ptr, png_bytep data, png_size_t length) -{ - PyObject *py_file_obj = (PyObject *)png_get_io_ptr(png_ptr); - PyObject *write_method = PyObject_GetAttrString(py_file_obj, "write"); - PyObject *result = NULL; - if (write_method) { - result = PyObject_CallFunction(write_method, (char *)"y#", data, length); - } - Py_XDECREF(write_method); - Py_XDECREF(result); -} - -static void flush_png_data(png_structp png_ptr) -{ - PyObject *py_file_obj = (PyObject *)png_get_io_ptr(png_ptr); - PyObject *flush_method = PyObject_GetAttrString(py_file_obj, "flush"); - PyObject *result = NULL; - if (flush_method) { - result = PyObject_CallFunction(flush_method, (char *)""); - } - Py_XDECREF(flush_method); - Py_XDECREF(result); -} - -const char *Py_write_png__doc__ = - "write_png(buffer, file, dpi=0, compression=6, filter=auto, metadata=None)\n" - "\n" - "Parameters\n" - "----------\n" - "buffer : numpy array of image data\n" - " Must be an MxNxD array of dtype uint8.\n" - " - if D is 1, the image is greyscale\n" - " - if D is 3, the image is RGB\n" - " - if D is 4, the image is RGBA\n" - "\n" - "file : binary-mode file-like object or None\n" - " If None, a byte string containing the PNG data will be returned\n" - "\n" - "dpi : float\n" - " The dpi to store in the file metadata.\n" - "\n" - "compression : int\n" - " The level of lossless zlib compression to apply. 0 indicates no\n" - " compression. Values 1-9 indicate low/fast through high/slow\n" - " compression. Default is 6.\n" - "\n" - "filter : int\n" - " Filter to apply. Must be one of the constants: PNG_FILTER_NONE,\n" - " PNG_FILTER_SUB, PNG_FILTER_UP, PNG_FILTER_AVG, PNG_FILTER_PAETH.\n" - " See the PNG standard for more information.\n" - " If not provided, libpng will try to automatically determine the\n" - " best filter on a line-by-line basis.\n" - "\n" - "metadata : dictionary\n" - " The keyword-text pairs that are stored as comments in the image.\n" - " Keys must be shorter than 79 chars. The only supported encoding\n" - " for both keywords and values is Latin-1 (ISO 8859-1).\n" - " Examples given in the PNG Specification are:\n" - " - Title: Short (one line) title or caption for image\n" - " - Author: Name of image's creator\n" - " - Description: Description of image (possibly long)\n" - " - Copyright: Copyright notice\n" - " - Creation Time: Time of original image creation\n" - " (usually RFC 1123 format, see below)\n" - " - Software: Software used to create the image\n" - " - Disclaimer: Legal disclaimer\n" - " - Warning: Warning of nature of content\n" - " - Source: Device used to create the image\n" - " - Comment: Miscellaneous comment; conversion\n" - " from other image format\n" - "\n" - " For more details see the PNG specification:\n" - " https://www.w3.org/TR/2003/REC-PNG-20031110/#11keywords\n" - "\n" - "Returns\n" - "-------\n" - "buffer : bytes or None\n" - " Byte string containing the PNG content if None was passed in for\n" - " file, otherwise None is returned.\n"; - -// this code is heavily adapted from -// https://www.object-craft.com.au/projects/paint/ which licensed under the -// (BSD compatible) LICENSE_PAINT which is included in this distribution. -static PyObject *Py_write_png(PyObject *self, PyObject *args, PyObject *kwds) -{ - numpy::array_view buffer; - PyObject *filein; - PyObject *metadata = NULL; - PyObject *meta_key, *meta_val; - png_text *text; - Py_ssize_t pos = 0; - int meta_pos = 0; - Py_ssize_t meta_size; - double dpi = 0; - int compression = 6; - int filter = -1; - const char *names[] = { "buffer", "file", "dpi", "compression", "filter", "metadata", NULL }; - - // We don't need strict contiguity, just for each row to be - // contiguous, and libpng has special handling for getting RGB out - // of RGBA, ARGB or BGR. But the simplest thing to do is to - // enforce contiguity using array_view::converter_contiguous. - if (!PyArg_ParseTupleAndKeywords(args, - kwds, - "O&O|diiO:write_png", - (char **)names, - &buffer.converter_contiguous, - &buffer, - &filein, - &dpi, - &compression, - &filter, - &metadata)) { - return NULL; - } - - png_uint_32 width = (png_uint_32)buffer.dim(1); - png_uint_32 height = (png_uint_32)buffer.dim(0); - npy_intp channels = buffer.dim(2); - std::vector row_pointers(height); - for (png_uint_32 row = 0; row < (png_uint_32)height; ++row) { - row_pointers[row] = (png_bytep)&buffer(row, 0, 0); - } - - png_structp png_ptr = NULL; - png_infop info_ptr = NULL; - struct png_color_8_struct sig_bit; - int png_color_type; - buffer_t buff; - buff.str = NULL; - - switch (channels) { - case 1: - png_color_type = PNG_COLOR_TYPE_GRAY; - break; - case 3: - png_color_type = PNG_COLOR_TYPE_RGB; - break; - case 4: - png_color_type = PNG_COLOR_TYPE_RGB_ALPHA; - break; - default: - PyErr_SetString(PyExc_ValueError, - "Buffer must be an NxMxD array with D in 1, 3, 4 " - "(grayscale, RGB, RGBA)"); - goto exit; - } - - if (compression < 0 || compression > 9) { - PyErr_Format(PyExc_ValueError, - "compression must be in range 0-9, got %d", compression); - goto exit; - } - - if (filein == Py_None) { - buff.size = width * height * 4 + 1024; - buff.str = PyBytes_FromStringAndSize(NULL, buff.size); - if (buff.str == NULL) { - goto exit; - } - buff.cursor = 0; - } else { - PyErr_Clear(); - PyObject *write_method = PyObject_GetAttrString(filein, "write"); - if (!(write_method && PyCallable_Check(write_method))) { - Py_XDECREF(write_method); - PyErr_SetString(PyExc_TypeError, - "Object does not appear to be a file-like object"); - goto exit; - } - Py_XDECREF(write_method); - } - - png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL); - if (png_ptr == NULL) { - PyErr_SetString(PyExc_RuntimeError, "Could not create write struct"); - goto exit; - } - - png_set_compression_level(png_ptr, compression); - if (filter >= 0) { - png_set_filter(png_ptr, 0, filter); - } - - info_ptr = png_create_info_struct(png_ptr); - if (info_ptr == NULL) { - PyErr_SetString(PyExc_RuntimeError, "Could not create info struct"); - goto exit; - } - - if (setjmp(png_jmpbuf(png_ptr))) { - PyErr_SetString(PyExc_RuntimeError, "libpng signaled error"); - goto exit; - } - - if (buff.str) { - png_set_write_fn(png_ptr, (void *)&buff, &write_png_data_buffer, &flush_png_data_buffer); - } else { - png_set_write_fn(png_ptr, (void *)filein, &write_png_data, &flush_png_data); - } - png_set_IHDR(png_ptr, - info_ptr, - width, - height, - 8, - png_color_type, - PNG_INTERLACE_NONE, - PNG_COMPRESSION_TYPE_BASE, - PNG_FILTER_TYPE_BASE); - - // Save the dpi of the image in the file - if (dpi > 0.0) { - png_uint_32 dots_per_meter = (png_uint_32)(dpi / (2.54 / 100.0)); - png_set_pHYs(png_ptr, info_ptr, dots_per_meter, dots_per_meter, PNG_RESOLUTION_METER); - } - -#ifdef PNG_TEXT_SUPPORTED - // Save the metadata - if (metadata != NULL && metadata != Py_None) { - if (!PyDict_Check(metadata)) { - PyErr_SetString(PyExc_TypeError, "metadata must be a dict or None"); - goto exit; - } - meta_size = PyDict_Size(metadata); - text = new png_text[meta_size]; - - while (PyDict_Next(metadata, &pos, &meta_key, &meta_val)) { - text[meta_pos].compression = PNG_TEXT_COMPRESSION_NONE; - if (PyUnicode_Check(meta_key)) { - PyObject *temp_key = PyUnicode_AsEncodedString(meta_key, "latin_1", "strict"); - if (temp_key != NULL) { - text[meta_pos].key = PyBytes_AsString(temp_key); - } - } else if (PyBytes_Check(meta_key)) { - text[meta_pos].key = PyBytes_AsString(meta_key); - } else { - char invalid_key[79]; - sprintf(invalid_key,"INVALID KEY %d", meta_pos); - text[meta_pos].key = invalid_key; - } - if (PyUnicode_Check(meta_val)) { - PyObject *temp_val = PyUnicode_AsEncodedString(meta_val, "latin_1", "strict"); - if (temp_val != NULL) { - text[meta_pos].text = PyBytes_AsString(temp_val); - } - } else if (PyBytes_Check(meta_val)) { - text[meta_pos].text = PyBytes_AsString(meta_val); - } else { - text[meta_pos].text = (char *)"Invalid value in metadata"; - } -#ifdef PNG_iTXt_SUPPORTED - text[meta_pos].lang = NULL; -#endif - meta_pos++; - } - png_set_text(png_ptr, info_ptr, text, meta_size); - delete[] text; - } -#endif - - sig_bit.alpha = 0; - switch (png_color_type) { - case PNG_COLOR_TYPE_GRAY: - sig_bit.gray = 8; - sig_bit.red = 0; - sig_bit.green = 0; - sig_bit.blue = 0; - break; - case PNG_COLOR_TYPE_RGB_ALPHA: - sig_bit.alpha = 8; - // fall through - case PNG_COLOR_TYPE_RGB: - sig_bit.gray = 0; - sig_bit.red = 8; - sig_bit.green = 8; - sig_bit.blue = 8; - break; - default: - PyErr_SetString(PyExc_RuntimeError, "internal error, bad png_color_type"); - goto exit; - } - png_set_sBIT(png_ptr, info_ptr, &sig_bit); - - png_write_info(png_ptr, info_ptr); - png_write_image(png_ptr, &row_pointers[0]); - png_write_end(png_ptr, info_ptr); - -exit: - if (png_ptr && info_ptr) { - png_destroy_write_struct(&png_ptr, &info_ptr); - } - if (PyErr_Occurred()) { - Py_XDECREF(buff.str); - return NULL; - } else { - if (buff.str) { - _PyBytes_Resize(&buff.str, buff.cursor); - return buff.str; - } - Py_RETURN_NONE; - } -} - -static void _read_png_data(PyObject *py_file_obj, png_bytep data, png_size_t length) -{ - PyObject *read_method = PyObject_GetAttrString(py_file_obj, "read"); - PyObject *result = NULL; - char *buffer; - Py_ssize_t bufflen; - if (read_method) { - result = PyObject_CallFunction(read_method, (char *)"i", length); - if (result) { - if (PyBytes_AsStringAndSize(result, &buffer, &bufflen) == 0) { - if (bufflen == (Py_ssize_t)length) { - memcpy(data, buffer, length); - } else { - PyErr_SetString(PyExc_IOError, "read past end of file"); - } - } else { - PyErr_SetString(PyExc_IOError, "failed to copy buffer"); - } - } else { - PyErr_SetString(PyExc_IOError, "failed to read file"); - } - - - } - Py_XDECREF(read_method); - Py_XDECREF(result); -} - -static void read_png_data(png_structp png_ptr, png_bytep data, png_size_t length) -{ - PyObject *py_file_obj = (PyObject *)png_get_io_ptr(png_ptr); - _read_png_data(py_file_obj, data, length); - if (PyErr_Occurred()) { - png_error(png_ptr, "failed to read file"); - } - -} - -static PyObject *_read_png(PyObject *filein, bool float_result) -{ - png_byte header[8]; // 8 is the maximum size that can be checked - png_structp png_ptr = NULL; - png_infop info_ptr = NULL; - int num_dims; - std::vector row_pointers; - png_uint_32 width = 0; - png_uint_32 height = 0; - int bit_depth; - PyObject *result = NULL; - - // TODO: Remove direct calls to Numpy API here - PyErr_Clear(); - - PyObject *read_method = PyObject_GetAttrString(filein, "read"); - if (!(read_method && PyCallable_Check(read_method))) { - Py_XDECREF(read_method); - PyErr_SetString(PyExc_TypeError, - "Object does not appear to be a file-like object"); - goto exit; - } - Py_XDECREF(read_method); - _read_png_data(filein, header, 8); - if (PyErr_Occurred()) { - goto exit; - } - - if (png_sig_cmp(header, 0, 8)) { - PyErr_SetString(PyExc_ValueError, "invalid PNG header"); - goto exit; - } - - /* initialize stuff */ - png_ptr = png_create_read_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL); - - if (!png_ptr) { - PyErr_SetString(PyExc_RuntimeError, "png_create_read_struct failed"); - goto exit; - } - - info_ptr = png_create_info_struct(png_ptr); - if (!info_ptr) { - PyErr_SetString(PyExc_RuntimeError, "png_create_info_struct failed"); - goto exit; - } - - if (setjmp(png_jmpbuf(png_ptr))) { - if (!PyErr_Occurred()) { - PyErr_SetString(PyExc_RuntimeError, "error setting jump"); - } - goto exit; - } - - png_set_read_fn(png_ptr, (void *)filein, &read_png_data); - png_set_sig_bytes(png_ptr, 8); - png_read_info(png_ptr, info_ptr); - width = png_get_image_width(png_ptr, info_ptr); - height = png_get_image_height(png_ptr, info_ptr); - bit_depth = png_get_bit_depth(png_ptr, info_ptr); - - // Unpack 1, 2, and 4-bit images - if (bit_depth < 8) { - png_set_packing(png_ptr); - } - - // If sig bits are set, shift data - png_color_8p sig_bit; - if ((png_get_color_type(png_ptr, info_ptr) != PNG_COLOR_TYPE_PALETTE) && - png_get_sBIT(png_ptr, info_ptr, &sig_bit)) { - png_set_shift(png_ptr, sig_bit); - } - -#if NPY_BYTE_ORDER == NPY_LITTLE_ENDIAN - // Convert big endian to little - if (bit_depth == 16) { - png_set_swap(png_ptr); - } -#endif - - // Convert palletes to full RGB - if (png_get_color_type(png_ptr, info_ptr) == PNG_COLOR_TYPE_PALETTE) { - png_set_palette_to_rgb(png_ptr); - bit_depth = 8; - } - - // If there's an alpha channel convert gray to RGB - if (png_get_color_type(png_ptr, info_ptr) == PNG_COLOR_TYPE_GRAY_ALPHA) { - png_set_gray_to_rgb(png_ptr); - } - - png_set_interlace_handling(png_ptr); - png_read_update_info(png_ptr, info_ptr); - - row_pointers.resize(height); - for (png_uint_32 row = 0; row < height; row++) { - row_pointers[row] = new png_byte[png_get_rowbytes(png_ptr, info_ptr)]; - } - - png_read_image(png_ptr, &row_pointers[0]); - - npy_intp dimensions[3]; - dimensions[0] = height; // numrows - dimensions[1] = width; // numcols - if (png_get_color_type(png_ptr, info_ptr) & PNG_COLOR_MASK_ALPHA) { - dimensions[2] = 4; // RGBA images - } else if (png_get_color_type(png_ptr, info_ptr) & PNG_COLOR_MASK_COLOR) { - dimensions[2] = 3; // RGB images - } else { - dimensions[2] = 1; // Greyscale images - } - - if (float_result) { - double max_value = (1 << bit_depth) - 1; - - numpy::array_view A(dimensions); - - for (png_uint_32 y = 0; y < height; y++) { - png_byte *row = row_pointers[y]; - for (png_uint_32 x = 0; x < width; x++) { - if (bit_depth == 16) { - png_uint_16 *ptr = &reinterpret_cast(row)[x * dimensions[2]]; - for (png_uint_32 p = 0; p < (png_uint_32)dimensions[2]; p++) { - A(y, x, p) = (float)(ptr[p] / max_value); - } - } else { - png_byte *ptr = &(row[x * dimensions[2]]); - for (png_uint_32 p = 0; p < (png_uint_32)dimensions[2]; p++) { - A(y, x, p) = (float)(ptr[p] / max_value); - } - } - } - } - - result = A.pyobj(); - } else if (bit_depth == 16) { - numpy::array_view A(dimensions); - - for (png_uint_32 y = 0; y < height; y++) { - png_byte *row = row_pointers[y]; - for (png_uint_32 x = 0; x < width; x++) { - png_uint_16 *ptr = &reinterpret_cast(row)[x * dimensions[2]]; - for (png_uint_32 p = 0; p < (png_uint_32)dimensions[2]; p++) { - A(y, x, p) = ptr[p]; - } - } - } - - result = A.pyobj(); - } else if (bit_depth == 8) { - numpy::array_view A(dimensions); - - for (png_uint_32 y = 0; y < height; y++) { - png_byte *row = row_pointers[y]; - for (png_uint_32 x = 0; x < width; x++) { - png_byte *ptr = &(row[x * dimensions[2]]); - for (png_uint_32 p = 0; p < (png_uint_32)dimensions[2]; p++) { - A(y, x, p) = ptr[p]; - } - } - } - - result = A.pyobj(); - } else { - PyErr_SetString(PyExc_RuntimeError, "image has unknown bit depth"); - goto exit; - } - - // free the png memory - png_read_end(png_ptr, info_ptr); - - // For gray, return an x by y array, not an x by y by 1 - num_dims = (png_get_color_type(png_ptr, info_ptr) & PNG_COLOR_MASK_COLOR) ? 3 : 2; - - if (num_dims == 2) { - PyArray_Dims dims = {dimensions, 2}; - PyObject *reshaped = PyArray_Newshape((PyArrayObject *)result, &dims, NPY_CORDER); - Py_DECREF(result); - result = reshaped; - } - -exit: - if (png_ptr && info_ptr) { -#ifndef png_infopp_NULL - png_destroy_read_struct(&png_ptr, &info_ptr, NULL); -#else - png_destroy_read_struct(&png_ptr, &info_ptr, png_infopp_NULL); -#endif - } - - for (png_uint_32 row = 0; row < height; row++) { - delete[] row_pointers[row]; - } - - if (PyErr_Occurred()) { - Py_XDECREF(result); - return NULL; - } else { - return result; - } -} - -const char *Py_read_png_float__doc__ = - "read_png_float(file)\n" - "\n" - "Read in a PNG file, converting values to floating-point doubles\n" - "in the range (0, 1)\n" - "\n" - "Parameters\n" - "----------\n" - "file : binary-mode file-like object\n"; - -static PyObject *Py_read_png_float(PyObject *self, PyObject *args, PyObject *kwds) -{ - return _read_png(args, true); -} - -const char *Py_read_png_int__doc__ = - "read_png_int(file)\n" - "\n" - "Read in a PNG file with original integer values.\n" - "\n" - "Parameters\n" - "----------\n" - "file : binary-mode file-like object\n"; - -static PyObject *Py_read_png_int(PyObject *self, PyObject *args, PyObject *kwds) -{ - return _read_png(args, false); -} - -const char *Py_read_png__doc__ = - "read_png(file)\n" - "\n" - "Read in a PNG file, converting values to floating-point doubles\n" - "in the range (0, 1)\n" - "\n" - "Alias for read_png_float()\n" - "\n" - "Parameters\n" - "----------\n" - "file : binary-mode file-like object\n"; - -static PyMethodDef module_methods[] = { - {"write_png", (PyCFunction)Py_write_png, METH_VARARGS|METH_KEYWORDS, Py_write_png__doc__}, - {"read_png", (PyCFunction)Py_read_png_float, METH_O, Py_read_png__doc__}, - {"read_png_float", (PyCFunction)Py_read_png_float, METH_O, Py_read_png_float__doc__}, - {"read_png_int", (PyCFunction)Py_read_png_int, METH_O, Py_read_png_int__doc__}, - {NULL} -}; - -extern "C" { - - static struct PyModuleDef moduledef = { - PyModuleDef_HEAD_INIT, - "_png", - NULL, - 0, - module_methods, - NULL, - NULL, - NULL, - NULL - }; - - PyMODINIT_FUNC PyInit__png(void) - { - PyObject *m; - - m = PyModule_Create(&moduledef); - - if (m == NULL) { - return NULL; - } - - import_array(); - - if (PyModule_AddIntConstant(m, "PNG_FILTER_NONE", PNG_FILTER_NONE) || - PyModule_AddIntConstant(m, "PNG_FILTER_SUB", PNG_FILTER_SUB) || - PyModule_AddIntConstant(m, "PNG_FILTER_UP", PNG_FILTER_UP) || - PyModule_AddIntConstant(m, "PNG_FILTER_AVG", PNG_FILTER_AVG) || - PyModule_AddIntConstant(m, "PNG_FILTER_PAETH", PNG_FILTER_PAETH)) { - return NULL; - } - - - return m; - } -} diff --git a/src/checkdep_libpng.c b/src/checkdep_libpng.c deleted file mode 100644 index fc8811b508dc..000000000000 --- a/src/checkdep_libpng.c +++ /dev/null @@ -1,17 +0,0 @@ -#ifdef __has_include - #if !__has_include() - #error "libpng version 1.2 or higher is required. \ -Consider installing it with e.g. 'conda install libpng', \ -'apt install libpng12-dev', 'dnf install libpng-devel', or \ -'brew install libpng'." - #endif -#endif - -#include -#pragma message("Compiling with libpng version " PNG_LIBPNG_VER_STRING ".") -#if PNG_LIBPNG_VER < 10200 - #error "libpng version 1.2 or higher is required. \ -Consider installing it with e.g. 'conda install libpng', \ -'apt install libpng12-dev', 'dnf install libpng-devel', or \ -'brew install libpng'." -#endif