Images and Heatmaps

imshow and pcolormesh - showing a 2-D array, and the origin that catches everyone.

Overview

imshow

imshow displays a 2-D array as a grid of coloured cells. It is the foundation for heatmaps, confusion matrices, correlation tables and image display. Any data that is naturally a matrix -- rows and columns with a value in each cell -- can be shown this way.

Heatmaps are one of the most effective ways to show a dense table. A correlation matrix with 20 features has 190 distinct values. Printing them in a table is unreadable. Colouring them makes the pattern visible. Strong positive correlations stand out in one colour, strong negative in another, and near-zero values fade into the background.

The main trap with imshow is that its defaults are tuned for photographs, not for data. The origin is top-left, the aspect ratio is fixed, and the interpolation smooths between cells. Each of these is wrong for a numeric matrix. This page shows the three settings you need to change and explains why each matters.

A colour bar is almost always needed alongside a heatmap. It maps the colour range back to numbers. Without it, the reader sees warm and cool colours but cannot tell what value "warm" represents. fig.colorbar(im) adds one. Placing it well, sizing it to match the axes, and labelling it with units are all covered here.

This page covers imshow for data and images, pcolormesh for non-uniform grids, cell labelling, colour bar placement, custom colour ranges, masked values and the practical question of when to use a heatmap versus a table.

ax.imshow(z) draws a 2-D array as a grid of coloured cells.

Its defaults are chosen for images, and three of them are wrong for data.

Origin. z[0, 0] is drawn at the top-left. That is where the first pixel of an image goes. For a mat whose rows are a quantity. A y axis. That puts the smallest value at the top, upside down. origin="lower" fixes it.

Aspect. The default "equal" makes each cell square. A 6×30 array is drawn five times wider than tall no matter what figure size you asked for. aspect="auto" stretches it to fill the axes.

blending. The default smooths between cells. On a photo that is wanted. On a mat of values it draws colours that match to no data point. This Is a small lie. blending="nearest" gives hard cell edges.

For data, imshow(z, origin="lower", aspect="auto", blending="nearest") is the honest starting point.

Worth knowing

imshow puts row 0 at the top; origin="lower" is what you want for a matrix of values.
aspect="equal" forces square cells — use aspect="auto" to fill the axes, and interpolation="nearest" so it does not invent intermediate values.
For a small matrix, write the numbers in the cells and pick the text colour by threshold so it reads on both ends of the colormap.
pcolormesh takes cell edges, so it handles uneven grids; edges outnumber cells by one per axis.
vmin/vmax force a shared colour scale — without it two heatmaps are not comparable, the same problem as unshared axes.
A diverging colormap needs symmetric limits, or its neutral midpoint lands somewhere arbitrary instead of at zero.

Images and Heatmaps

imshow and pcolormesh, and the origin that catches all.

imshow draws an array as a grid

Row 0 is at the TOP, because it is drawing an image.

example_01.pymatplotlib
Output

Aspect and interpolation

Two defaults that suit photographs and not data.

example_02.pymatplotlib
Output

Labelling the cells

A heatmap of a small matrix is a table, and should read like one.

example_03.pymatplotlib
Output

pcolormesh for uneven grids

When the cells are not all the same size.

example_04.pymatplotlib
Output

Setting the colour range

vmin and vmax, and why two heatmaps otherwise lie.

example_05.pymatplotlib
Output

Diverging data needs a centred scale

Or zero ends up somewhere arbitrary in the colormap.

example_06.pymatplotlib
Output

Labelling cells

A heatmap of a small mat is a table that has been coloured. It should read like one.

Set the ticks to the row and column names. Write the values into the cells:

for i in range(rows):
    for j in range(cols):
        ax.text(j, i, z[i, j], ha="center", va="center")

Note the index order: ax.text(j, i, ...). The first arg is x. This Is the column. Getting this backwards is the usual bug. On a square mat it makes a transposed result that looks plausible.

Choosing the text colour by threshold. White on dark cells, black on light. Keeps each label readable. Without it, half the numbers vanish into the background at one end of the colormap.

pcolormesh

imshow assumes a regular grid and skips any coords you've.

pcolormesh(x_edges, y_edges, z) takes the edges of the cells. They can be unevenly spaced. Logarithmic bins, irregular time intervals, a non-uniform mesh.

