Device and user space in generativepy.drawing

Martin McBride, 2020-08-30
Tags generativepy tutorial device space user space affine transform transform
Categories generativepy generativepy tutorial

In the previous article we used generativepy to create a simple image of a rectangle.

We used pixel coordinates to draw the rectangle. That is to say, we created a rectangle that had a size of 250 by 200 units, and the final image contained a rectangle that was exactly 250 by 200 pixels in size.

That isn't the only option. We can scale our drawing space. For example, we can scale the space by a factor of 100. We can then draw a rectangle that is 2.5 units by 2 units, and it will appear on the image at 250 by 200 pixels.

This type of scaling has a couple of advantages:

  • We can use the most convenient units when we make our drawing. For example, if we wanted to draw a right-angled triangle with sides of 3cm, 4cm and 5cm, we could draw it using lengths of 3, 4 and 5 units. We could then scale it to be whatever pixel size we wanted on the final image.
  • If we need to create the same image at different pixel sizes, we can do it very easily. For example, we might want a 400-pixel wide image of the triangle for a web page, but a 4000-pixel wide image for a book illustration. We can do this using the same drawing code, just changing the scaling.

This article mainly covers device and user space for vector drawing (ie the drawing and movie modules). There is a section at the end that covers user space for the bitmap module.

Device and user space.

generativepy uses the concept of device space and user space to do this scaling (actually it makes direct use of the Pycairo implementation).

Device space is fixed, and always represents the pixel coordinates of the final output image.

User space maps on to device space using a transformation that we can choose in our code.

Whenever we draw anything, it is always drawn in user space coordinates, and the coordinates get mapped onto device space using the current transform. However, the initial transform is 1:1, so if we never change the transform it appears as if we are drawing in device space.

The transformation between user and device space can include scaling, translation, rotation, mirroring, and shearing in any combination (in fact it can be any affine transformation). You can change the current transform at any time.

Initialising the user space transform

To make things a bit easier, generativepy allows you to specify the two most commonly used transforms - scaling and translation - in the setup function of the drawing module.

Whether you use this feature or not, you can still make further changes to the transform by using the standard Pycairo functions.

Scaling user space

In this example, we will see how to scale user space. We will use the previous example of an orange rectangle.

Here is the code:

from generativepy.drawing import make_image, setup
from generativepy.color import Color
from generativepy.geometry import Rectangle, Circle

def draw_rect(ctx, width, height, frame_no, frame_count):
    setup(ctx, width, height, width=5, background=Color(0.4))    
    color = Color(1, 0.5, 0)
    Rectangle(ctx).of_corner_size((1, 1.5), 2.5, 2).fill(color)

make_image("rectangle-user.png", draw_rect, 500, 400)

The main difference here is that we have added a width=5 parameter to the setup function.

This tells generativepy to set the user space width to 5 units. Since the device width is 500 pixels, this establishes a user space scaling of 100 - that is, 1 unit in user space maps onto 100 pixels in device space.

The relationship between device and user space is shown here:

Here is the image it produces (which is identical to the image created in the previous tutorial, even though the rectangle is expressed in a different user space).

The setup function accepts either a width, or height, or both.

  • Since the pixel size is 500 by 400, setting width to 5 creates a scale factor of 100, so the user space of 5 by 4 maps onto device space 500 by 400.
  • If you prefer you could set the height to 4 instead. This will also create a scale factor of 100 and has exactly the same effect as setting width to 5.
  • You can set both width and height. This creates the possibility of having different scale factors in the x and y directions. For example, width=5 and height=8 creates a scale factor of 100 in the x-direction but 50 in the y-direction. User space of 5 by 8 maps onto device space 500 by 400. This means that objects are "squashed" in the y-direction, so for example, if you draw a square it will appear as an elongated rectangle.

Of course, you don't need to stick to integer scale factors. For example, you could take a 500 by 400 device space and select a width of 857 if you wanted, which would create a scale factor of about 0.583.

Changing the pixel size of the output image

Suppose we wanted to create a different-sized image. Say 2635 by 2108 pixel (totally random size, but in the ratio 5:4). All we need to change is the make_image call:

make_image("rectangle-user.png", draw_rect, 2635, 2108)

This will create an image that has been perfectly scaled up to the new size.

Scaling and translating user space

In this example, we will scale and translate user space.

By default, the origin (0, 0) is always in the top left of the image. That isn't always what you want, and it can be changed like this:

from generativepy.drawing import make_image, setup
from generativepy.color import Color
from generativepy.geometry import Rectangle, Circle

