4

I want to create a sequence logo using matplotlib for my work.

Sequence logo is like this as shown in https://en.wikipedia.org/wiki/Sequence_logo. Each character has a specific height. And I'd like to make this using matplotlib.

How do I change the aspect ratio of a font? I've tried to use Affine2D from matplotlib.transform as following, but it didn't work.

ax.text(1,1,"A").set_transform(matplotlib.transforms.Affine2D().scale(0.5,1))

Is there an easy workaround?

4
  • You may do ax.text(1,1,"A", fontsize = 20)..... But this is without changing aspect. You want to width/height ratio to be non-default? Commented Jan 7, 2021 at 9:09
  • BTW, what's wrong with what you did using Affine2D? It doesn't work at all? Or too long to write such line of code? Or it works but not very precisely? Also you may pass transform object as parameter ax.text(1,1,"A", transform = .....Affine2D....) if .set_transform doesn't work Commented Jan 7, 2021 at 9:14
  • Sorry for the poor description of what I did. I couldn't upload an image because I was new to this service. And yes, I want to change the aspect ratio to meet the requirements to make a sequence logo. After watching this page, I thought by applying Affine2D().scale(0.5,1) would somehow work for text also, but the result was that the text was sticking to the lower-left corner of the image. Commented Jan 7, 2021 at 12:09
  • I implemented my own solution to your task see my answer, tell if something doesn't work there for you! Commented Jan 7, 2021 at 12:34

1 Answer 1

2

I've tried different ways of stretching text's width in Matplotlib but nothing worked out. Probably they didn't implement stretching correctly yet, I even saw in docs this notice like This feature is not implemented yet! for one of font-stretching function.

So I decided to write my own helper functions to achieve this task. It uses drawing functions of PIL module, you have to install one time next modules python -m pip install pillow numpy matplotlib.

Notice that my helper function text_draw_mpl(...) accepts x, y offset and width, height all expressed in your plot units, e.g. if you have x/y ranging from 0 to 1 on your plot then you have to use in function values like 0.1, 0.2, 0.3, 0.4.

My other helper function text_draw_np(...) is low level function, probably you'll never use it straight away, it uses width, height expressed in pixels and produces on output RGB array of shape (height, width, 3) (3 for RGB colors).

In my functions you may pass background color (bg argument) and foreground color (color argument) both as string color name (like 'magenta' or 'blue') and also as RGB tuple like (0, 255, 0) for green color. By default if not provided foreground color is black and background color is white.

Notice that my function supports argument remove_gaps, if it is True that empty space will be removed from all sides of drawn text picture, if it is False then empty space remains. Empty space is introduced by the way Glyphs are drawn inside Font file, e.g. small letter m has more space at the top, capital letter T less space at the top. Font has this space so that whole text has same height and also that two lines of text over each other have some gap between and don't merge.

Also notice that I provided path to default Windows Arial font c:/windows/fonts/arial.ttf, if you have Linux, or you want other font, just download any free Unicode TrueType (.ttf) font from internet (e.g. from here) and put that font nearby to your script and modify path in my code below. Also PIL module supports other formats, as stated in its docs Supported: TrueType and OpenType fonts (as well as other font formats supported by the FreeType library).

Try it online!