The edges outnumber the cells by one in each direction, the same rule as histogram bins. Passing centres instead of edges is the common mistake. Matplotlib will accept same-sized arrays and shift all by half a cell.

pcolormesh is slower than imshow and much more flexible. For a regular grid, imshow is the right tool.

contourf is the third option, drawing filled contour bands rather than cells. Right when the underlying field is truly smooth and misleading when it isn't.

Shared colour scales

vmin and vmax set the values at the ends of the colormap.

Without them, each heatmap scales to its own data, just like unshared subplot axes. Two heatmaps drawn side by side then use the same colours for other values. The compare the layout invites is invalid.

The colorbar does say so, in numbers. Readers don't read them. They read the colours.

So: when two colour-mapped plots will be checked, fix vmin and vmax across both, or use one shared colorbar.

norm= gives finer control — LogNorm for data spanning orders of magnitude, BoundaryNorm for discrete bands.

Centring a diverging map

A diverging colormap has a neutral colour in the middle and two directions away from it. It only means anything if that neutral point sits at the value that matters, often zero.

With auto limits, the midpoint of the colormap lands at the midpoint of the data. On data ranging from −4 to +10, white sits at +3. Each cell below +3 is coloured as though it were minus.

The fix is even limits:

lim = np.abs(z).max()
ax.imshow(z, cmap="coolwarm", vmin=-lim, vmax=lim)

or TwoSlopeNorm(vcenter=0) when the two sides truly need other ranges.

This is one of the most common quiet errors in heatmaps. The result looks like a normal chart and asserts a thing false about the sign of half the data.

extent for real coordinates

imshow numbers the cells from zero by default. extent=(x0, x1, y0, y1) maps the array onto real coords:

ax.imshow(z, extent=(0, 100, 0, 50), origin="lower", aspect="auto")

The four values are the outer edges of the image, not cell centres. This Is the usual off-by-half confusion. With origin="lower", y0 is the bottom.

This is what lets a heatmap be overlaid with a line or a scatter in the same units. Without it any overlay is a half-cell out.

Masked and missing values

np.nan in the array is drawn as the colormap's "bad" colour. This Defaults to fully transparent. So missing cells show what is behind them, often the axes background.

cmap.set_bad("0.9") makes them an clear grey. This Is better than transparent because a missing cell then reads as missing rather than as background.

A masked array behaves the same way. Is the more clear form when the mask is found apart from the data.

Aspect and reading order

For a link mat or any square array, aspect="equal" is right and is the default.

For a wide array — time along one axis, a handful of groups along the other — aspect="auto" is needed, or the figure is drawn at the array's proportions and skips the size you asked for.

Row order deserves thought. An unordered heatmap makes the reader scan for structure. Sorting rows by their mean, or by a clustering, often reveals the pattern the chart is meant to show. That is a data choice made in the plotting code. It should be stated in the caption if the order isn't alphabetical.

Contours

ax.contour(z) draws lines of constant value. ax.contourf(z) fills between them.

They suit truly smooth fields. Elevation, temp, a fitted surface — and imply smoothness. They're wrong for a mat of split readings where pcolormesh is honest.

levels= sets the number or the exact values. ax.clabel(cs, inline=True) writes the values on the lines. This Often drops the need for a colorbar.

Mixing a filled contour with thin contour lines on top is a standard treatment for a field where both the bands and the exact levels matter.

Annotating a heatmap

For a small mat, writing values into the cells makes it a table with colour as a visual aid rather than the only encoding.

The threshold trick for text colour is worth restating because it's the gap between a readable and an unreadable heatmap:

color = "white" if z[i, j] > z.max() / 2 else "black"

For a diverging map centred on zero, the threshold should be based on distance from the centre rather than from the max, or the labels invert on the wrong half.

Reading a heatmap

Heatmaps are read less accurately than people assume. Colour intensity is a weak encoding.

They're good at showing patterns: blocks, grads, an outlier cell, structure along a main-line.

They're poor at values: no one reads 0.62 off a colour.

So the rule is that a heatmap should either be about the pattern, or it should have the numbers written in the cells. At which point it's a table with colour as a guide. This Is a truly good display for a small mat.

For a large mat, the pattern is the only thing on hand. Sorting the rows and columns. By cluster, by total, by a real sequence. Is what makes a pattern visible at all.

Common heatmap mistakes

Wrong origin — row 0 at the top for data whose y axis is a quantity.

blending on — smoothing between cells that are split readings.

