Range Normalisation

Programming at its—grossly simplified—core, is nothing more than a transform of the input data into desired output data. Obviously in non-trivial programs this becomes more complicated, but this simple definition is enough for what I want to write about: range rormalisation (or just normalisation in general).

In my opinion normalisation, or any kind of mapping from one set of data to another, is invaluable for programmers. There are plenty of programs that require specific mappings e.g.: tile coordinates to pixel coordinates, sRGB to HSV or data from some sensor to a point on a graph. Without diving too deep into related math topics, I want to show some practical applications of normalisation. Note that this small write-up is aimed at people who are just beginning their programming journey or people who just want a quick refresher on normalisation (or maybe steal some code?).

Normalisation as Math

For the purposes of this write-up, whenever you see me write something like: 'normalise range' I mean: 'transform real number interval \([a;b]\) into \([0;1]\)', since \([0;1]\) is very easy to operate on (\(0\) and an identity \(1\)). It's not that hard to come up with a naive-but-working algorithm for such transformation... seriously, try it yourself before reading further, all you need is subtraction and division.

We can start by subtracting \(a\) from every number in \([a;b]\) which will give us: \[[a;b] \rightarrow [0;b-a]\]

After that we can divide by \((b-a)\) leaving us with: \[[a;b] \rightarrow [0;b-a] \rightarrow [0;1]\]

This is the mathematical heart of range normalisation (specifically 'min-max feature scaling'), it may not seem as much, but we can do pretty much anything* with the interval \([0;1]\). Using just a simple multiplication we can extend it or shrink it and with addition/subtraction we can also translate it. What's the catch then? Well, it doesn't change the shape of your distribution, that means you have to be careful or use other types of normalisation if you want your 'normalised' data to follow a specific distribution/curve. Other normalisation techniques/types are beyond the scope of this simple write-up, but you can always read more about them on your own.

General formula for what the above algorithm describes (given data set \(X\) and a point in that data set \(x\)) is the following: \[x' = \frac{x - X_{min}}{X_{max} - X_{min}}\]

Despite it's few drawbacks (that are really more of a problem when you're dealing with something more serious, like data science or machine learning), this type of normalisation is immensely useful. Let’s say we want to oscillate some level of brightness \(b\) over time \(t\), but we also want that brightness to be within \([0.5;1]\) range. Knowing what we know now we can easily tackle such a task. Because we want to oscillate some value of brightness over time, let's start with an oscillating sine function: \[b = \sin{(t)}\]

That's fine but sine oscillates between \([-1;1]\), well, we can use the formula above (with \(x' = b\) and \(x = \sin{(t)}\)) to make it oscillate between \([0;1]\) and then operate on that interval instead: \[b = \frac{\sin{(t)} + 1}{2}\]

Only one thing remains, to change \([0;1]\) to \([0.5;1]\) we need to, first, divide by \(2\) and then add \(0.5\), after that we're done: \[b = \frac{\sin{(t)} + 1}{4} + 0.5\]

Hovering over this little square → helps visualise previously described behaviour a little better. You can imagine something similar happening in video games when you're dealing with any kind of 'breathing' effect. Side note, to achieve this effect I, again, had to map \([0.5;1]\) range to \([127;255]\) because of RGB colors, of course, this was just a simple multiplication, but I hope it solidifies the idea/claim that these range transformations/normalisations show up in all sorts of places in programming.

Practicality

You may be wondering how practical all of this (travelling through \([0;1]\), mapping it to something else, etc.) actually is. Very! Personally, I use it pretty much ~all the time since it pops up in tons of places (as stated before); here is but one concise example.

Plenty of indie/just-starting-out developers begin by making a simple 2D game, typically using some kind of tiles (2D sprites) to block out their levels. Let's say your tile is 30x30 pixels and your whole map is 10x10 tiles, it would be somewhat inconvenient to always have to work out where a tile at column X row Y is located in pixel coordinates, or what pixel coordinates are a part of which tile.

// Get tile at column 2 row 1
vec2f tile_to_pixels = { 60.0f, 30.0f }; 

// Get tile at { 60, 30 } in pixel coordinates
// each tile is 30x30 pixels
vec2i pixels_to_tile = { 60/30, 30/30 };

It would be much better to just say 'give me a tile at column X row Y' and let the engine/function figure out where that is in pixel coordinates. This is a really good opportunity to implement some kind of mapping.

static vec2f
tile_to_pixel_coordinates(vec2i tile)
{
  vec2f result = { 30.0f*tile.x, 30.0f*tile.y };
  return(result);
}

static vec2i
tile_from_pixel_coordinates(vec2f coordinates)
{
  vec2i result = { (int)coordinates.X/30, (int)coordinates.Y/30 };
  return(result);
}

This is a very simple example and it's here for educational purposes, in my experience pretty much everyone understands why we pulled this into a function, but not many people explicitly state/realise that 'ah, it is a mapping/a kind of normalisation'. It's one of those things you use, but never really take the time to think deeper on.

There is a nice general-purpose function that I (and many other libraries) typically use to map one range to another:

static float
range_map(float v, float old_min, float old_max, float new_min, float new_max)
{
  // First, the value (v) is mapped from [old_min;old_max] -> [0;1]
  // as described previously...
  float zero_to_one = (v - old_min)/(old_max - old_min);
  
  // ...then it's just a matter of simple multiplication and addition:
  // [0;1] -> [0;new_max - new_min] -> [new_min;new_max]
  float result = (zero_to_one*(new_max - new_min)) + new_min;
  
  return(result);
}

If you were to use this and exploit the fact that your map is 10x10 tiles, you can get the same kind of mappings as above, albeit slightly more involved in terms of instructions executed.

I hope all of this helped shed some light on mappings and how you can exploit, spot and take advantage of them. All of it boils down to the fact that programming is just manipulating data (gross oversimplification) and if you understand how you can transform that data to your desired shape, you're able to work with it more efficiently.