def text_draw_np(text, width, height, *, font = 'c:/windows/fonts/arial.ttf', bg = (255, 255, 255), color = (0, 0, 0), remove_gaps = False, cache = {}):
    import math, numpy as np, PIL.Image, PIL.ImageDraw, PIL.ImageFont, PIL.ImageColor
    def get_font(fname, size):
        key = ('font', fname, size)
        if key not in cache:
            cache[key] = PIL.ImageFont.truetype(fname, size = size, encoding = 'unic')
        return cache[key]
    width, height = math.ceil(width), math.ceil(height)
    pil_font = get_font(font, 24)
    text_width, text_height = pil_font.getsize(text)
    pil_font = get_font(font, math.ceil(1.2 * 24 * max(width / text_width, height / text_height)))
    text_width, text_height = pil_font.getsize(text)
    canvas = PIL.Image.new('RGB', (text_width, text_height), bg)
    draw = PIL.ImageDraw.Draw(canvas)
    draw.text((0, 0), text, font = pil_font, fill = color)
    if remove_gaps:
        a = np.asarray(canvas)
        bg_rgb = PIL.ImageColor.getrgb(bg)
        b = np.zeros_like(a)
        b[:, :, 0] = bg_rgb[0]; b[:, :, 1] = bg_rgb[1]; b[:, :, 2] = bg_rgb[2]
        t0 = np.any((a != b).reshape(a.shape[0], -1), axis = -1)
        top, bot = np.flatnonzero(t0)[0], np.flatnonzero(t0)[-1]
        t0 = np.any((a != b).transpose(1, 0, 2).reshape(a.shape[1], -1), axis = -1)
        lef, rig = np.flatnonzero(t0)[0], np.flatnonzero(t0)[-1]
        a = a[top : bot, lef : rig]
        canvas = PIL.Image.fromarray(a)
    canvas = canvas.resize((width, height), PIL.Image.LANCZOS)
    return np.asarray(canvas)
    
def text_draw_mpl(fig, ax, text, offset_x, offset_y, width, height, **nargs):
    axbb = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
    pxw, pxh = axbb.width * fig.dpi * width / (ax.get_xlim()[1] - ax.get_xlim()[0]), axbb.height * fig.dpi * height / (ax.get_ylim()[1] - ax.get_ylim()[0])
    ax.imshow(text_draw_np(text, pxw * 1.2, pxh * 1.2, **nargs), extent = (offset_x, offset_x + width, offset_y, offset_y + height), aspect = 'auto', interpolation = 'lanczos')

import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.set_ylim(0, 1000)
ax.set_xlim(0, 1000)
text_draw_mpl(fig, ax, 'Hello!', 100, 500, 150, 500, color = 'green', bg = 'magenta', remove_gaps = True)
text_draw_mpl(fig, ax, 'World!', 100, 200, 800, 100, color = 'blue', bg = 'yellow', remove_gaps = True)
text_draw_mpl(fig, ax, ' Gaps ', 400, 500, 500, 200, color = 'red', bg = 'gray', remove_gaps = False)
plt.show()

Output:

enter image description here

Sign up to request clarification or add additional context in comments.

6 Comments

Thank you for the running example and the investigation into the docs! I didn't notice that there is no function to stretch text in matplotlib (yeah, I know literally nobody use this function if there is so). The example worked for me, and it was ok! But I'm worried about the margin at the top of the text. Can this be adjusted? When creating a sequence logo, the margins between the top, bottom, left, and right characters need to be filled in, so it would be very helpful if that part could be changed.
@IwanoNatsuki This margin actually comes from Font Glyphs themselves, i.e. this gap at the top is included inside bounding box of drawn symbols inside font file. It means that different font files may or may not include this gap. You may try other fonts. I don't know in general what gap amount to remove for different kinds of fonts. Also it could be the case that some letters in Unicode alphabet have this gap and others don't inside same font file. E.g. if you have capital letter H it will have less gap and for small m gap will be more.
@IwanoNatsuki One way to remove this gap is to use numpy to search for background-equal rows and remove them. If you want I'll do this. But it means that in this case all capital letters will have same size as small letters if it is OK for you.
I understood. The latter workaround, to search the character border, looks fine for me. The sequence logo, particularly the consensus logo, like this and has only 4 characters; A, T, G, and C. Unlike "I", they look fine when those width and height are fixed to the same size. Is it easy to implement? It looks like a bit tough implementation...
@IwanoNatsuki I've updated my code to support both having and removing gaps, also attached picture in my answer with text having gaps and no gaps. Please put a look, if it is what you want!
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.