diff --git a/docs/reference/image.rst b/docs/reference/image.rst index 1b3e43c1..5fe115ba 100644 --- a/docs/reference/image.rst +++ b/docs/reference/image.rst @@ -47,6 +47,12 @@ no_tint() .. autofunction:: no_tint :noindex: +save_canvas() +------------- + +.. autofunction:: save_canvas + :noindex: + Pixels ====== diff --git a/p5/core/api.py b/p5/core/api.py index a6514404..8a9d29d4 100644 --- a/p5/core/api.py +++ b/p5/core/api.py @@ -17,7 +17,7 @@ ) from .color import color_mode from .attribs import no_fill, no_tint, no_stroke, stroke_cap, stroke_join, stroke_weight -from .image import load_image, load_pixels, save_frame, image_mode +from .image import load_image, load_pixels, save_canvas, image_mode from .font import ( create_font, load_font, @@ -363,8 +363,8 @@ def loadPixels(): load_pixels() -def saveFrame(filename=None): - save_frame(filename) +def saveCanvas(filename=None): + save_canvas(filename) def createFont(name, size=10): diff --git a/p5/core/constants.py b/p5/core/constants.py index fc56528f..ddf139ca 100644 --- a/p5/core/constants.py +++ b/p5/core/constants.py @@ -247,3 +247,15 @@ class SType(Enum): # color parse modes RGB = "RGB" HSB = "HSB" + +# Image filters +THRESHOLD = "THRESHOLD" +GRAY = "GRAY" +OPAQUE = "OPAQUE" +INVERT = "INVERT" +POSTERIZE = "POSTERIZE" +BLUR = "BLUR" +ERODE = "ERODE" +DILATE = "DILATE" + +RGBA_CHANNELS = 4 diff --git a/p5/core/image.py b/p5/core/image.py index 0de4774a..07d62fff 100644 --- a/p5/core/image.py +++ b/p5/core/image.py @@ -25,6 +25,8 @@ "image_mode", "load_pixels", "update_pixels", + "create_image", + "save_canvas", ] @@ -90,6 +92,11 @@ def size(self): @size.setter @abstractmethod def size(self, new_size): + """ + set or resize the PImage + :param size: size of the image + :type size: tuple + """ pass @property @@ -170,7 +177,7 @@ def save(self, file_name): pass -def image(*args, size=None): +def image(img, x, y, w=None, h=None): """Draw an image to the display window. Images must be in the same folder as the sketch (or the image path @@ -178,22 +185,22 @@ def image(*args, size=None): modified with the :meth:`p5.tint` function. :param img: the image to be displayed. - :type img: p5.Image + :type img: PImage :param x: x-coordinate of the image by default - :type float: + :type x: float :param y: y-coordinate of the image by default - :type float: + :type y: float :param w: width to display the image by default - :type float: + :type w: float :param h: height to display the image by default - :type float: + :type h: float """ - p5.renderer.image(*args) + p5.renderer.image(img, x, y, w, h) def image_mode(mode): @@ -243,8 +250,8 @@ def load_image(filename): file-extennsion is automatically inferred. :type filename: str - :returns: An :class:`p5.PImage` instance with the given image data - :rtype: :class:`p5.PImage` + :returns: An :class:`PImage` instance with the given image data + :rtype: :class:`PImage` """ return p5.renderer.load_image(filename) @@ -269,5 +276,27 @@ def update_pixels(): p5.renderer.update_pixels() -def save_frame(filename=None): - p5.renderer.save_frame(filename) +def save_canvas(filename=None, canvas=None): + """ + Saves the given Canvas as an image with filename + :param filename: filename/path for the image + :type filename: str + + :param canvas: Canvas to be saved. If not specified default canvas is used + :type canvas: PGraphics + """ + p5.renderer.save_canvas(filename, canvas) + + +def create_image(width, height): + """ + Creates a new p5.Image (the datatype for storing images). This provides a fresh buffer of pixels to play with. + Set the size of the buffer with the width and height parameters. + + :param width: Width in pixels + :type width: int + + :param height: Height in pixels + :type height: int + """ + return p5.renderer.create_image(width, height) diff --git a/p5/sketch/Skia2DRenderer/image.py b/p5/sketch/Skia2DRenderer/image.py new file mode 100644 index 00000000..6920156d --- /dev/null +++ b/p5/sketch/Skia2DRenderer/image.py @@ -0,0 +1,119 @@ +from p5.core import constants +from p5.core import PImage +import numpy as np +import skia +import builtins + +class SkiaPImage(PImage): + def __init__(self, width, height, pixels=None): + self._width = width + self._height = height + self.pixels = ( + pixels + if pixels is not None + else np.zeros((width, height, constants.RGBA_CHANNELS), dtype=np.uint8) + ) + + @property + def width(self): + return self._width + + @width.setter + def width(self, width): + self._width = width + + @property + def height(self): + return self._height + + @height.setter + def height(self, height): + self._height = height + + @property + def size(self): + return (self.width, self.height) + + @size.setter + def size(self, size): + self.width, self.height = size + self.pixels.resize((*size, constants.RGBA_CHANNELS)) + + @property + def aspect_ratio(self): + return self.width / self.height + + def load_pixels(self): + builtins.pixels = self.pixels + + def update_pixels(self): + pass + + def mask(self, image): + """ + :param image: Image to be used as make + :type image: SkiaPImage + """ + with skia.Surface(self.pixels) as canvas: + canvas.drawImage(image.get_skia_image()) + + def filter(self, kind, param=0.5): + # https://www.baeldung.com/cs/convert-rgb-to-grayscale#3-luminosity-method + if kind == constants.THRESHOLD: + threshold = param * 255 + mask = np.dot(self.pixels[..., :3], [0.2989, 0.5870, 0.1140]) < threshold + self.pixels[:, :, :3][mask] = 1 + + if kind == constants.GRAY: + mask = np.dot(self.pixels[..., :3], [0.2989, 0.5870, 0.1140]) + mask = np.array([[i] * 3 for i in mask.flatten()]).reshape( + (self.pixels.shape[0], self.pixels.shape[1], 3) + ) + self.pixels[:, :, :3] = mask + + if kind == constants.OPAQUE: + self.pixels[..., 3:] = 255 + + if kind == constants.INVERT: + + def invert(x): + return 255 - x + + self.pixels[..., :3] = invert(self.pixels[..., :3]) + + if kind == constants.POSTERIZE: + raise NotImplementedError("POSTERIZE is not yet implemented for skia") + + if kind == constants.DILATE: + with skia.Surface(self.pixels) as canvas: + paint = skia.Paint(ImageFilter=skia.ImageFilters.Dilate(param, param)) + image = canvas.getSurface().makeImageSnapshot() + canvas.clear(skia.ColorWHITE) + canvas.drawImage(image, 0, 0, paint) + + if kind == constants.ERODE: + with skia.Surface(self.pixels) as canvas: + paint = skia.Paint(ImageFilter=skia.ImageFilters.Erode(param, param)) + image = canvas.getSurface().makeImageSnapshot() + canvas.clear(skia.Color(0, 0, 0, 0)) + canvas.drawImage(image, 0, 0, paint) + + if kind == constants.BLUR: + with skia.Surface(self.pixels) as canvas: + paint = skia.Paint(ImageFilter=skia.ImageFilters.Blur(param, param)) + image = canvas.getSurface().makeImageSnapshot() + canvas.clear(skia.Color(0, 0, 0, 0)) + canvas.drawImage(image, 0, 0, paint) + + def blend(self, other, mode): + # TODO: Implement blend + raise NotImplementedError("To be implemented") + + def save(self, filename): + if filename.endswith(".png"): + skia.Image.fromarray(self.pixels).save(filename, skia.kPNG) + else: + skia.Image.fromarray(self.pixels).save(filename, skia.kJPEG) + + def get_skia_image(self): + return skia.Image.fromarray(self.pixels) diff --git a/p5/sketch/Skia2DRenderer/renderer2d.py b/p5/sketch/Skia2DRenderer/renderer2d.py index 0b67ad33..08a78eba 100644 --- a/p5/sketch/Skia2DRenderer/renderer2d.py +++ b/p5/sketch/Skia2DRenderer/renderer2d.py @@ -9,6 +9,8 @@ from p5.core.primitives import point, line from p5.pmath.utils import * +from .image import SkiaPImage + @dataclass class Style2D: @@ -68,6 +70,7 @@ def __init__(self): self.style_stack = [] self.path = None self.curve_tightness = 0 + self.pimage = None # Transforms functions def push_matrix(self): @@ -704,6 +707,42 @@ def end_shape( self.path.lineTo(v[0], v[1]) self._do_fill_stroke_close(close_shape) - # Fonts functions - # Images functions + def create_image(self, width, height): + return SkiaPImage(width, height) + + def image(self, pimage, x, y, w=None, h=None): + if self.style.image_mode == constants.CORNER: + if w and h: + pimage.size = (w, h) + elif self.style.image_mode == constants.CORNERS: + pimage.size = (w - x, h - y) + elif self.style.image_mode == constants.CENTER: + if w and h: + pimage.size = (w, h) + size = pimage.size + x += size[0] // 2 + y += size[1] // 2 + self.canvas.drawImage(pimage.get_skia_image(), x, y) + + def load_pixels(self): + c_array = self.canvas.toarray() + self.pimage = SkiaPImage(c_array.shape[0], c_array.shape[1], c_array) + self.pimage.load_pixels() + + def update_pixels(self): + self.pimage.update_pixels() + self.image(self.pimage, 0, 0) + + def load_image(self, filename): + image = skia.Image.open(filename) + return SkiaPImage(image.width, image.height, pixels=image.toarray()) + + def save_canvas(self, filename, canvas): + if canvas: + # TODO: Get the surface of the PGraphics object yet to be implemented + pass + else: + canvas = self.canvas + image = canvas.getSurface().makeImageSnapshot() + image.save(filename) diff --git a/p5/sketch/Vispy2DRenderer/renderer2d.py b/p5/sketch/Vispy2DRenderer/renderer2d.py index d77d4711..25a4ada9 100644 --- a/p5/sketch/Vispy2DRenderer/renderer2d.py +++ b/p5/sketch/Vispy2DRenderer/renderer2d.py @@ -537,22 +537,20 @@ def text_style(self): def text_wrap(self, text_wrap_style): raise NotImplementedError("Not Implemented in Vispy") - def image(self, *args, size=None): - if len(args) == 3: - img, location = args[0], args[1:] - elif len(args) == 5: - img, location, size = args[0], args[1:3], args[3:] - else: - raise ValueError("Unexpected number of arguments passed to image()") + def image(self, img, x, y, w, h): + + location = (x, y) + if w is None: + w = img.size[0] + if h is None: + h = img.size[1] - if size is None: - size = img.size + size = (w, h) # Add else statement below to resize the img._img first, # or it will take much time to render large image, # even when small size is specified to the image - else: - if size != img.size: - img.size = size + if size != img.size: + img.size = size lx, ly = location sx, sy = size @@ -597,7 +595,7 @@ def update_pixels(self): builtins.pixels = None - def save_frame(self, filename): + def save_canvas(self, filename, canvas): if filename: p5.sketch.screenshot(filename) else: diff --git a/p5/sketch/userspace.py b/p5/sketch/userspace.py index e2c4f53b..ea143102 100644 --- a/p5/sketch/userspace.py +++ b/p5/sketch/userspace.py @@ -364,7 +364,7 @@ def save(filename="screen.png"): """ # TODO: images saved using ``save()`` should *not* be numbered. # --abhikpal (2018-08-14) - p5.sketch.screenshot(filename) + p5.renderer.save_canvas(filename) def save_frame(filename="screen.png"):