def draw_circle(ctx, width, height, frame_no, frame_count):
    setup(ctx, width, height, width=4,
          startx=-2, starty=-2, background=Color(0.4))    
    color = Color("magenta")
    Circle(ctx).of_center_radius((0, 0), 1.5).fill(color)

make_image("circle-user.png", draw_circle, 400, 400)

This time the pixel size is 400 square, and we have set width=4 so our user space is 4 units square.

But notice that we have also set startx=-2 and starty=-2, which means that the whole user-space is shifted by -2 in the x and y directions. The top left of the image is now (-2, -2), which means that the origin (0, 0) is now at the centre of the image, like this:

This means that when we draw a circle centred on the origin, it is right in the centre of the image:

Other transformations

Here is a quick example of how to use other transformations, such as rotation, using native Pycairo calls.

In this example we will draw the original orange rectangle, but rotated around its top left corner, like this:

Here is the code:

from generativepy.drawing import make_image, setup
from generativepy.color import Color
from generativepy.geometry import Rectangle, Circle

def draw_rect_rotated(ctx, width, height, frame_no, frame_count):
    setup(ctx, width, height, width=5, background=Color(0.4))

    color = Color(1, 0.5, 0)
    ctx.translate(1, 1.5)
    Rectangle(ctx).of_corner_size((0, 0), 2.5, 2).fill(color)

make_image("rotated-rectangle-user.png", draw_rect_rotated, 500, 400)

We have added several Pycairo calls to this code:

  • saves the current drawing state. We are about to rotate the coordinate system, we save it here so we can restore it later on.
  • ctx.translate(1, 1.5) translates user space so that the origin is at the top left corner of the rectangle.
  • ctx.rotate(-0.5) rotates user space by -0.5 radians about the origin. A radian is about 57 degrees, so this is a rotation of almost 30 degrees in the counterclockwise direction. Since we have moved the origin to the top left corner of the rectangle, that is the centre of rotation.

We now draw the rectangle, but we set the corner to be (0, 0) because of the previous translation.

  • ctx.restore() sets user space back to where it was when save() was called.

Calling save and restore isn't strictly necessary in this case, because we don't draw anything except the rectangle. But if you were intending to draw more things that you don't want to be rotated, it is very useful to be able to reset things.

User space with the bitmap and nparray modules

The bitmap module uses the Python imaging library (PIL) to manipulate bitmap images, rather than using Pycairo to manipulate vector graphics.

PIL works exclusively in pixel coordinates and does not have any concept of a user space. However, generativepy provides a Scaler class that can perform similar calculations. Here is how it is used:

from generativepy.bitmap import Scaler

scaler = Scaler(300, 200, width=3, height=2, startx=-1.5, starty=-1)

print(scaler.user_to_device(1, .5))   # (250, 150)
print(scaler.device_to_user(20, 50))  # (-1.3, -.5)

The scaler is initialised with a pixel size of 300 by 200, and a user size of 3 by 2 (a scale factor of 100), with a user space offset of (-1.5, -1), like this:

The point (1, .5) in user space is converted to device space as ( (1+1.5)100, (0.5+1)100 ), or (250, 150).

The point (20, 50) in device space is converted to user space as ( 20/100 - 1.5, 50/100 -1 ) or (-1.3, -.5).

The scaler class can also be used with the nparray module.

If you found this article useful, you might be interested in the book Computer Graphics in Python or other books by the same author.


Popular tags

2d arrays abstract data type alignment and animation arc array arrays behavioural pattern bezier curve built-in function callable object chain circle classes close closure cmyk colour combinations comparison operator comprehension context context manager conversion count creational pattern data types design pattern device space dictionary drawing duck typing efficiency ellipse else encryption enumerate fill filter font font style for loop function function composition function plot functools game development generativepy tutorial generator geometry gif gradient greyscale higher order function hsl html image image processing imagesurface immutable object index inner function input installing iter iterable iterator itertools l system lambda function len line linear gradient linspace list list comprehension logical operator lru_cache magic method mandelbrot mandelbrot set map monad mutability named parameter numeric python numpy object open operator optional parameter or partial application path pattern permutations polygon positional parameter print pure function python standard library radial gradient range recipes rectangle recursion reduce repeat rgb rotation scaling sector segment sequence setup shape singleton slice slicing sound spirograph sprite square str stream string stroke structural pattern subpath symmetric encryption template text text metrics tinkerbell fractal transform translation transparency triangle tuple turtle unpacking user space vectorisation webserver website while loop zip