Unshared scales across panels that will be checked.

An uncentred diverging map, putting the neutral colour at an any value.

No colorbar, leaving the colours meaningless.

Text the same colour all over. So Half the labels disappear into the background.

Unordered rows, hiding what structure exists.

All seven are defaults or omissions rather than errors of math. This Is why a heatmap can look professional and send nothing.

Correlation matrices

The most common heatmap and it has its own rules.

Centre the colormap on zero with even limits. A link of −0.4 and +0.4 should be equally strong in opposite directions. Without it, a mat of mostly plus links puts the neutral colour one spot any and the few negatives look far more extreme than they're.

Use a diverging map for the same reason.

**Set vmin=-1, vmax=1**, so two mats are close and so the colour scale means the same thing in each such chart.

Mask the upper triangle, since the mat is even and half of it's repetition. np.triu with np.nan and a set_bad colour does it.

Write the numbers in, if the mat is small enough. The exact values are often what the reader wants.

Those five turn the default output into a thing readable. The first three are the ones that affect whether it's accurate.

Images and Heatmaps, in summary

imshow has image defaults: row 0 at the top, square cells. Blending between them. For data, origin="lower", aspect="auto" and blending="nearest" are the honest settings.

pcolormesh takes cell edges and handles uneven grids. Edges outnumber cells by one per axis.

vmin/vmax are what make two heatmaps close. Their lack is the same error as unshared subplot axes.

A diverging colormap needs even limits or its neutral point lands at the midpoint of the data rather than at zero.

A colorbar isn't optional, and a small mat is better as a table with the numbers written in.

And row order is a choice: sorting by a real quantity is often what makes a pattern visible at all.

Overlaying on an image

A heatmap or image often needs a thing drawn on top. Contours, a scatter, a boundary.

The key is that imshow sets up a coord system. All overlaid must use the same one.

With default settings the cells are numbered from zero and a scatter must be in those units. With extent=, both use real coords and the overlay is straightforward.

Two details catch people. extent gives the outer edges. A cell centre is half a cell in from the boundary. A scatter of cell centres plotted against edge coords is offset by half a cell. This Looks like a small registration error.

And origin="lower" must be steady between the image and any y coords found for the overlay, or the overlay is flipped scaled to the image while both look on their own correct.

Drawing a few known points on top is the quickest way to confirm the coord system is what you think.

One more thing

ax.matshow is imshow with the defaults already set for a mat. Origin at the top with the tick labels along the top edge. This Is the rule for displaying a mat in math form.

It's convenient for a link mat and confusing for anything with a quantitative y axis. Here imshow(origin="lower") remains the right call.

Images and Heatmaps: the short version

A heatmap is read for its pattern, not its values. This Is why sorting the rows and columns matters as much as the colormap.

The defaults are built for photos. Three of them. Origin, aspect and blending. Are wrong for data. Setting all three by name is the honest starting point.

Questions people ask about Images and Heatmaps

What is the difference between imshow and pcolormesh? imshow shows a regular grid of pixels. pcolormesh handles uneven cell edges and non-uniform coordinates.

Why is my image upside down? imshow puts row 0 at the top by default. Use origin='lower' if your data has y increasing upward.

How do I label heatmap cells? Loop over the cells and call ax.text with the value, choosing white or black text based on the cell colour.

How do I set the colour range? Use vmin and vmax. Use the same values on every panel when comparing.

How do I map the pixels to real coordinates? Pass extent=(left, right, bottom, top) to imshow.

Why are my heatmap cells blurry? Interpolation smooths them. Pass interpolation='nearest' to see the cells as they are.

Reading the code back

A heatmap is one call and four corrections to its defaults. Origin, aspect, blending and the colour limits. Add a colorbar with a label and, for a small mat, the values in the cells. That is six lines. It's the gap between a picture of colours and a readable display of a mat.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Why does `imshow` put row 0 at the top?

  2. Why use `interpolation='nearest'` for data?

  3. What does `pcolormesh` take that `imshow` does not?

  4. Data ranges from -4 to +10 with a diverging colormap and automatic limits. Where is the neutral colour?

Cheat sheet

Images and Heatmaps

Display images and heatmaps with imshow and pcolormesh in matplotlib. Fix the upside-down origin and choose a colormap that reads correctly.

MATPLOTLIB · vizlearn.in/matplotlib/images_and_heatmaps.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.