0 Tiling the background of a widget with an image, pixel perfect
Richard Larkin edited this page 2017-05-06 19:10:30 +02:00

I'm working on a layout system, and I wanted to be able to lay out pixel accurate objects on my layout surface. I want to draw a grid on my layout surface so that the user can see where the alignment points are. I'm doing this in Kivy, so the objects are Kivy widgets and the surface is a Kivy widget. I don't want to draw lots of lines, so instead I thought I'd draw a tilable image and just tile the background with the image, to draw the alignment grid. How to do this?

Took me longer than I'd like to figure this out, so I thought I'd write it down for others to follow.

First of all, draw the image you want to use. I drew a 16x16 pixel square tile with a one-pixel wide stripe all around the edge, the stripe being white and the rest of the tile being transparent.

Now you need to draw this onto your widget's background, specifying that you want to 'repeat' the image instead of anchoring it or stretching it:

from kivy.core.image import Image as CoreImage
from kivy.graphics import Color, Rectangle
...
def draw_background(widget, *args):
    widget.canvas.before.clear()
    with widget.canvas.before:
        Color(.4, .4, .4, 1)
        texture = CoreImage("tile.png").texture
        texture.wrap = 'repeat'
        Rectangle(pos=widget.pos, size=widget.size, texture=texture)

Your results may vary, but mine looked awful. The image wasn't repeated, it was stretched across the whole widget. So, texture has uvpos and uvsize attributes; could they help? Yes, but it's not really the right way to do it. I took up my copy of the excellent book "Kivy Blueprints", and read chapter 7, which explains a lot.

What you want to do is to use the tex_coords attribute of the Rectangle. They define a coordinate space for the rectangle in OpenGL space. By default, they map the X axis from 0 to 1 (starting at the left) and the Y axis from 0 to 1 (starting at the bottom). In this coordinate space, every pixel of the Rectangle has a location (u, v) in which both u and v are values between 0 and 1. When you apply a texture to the Rectangle, the texture coordinate system is stretched to match the Rectangle's coordinate system. Since both have a 0-1 space on each axis, the texture is stretched to fill the entire Rectangle.

However, if each unit square of each has the same number of pixels horizontally and vertically, you get a pixel-perfect overlay (or background). You can change either of the coordinate spaces to match, but changing the Rectangle's will only affect this Rectangle, whereas changing the texture's will affect all instances of the texture.

So, what we need to do to get perfect tiling is to re-define the Rectangle's coordinate space so that a unit square in it has exactly the number of pixels as our tile, so that they match pixel-for-pixel. We use the tex_coords attribute of the Rectangle, which is a list of 8 floating-point values: left X, bottom Y, right X, bottom Y, right X, top Y, left X, top Y. (There are eight instead of just four, because the Rectangle is really a quadrilateral, which is defined by four not-necessarily-rectangular points, but we don't really have to worry about that.)

To define a new unit square, we just change the values of these eight numbers. We want each unit square to be the same size in pixels as our image, so the calculation is straight-forward:

  x_size = widget.width / texture.width
  y_size = widget.height / texture.height

And we set the Rectangle's coordinates thusly:

from kivy.core.image import Image as CoreImage
from kivy.graphics import Color, Rectangle
...
def draw_background(widget, *args):
    widget.canvas.before.clear()
    with widget.canvas.before:
        Color(.4, .4, .4, 1)
        texture = CoreImage("tile.png").texture
        texture.wrap = 'repeat'
        nx = widget.width / texture.width
        ny = widget.height / texture.height
        Rectangle(pos=widget.pos, size=widget.size, texture=texture,
                  tex_coords=(0, 0, nx, 0, nx, ny, 0, ny))

Looking much better, but using a ruler from the Art Director's Toolkit (highly recommended Mac app), I found that it's still a bit off. The culprit was integer truncation -- the size of the tile did not divide evenly into the height of the widget -- so we actually needed to write it like this:

from kivy.core.image import Image as CoreImage
from kivy.graphics import Color, Rectangle
...
def draw_background(widget, *args):
    widget.canvas.before.clear()
    with widget.canvas.before:
        Color(.4, .4, .4, 1)
        texture = CoreImage("tile.png").texture
        texture.wrap = 'repeat'
        nx = float(widget.width) / texture.width
        ny = float(widget.height) / texture.height
        Rectangle(pos=widget.pos, size=widget.size, texture=texture,
                  tex_coords=(0, 0, nx, 0, nx, ny, 0, ny))

And, hey presto!, a perfect grid background.