diff --git a/enable/gcbench/bench.py b/enable/gcbench/bench.py index b628335ba..cb203cad0 100644 --- a/enable/gcbench/bench.py +++ b/enable/gcbench/bench.py @@ -25,6 +25,7 @@ "blend2d": "enable.null.blend2d", "cairo": "enable.null.cairo", "celiagg": "enable.null.celiagg", + "pil_image": "enable.null.pil_image", "qpainter": "enable.null.qpainter", "quartz": "enable.null.quartz", }, diff --git a/enable/null/pil_image.py b/enable/null/pil_image.py new file mode 100644 index 000000000..7db2fc446 --- /dev/null +++ b/enable/null/pil_image.py @@ -0,0 +1,26 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +from kiva.pil_image import CompiledPath, GraphicsContext # noqa + + +class NativeScrollBar(object): + pass + + +class Window(object): + pass + + +def font_metrics_provider(): + from kiva.api import Font + + gc = GraphicsContext((1, 1)) + gc.set_font(Font()) + return gc diff --git a/enable/qt4/pil_image.py b/enable/qt4/pil_image.py new file mode 100644 index 000000000..56da82175 --- /dev/null +++ b/enable/qt4/pil_image.py @@ -0,0 +1,78 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import numpy as np +from kiva.pil_image import CompiledPath, GraphicsContext # noqa +from pyface.qt import QtCore, QtGui +from traits.api import Array + +from .base_window import BaseWindow +from .scrollbar import NativeScrollBar # noqa + + +class Window(BaseWindow): + # Keep a buffer around for converting RGBA -> BGRA + _shuffle_buffer = Array(shape=(None, None, 4), dtype=np.uint8) + + def _create_gc(self, size, pix_format="rgba32"): + gc = GraphicsContext( + (size[0] + 1, size[1] + 1), + pix_format=pix_format, + base_pixel_scale=self.base_pixel_scale, + ) + gc.translate_ctm(0.5, 0.5) + + self._shuffle_buffer = np.empty( + (size[1] + 1, size[0] + 1, 4), dtype=np.uint8 + ) + + return gc + + def _window_paint(self, event): + if self.control is None: + return + + # Convert to Qt's pixel format + self._shuffle_copy() + + # self._gc is an image context + w = self._gc.width() + h = self._gc.height() + image = QtGui.QImage( + self._shuffle_buffer, w, h, QtGui.QImage.Format_RGB32 + ) + rect = QtCore.QRectF( + 0, 0, w / self._gc.base_scale, h / self._gc.base_scale + ) + painter = QtGui.QPainter(self.control) + painter.drawImage(rect, image) + + def _shuffle_copy(self): + """ Convert from RGBA -> BGRA. + Supported source formats are: rgb24, rgba32, & bgra32 + + Qt's Format_RGB32 is actually BGR. So, Yeah... + """ + src = np.array(self._gc.image) + dst = self._shuffle_buffer + + indices = (2, 1, 0) + dst[..., 0] = src[..., indices[0]] + dst[..., 1] = src[..., indices[1]] + dst[..., 2] = src[..., indices[2]] + dst[..., 3] = src[..., 3] + + +def font_metrics_provider(): + from kiva.api import Font + + gc = GraphicsContext((1, 1)) + gc.set_font(Font()) + return gc diff --git a/enable/wx/pil_image.py b/enable/wx/pil_image.py new file mode 100644 index 000000000..1e3635cb0 --- /dev/null +++ b/enable/wx/pil_image.py @@ -0,0 +1,50 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import numpy as np +import wx + +from kiva.pil_image import CompiledPath, GraphicsContext # noqa + +from .base_window import BaseWindow +from .scrollbar import NativeScrollBar + + +class Window(BaseWindow): + def _create_gc(self, size, pix_format="bgra32"): + gc = GraphicsContext( + (size[0] + 1, size[1] + 1), + base_pixel_scale=self.base_pixel_scale, + ) + gc.translate_ctm(0.5, 0.5) + return gc + + def _window_paint(self, event): + if self.control is None: + event.Skip() + return + + control = self.control + wdc = control._dc = wx.PaintDC(control) + + bmp = wx.Bitmap.FromBufferRGBA( + self._gc.width(), self._gc.height(), self._gc.image.tobytes() + ) + wdc.DrawBitmap(bmp, 0, 0) + + control._dc = None + + +def font_metrics_provider(): + from kiva.api import Font + + gc = GraphicsContext((1, 1)) + gc.set_font(Font()) + return gc diff --git a/kiva/basecore2d.py b/kiva/basecore2d.py index d04936b56..67939bdd9 100644 --- a/kiva/basecore2d.py +++ b/kiva/basecore2d.py @@ -1218,7 +1218,8 @@ def device_transform_device_ctm(self, func, args): elif func == CONCAT_CTM: self.device_ctm = affine.concat(self.device_ctm, args[0]) elif func == LOAD_CTM: - self.device_ctm = args[0].copy() + self.device_prepare_device_ctm() + self.device_ctm = affine.concat(self.device_ctm, args[0]) def device_draw_rect(self, x, y, sx, sy, mode): """ Default implementation of drawing a rect. diff --git a/kiva/pil_image.py b/kiva/pil_image.py new file mode 100644 index 000000000..44f99b989 --- /dev/null +++ b/kiva/pil_image.py @@ -0,0 +1,298 @@ +from copy import deepcopy +from io import BytesIO +import os + +import numpy as np +from PIL import Image, ImageChops, ImageDraw, ImageFont + +from kiva import affine +from kiva.oldagg import CompiledPath +from kiva.basecore2d import GraphicsContextBase +from kiva.constants import EOF_FILL, EOF_FILL_STROKE, FILL, FILL_STROKE, JOIN_ROUND, STROKE, TEXT_FILL, TEXT_FILL_STROKE, TEXT_FILL_STROKE_CLIP, TEXT_STROKE, WEIGHT_BOLD + + +class GraphicsContext(GraphicsContextBase): + + def __init__(self, size, base_pixel_scale=1.0, *args, **kwargs): + self.size = size + self.base_scale = base_pixel_scale + self.image = Image.new('RGBA', self.size) + self.bmp_array = np.array(self.image) + self.image_draw = ImageDraw.Draw(self.image) + self._scale_factor = None + self._clip_mask = None + super().__init__(*args, **kwargs) + + # AbstractGraphicsContext protocol + + def show_text_at_point(self, text, x, y): + """ + """ + self.show_text(text, (x, y)) + + def save(self, filename, file_format=None, pil_options={}): + + if file_format is None: + file_format = '' + if pil_options is None: + pil_options = {} + + ext = ( + os.path.splitext(filename)[1][1:] if isinstance(filename, str) + else '' + ) + + # Check the output format to see if it can handle DPI + dpi_formats = ('jpg', 'png', 'tiff', 'jpeg') + if ext in dpi_formats or file_format.lower() in dpi_formats: + # Assume 72dpi is 1x + dpi = int(72 * self.base_scale) + pil_options['dpi'] = (dpi, dpi) + + self.image.save(filename, file_format, **pil_options) + + def to_image(self): + """ Return the contents of the context as a PIL Image. + + If the graphics context is in BGRA format, it will convert it to + RGBA for the image. + + Returns + ------- + img : Image + A PIL/Pillow Image object with the data in RGBA format. + """ + return self.image.copy() + + def width(self): + return self.size[0] + + def height(self): + return self.size[1] + + def clear(self, clear_color=(1.0, 1.0, 1.0, 1.0)): + pil_color = tuple(int(x * 255) for x in clear_color) + self.image.paste(pil_color, (0, 0,) + self.size) + + # GrpahicsContext device methods + + def device_prepare_device_ctm(self): + self.device_ctm = self._coordinate_transform() + + def device_transform_device_ctm(self, func, args): + self._scale_factor = None + super().device_transform_device_ctm(func, args) + + def device_set_clipping_path(self, x, y, w, h): + image = Image.new("L", self.size, color=0) + draw = ImageDraw.Draw(image) + transform = self._coordinate_transform() + + x1, y1 = affine.transform_point(transform, (x, y)) + x2, y2 = affine.transform_point(transform, (x + w, y + h)) + draw.rectangle( + (x1, y1, x2, y2), + fill=255, + ) + self._clip_mask = image + + def device_destroy_clipping_path(self): + self._clip_mask = None + + def device_show_text(self, text): + """ Draws text on the device at the current text position. + + Advances the current text position to the end of the text. + """ + # set up the font and aesthetics + font = self.state.font + spec = font.findfont() + scale_factor = self._get_scale_factor() + font_size = int(font.size * scale_factor) + pil_font = ImageFont.FreeTypeFont(spec.filename, font_size, spec.face_index) + + if self.state.text_drawing_mode in {TEXT_FILL, TEXT_FILL_STROKE}: + fill_color = self.state.fill_color.copy() + fill_color[3] *= self.state.alpha + fill = tuple((255 * fill_color).astype(int)) + else: + fill = (0, 0, 0, 0) + if self.state.text_drawing_mode in {TEXT_STROKE, TEXT_FILL_STROKE}: + stroke_color = self.state.line_color.copy() + stroke_color[3] *= self.state.alpha + stroke = tuple((255 * stroke_color).astype(int)) + stroke_width = self.base_scale + else: + stroke = (0, 0, 0, 0) + stroke_width = 0 + + # create an image containing the text + ascent, descent = pil_font.getmetrics() + w, h = self.image_draw.textsize(text, pil_font) + h = max(h, ascent + descent) + + temp_image = Image.new('RGBA', (w + 2*stroke_width, h + 2*stroke_width)) + draw = ImageDraw.Draw(temp_image) + draw.text((0, 0), text, fill, pil_font, stroke_width=stroke_width, stroke_fill=stroke) + + # paste the text into the image + temp_image = temp_image.transpose(Image.FLIP_TOP_BOTTOM) + transform = affine.concat( + self.device_ctm, + affine.concat( + self.state.ctm, + affine.translate( + affine.scale( + self.state.text_matrix, + 1/scale_factor, 1/scale_factor, + ), + 0, -descent, + ) + ) + ) + a, b, c, d, tx, ty = affine.affine_params(affine.invert(transform)) + temp_image = temp_image.transform( + self.image.size, + Image.AFFINE, + (a, b, tx, c, d, ty), + Image.BILINEAR, + fillcolor=(0, 0, 0, 0), + ) + self._compose(temp_image) + + tx, ty = self.get_text_position() + self.set_text_position(tx + w, ty) + + def device_get_text_extent(self, text): + font = self.state.font + spec = font.findfont() + pil_font = ImageFont.FreeTypeFont(spec.filename, font.size, spec.face_index) + w, h = self.image_draw.textsize(text, pil_font) + return (0, font.size - h, w, h) + + def device_update_line_state(self): + # currently unused - ImageDraw has no public line state + pass + + def device_update_fill_state(self): + # currently unused - ImageDraw has no public fill state + pass + + def device_fill_points(self, pts, mode): + pts = affine.transform_points(self.device_ctm, pts).reshape(-1).tolist() + if mode in {FILL, EOF_FILL, FILL_STROKE, EOF_FILL_STROKE}: + temp_image = Image.new("RGBA", self.size) + draw = ImageDraw.Draw(temp_image) + draw.polygon( + pts, + fill=tuple((255 * self.state.fill_color).astype(int)), + ) + self._compose(temp_image) + + def device_stroke_points(self, pts, mode): + pts = affine.transform_points(self.device_ctm, pts).reshape(-1).tolist() + scale_factor = self._get_scale_factor() + if mode in {STROKE, FILL_STROKE, EOF_FILL_STROKE}: + temp_image = Image.new("RGBA", self.size) + draw = ImageDraw.Draw(temp_image) + draw.line( + pts, + fill=tuple((255 * self.state.line_color).astype(int)), + width=int(self.state.line_width * scale_factor), + joint='curve', + ) + self._compose(temp_image) + + def device_draw_image(self, image, rect=None): + if isinstance(image, GraphicsContext): + image = image.image + elif isinstance(image, np.ndarray): + image = Image.fromarray(image) + elif hasattr(image, "bmp_array"): + image = Image.fromarray(image.bmp_array) + if rect is None: + rect = (0, 0, image.width, image.height) + if image.mode != 'RGBA': + image = image.convert('RGBA') + image = image.transpose(Image.FLIP_TOP_BOTTOM) + transform = affine.scale( + affine.translate( + self.device_ctm, + rect[0], rect[1], + ), + rect[2]/image.width, rect[3]/image.height + ) + a, b, c, d, tx, ty = affine.affine_params(affine.invert(transform)) + temp_image = image.transform( + self.image.size, + Image.AFFINE, + (a, b, tx, c, d, ty), + Image.BILINEAR, + fillcolor=(0, 0, 0, 0), + ) + self._compose(temp_image) + + # IPython hooks + + def _repr_png_(self): + """ Return a the current contents of the context as PNG image. + + This provides Jupyter and IPython compatibility, so that the graphics + context can be displayed in the Jupyter Notebook or the IPython Qt + console. + + Returns + ------- + data : bytes + The contents of the context as PNG-format bytes. + """ + data = BytesIO() + dpi = 72 * self.base_scale + self.image.save(data, format='png', dpi=(dpi, dpi)) + return data.getvalue() + + # Private methods + + def _compose(self, image): + """ Compose a drawing image with the main image with clipping.""" + if self._clip_mask is not None: + alpha = ImageChops.multiply(image.getchannel("A"), self._clip_mask) + image.putalpha(alpha) + self.image.alpha_composite(image) + + def _get_scale_factor(self): + """A scale factor for the current affine transform. + + This is a suitable factor to use to scale font sizes, line widths, + etc. or otherwise to get an idea of how + + This is the maximum amount a distance will be stretched by the + linear part of the transform. It is effectively the operator norm + of the linear part of the affine transform which can be computed as + the maximum singular value of the matrix. + """ + if self._scale_factor is None: + _, singular_values, _ = np.linalg.svd(self.device_ctm[:2, :2]) + # numpy's svd function returns the singular values sorted + self._scale_factor = singular_values[0] + return self._scale_factor + + def _coordinate_transform(self): + return affine.translate( + affine.scale( + affine.affine_identity(), + self.base_scale, + -self.base_scale, + ), + 0.0, + -self.size[1]/self.base_scale, + ) + + +def font_metrics_provider(): + """ Creates an object to be used for querying font metrics. + """ + return GraphicsContext((1, 1)) + + +__all__ = [GraphicsContext, CompiledPath, font_metrics_provider] diff --git a/kiva/tests/test_pil_drawing.py b/kiva/tests/test_pil_drawing.py new file mode 100644 index 000000000..a641ec716 --- /dev/null +++ b/kiva/tests/test_pil_drawing.py @@ -0,0 +1,40 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import unittest + +from kiva.pil_image import GraphicsContext +from kiva.tests.drawing_tester import DrawingImageTester + + +class TestPILImageDrawing(DrawingImageTester, unittest.TestCase): + + def create_graphics_context(self, width=600, height=600, pixel_scale=1.0): + return GraphicsContext((width, height), base_pixel_scale=pixel_scale) + + def test_save_dpi(self): + # Base DPI is 72, but our default pixel scale is 2x. + self.assertEqual(self.save_and_return_dpi(), 144) + + def test_clip_rect_transform(self): + with self.draw_and_check(): + self.gc.clip_to_rect(0, 0, 100, 100) + self.gc.begin_path() + self.gc.rect(75, 75, 25, 25) + self.gc.fill_path() + + def test_ipython_repr_png(self): + self.gc.begin_path() + self.gc.rect(75, 75, 25, 25) + self.gc.fill_path() + stream = self.gc._repr_png_() + filename = "{0}.png".format(self.filename) + with open(filename, 'wb') as fp: + fp.write(stream) + self.assertImageSavedWithContent(filename)