Pythonstuff GLSL in English Pythonstuff GLSL auf Deutsch Pythonstuff GLSL Pythonstuff
PythonStuff Home
 

 

The height2bump.py Program

This page explains my cross platform open source program height2bump.py that converts heightmaps into normalmaps.

Here is how to use it as a command line program (import as a module is also possible).

Here is the source again:

What is a heightmap ?

A heightmap is a greyscale image describing the local geometry of an object by giving its height (coded as brightness, 0=black=“low”, 255=white=“high”) for every texture pixel. It is applied to a much coarser polygon model to give the impression of surface details that are not really there in the model. Depending on your shader program, you can use the heightmap to

  • modify the lighting (usually by converting it to a normalmap first :-)) - see here: Bump Mapping
  • modify the texture position (Parallax Mapping)
  • modify the geometry of your model (moving vertices in a Vertex Offset Shader or creating vertices with a geometry shader)

What is a Normalmap ? What is a Bumpmap ?

As you can see, to show “Bumps” on a polygon surface (that in reality consists of flat facets) you have more than one way. If you want to modify the lighting of the surface according to your texture map, you usually need the surface normal.

The surface normal of the flat facet is constant over the facet (A). The first approximation would be a “smooth” look by interpolating the normals between the vertex normals (B). A normal map gives you the normal (relative to the interpolated normal) for every pixel in the texture (C).

Surface Normals

To code the normal (Vx, Vy, Vz with length = 1, direction in x, y, z) in an RGB-Image, we define:

R = (Vx + 1.0) * 127
G = (Vy + 1.0) * 127
B = (Vz + 1.0) * 127

Vx, Vy, Vz all vary between -1 and +1. By adding ”+1” we get the range 0..+2 and after the multiply we have 0..255 to fit it nicely into an 8-bit-Color-Channel.

  • We need the normal to point way from the surface, if the height does not change (x = 0, y = 0, z = 1 ⇒ color = 7F 7F FF)
       
  • The normal points left if the surface rises apruptly to the east (x = -1, y = 0, z = 0 ⇒ color = 00 7F 7F)
       
  • The normal points right if the surface falls apruptly to the east (x = 1, y = 0, z = 0 ⇒ color = FF 7F 7F)
       
  • The normal points up if the surface rises apruptly to the south (x = 0, y = 1, z = 0 ⇒ color = 7F FF 7F)
       
  • The normal points down if the surface falls apruptly to the south (x = 0, y = -1, z = 0 ⇒ color = 7F 00 7F)
       

Because most of the time the height does not change too much, the normalmaps in general have a light blue tint - see an example here.

The filter kernel

To calculate the normalmap, we need to

  • Filter fast local changes from the heightmap
  • find the x-Change in west-east direction (for the “R”-channel)
  • find the y-Change in north-south direction (for the “G”-channel)
  • Calculate the z-component because the length should be “1” (for the “B”-channel)

Filter

This means to take some kind of weighted sum of some pixels - called a “Gaussian Blur”.

x-Change and y-Change

This means calculating the difference in height between a pixel more “east” and a pixel more “west” for x, and “north” to “south” for y. This is called “partial differential”.

You can do this in one step - this is called an “edge detection filter”. I use two kinds:

  • Sobel Operator
  • Scharr filter

See Sobel Operator or the source file for details. The “Scharr”-Filter looks like the Sobel-Filter but has better properties for edges with arbitrary angles.

The nice thing is that PIL (the Python Image Library) has a high speed implementation for applying a filter to an image. You just have to define the filter kernel (the factors for the weighted sum) and off you go:

r = heightBand.filter(ImageFilter.Kernel((5,5), kernel[0], scale=scale, offset=128.0))
g = heightBand.filter(ImageFilter.Kernel((5,5), kernel[1], scale=scale, offset=128.0))

Calculate z

Because the normal's length should be 1, good old Pythagoras is used such that x^2 + y^2 + z^2 = 1.

I did not succeed to get PIL do this calculation for me (although it should be possible). So I have to loop over the whole image one pixel at the time - this is a slow operation in Python:

  for y in range( r.size[1] ):
      for x in range( r.size[0] ):
          op = 1.0 - (rr[x,y]*2.0/255.0 - 1.0)**2 - (gg[x,y]*2.0/255.0 - 1.0)**2
          bb[x,y] = 128.0 + 128.0 * sqrt(op)

(Handling of “negative square root” removed for clarity).

I worked around the performance problem by

  • using fast image access functions provided by PIL
  • only calculate the normalmap if the original heightmap changed (the heightmap file is newer than the normalmap file):
infile_stamp = os.path.getmtime(infn)
outfile_stamp = os.path.getmtime(outfn)
if infile_stamp < outfile_stamp:
    ....

Program structure

All the weight-lifting is done in the function

def height2bump( heightBand, filter="Scharr" ): # normal[0..2] band-array

Input is a PIL-“band” you can get from any PIL-Image by using Image.split(). The output are 3 Bands you can merge into a Multiband-Image for texturing or saving as an image file. If you create a 4-Band-Image (RGBA) you must use ”.tga” as image format.

Depending on the “filter” Option you get different filter kernels. Because of some symmetry, you only have to define 6 Numbers to define a 5×5-Filter.

To have fast access to the pixel data, the function r.load() creates the access methods to use the image just as any array (see the PIL Documentation for details).

I really wanted to use the ImageMath module of PIL to calculate the z Component of the Normalmap - but I did not succeed. So I loop over the array. If the filter calculates x- and y-values that are too large ( x*x + y*y > 1.0 ) an error in the sqrt() function would follow - so I have a special case in the nested loop (that is already slow).

The filter scaling factor is calculated dynamically - if you want to add your own custom filter, this is rather easy: Another “if” for the filtername and 6 float values for the filter kernel.

The function

readHeight2Bump( infn, outfn )

is the “high level”-Interface to the conversion routine: it looks at the file timestamps to decide if the calculation can be skipped entirly. With the option “v” you get a lot of information what it thinks it is doing :-)

The part after

if __name__ == "__main__":

does the command line option processing. If anything goes wrong, it shows a usage info and exits. This is skipped if the code is used as a module.

height2normal.py as a module

Simply import it and use the readHeight2Bump() function:

from height2bump import readHeight2Bump
result = readHeight2Bump( heightfilename, bumpfilename, options="tqa" )

result is 4-band PIL-Image containing x,y,z,h.

The filenames define the (monochrome) Heightmap and the Normalmap. If the Normalmap exists and is newer than the Heightmap, it is read and returned - this is faster than doing the calculations. If the Heightmap is newer, the Normalmap is calculated and written to the Normalmap directory. In this case write access to the Normalmap-File/Directory is needed!

If the Normalmap exists, the Heightmap does not need to exist.


Deutschsprachige Version, Start
Impressum & Disclaimer