Changing screen brightness in accordance with human perception

Since the kernel update to version 3.16 in Arch Linux the function keys for changing screen brightness in my laptop have ceased to work (at least in Openbox). While I was struggling with the problem, I discovered that xbacklight works well and gives a nice smooth transition effect between brightness levels, so I decided to use it instead. But it did not work exactly in a way I expected it to.

I added proper configuration to Openbox’ rc.xml:

<keybind key="XF86MonBrightnessUp">
  <action name="Execute">
    <command>xbacklight -inc 5</command>
  </action>
</keybind>
<keybind key="XF86MonBrightnessDown">
  <action name="Execute">
    <command>xbacklight -dec 5</command>
  </action>
</keybind>

This solution changes brightness as expected (from 0 to 100% of /sys/class/backlight/intel_backlight/max_brightness), but not in a human-friendly way. If you play with this for a while you’ll notice that brightness changes almost unnoticeably when the screen is very bright and more and more rapidly when the screen gets darker. Furthermore, in the end, you get to zero brightness: the backlight is turned off and you don’t see anything anymore. You can of course increase brightness back at that point, but having to do this is quite annoying.

The Weber-Fechner law

As it turns out, human perception isn’t linear. According to the Weber-Fechner law perceived brightness is more or less proportional to the common logarithm (logarithm with base 10). The same principle applies also to the perception of sound.

And here’s how it looks in our case:

Logarithm with base 10

In other words, if we increase illuminance of a screen by 5 percentage points the perceived brightness change will be bigger when the screen is dark and smaller when the screen is bright. For example, a change from 5% to 10% of maximum illuminance will be perceived as if the brightness increased by 15 percentage points with respect to the maximum brightness. However, a change from 90% to 95% will be perceived as if the brightness changed by about 1 percentage point.

To be sure that illuminance change is in my case linear, I used a Light Meter app and measured the illuminance using the sensor in my phone (in not-very-scientific conditions, from a small distance):

Illuminance of the screen

And yes, it is linear, exactly as expected.

The solution

Let’s say we would like to divide our range of brightness evenly based on perceived brightness, not illuminance. The following Python script does exactly that, and a bit more:

#!/usr/bin/env python3

from math import log10

import sys
import subprocess


def get_backlight():
    return float(subprocess.check_output(["xbacklight",  "-get"]))

def set_backlight(backlight):
    subprocess.call(["xbacklight", "-set", str(backlight)])

def backlight_to_step(backlight, backlight_min, backlight_max, steps):
    x_min = log10(backlight_min)
    x_max = log10(backlight_max)
    return round(log10(backlight) / (x_max - x_min) * steps)

def step_to_backlight(step, backlight_min, backlight_max, steps):
    x_min = log10(backlight_min)
    x_max = log10(backlight_max)
    x = step / steps * (x_max - x_min)

    backlight = round(max(min(10 ** x, backlight_max), backlight_min))
    return backlight


if __name__ == "__main__":

    backlight_min = 2
    backlight_max = 100

    steps = 20

    if len(sys.argv) < 2 or sys.argv[1] not in ["-inc", "-dec"]:
        print("usage:\n\t{0} -inc / -dec".format(sys.argv[0]))
        sys.exit(0)

    current_backlight = get_backlight()
    current_step = backlight_to_step(current_backlight, backlight_min, backlight_max, steps)

    action = sys.argv[1]
    if action == "-inc":
        new_step = current_step + 1
    elif action == "-dec":
        new_step = current_step - 1

    new_backlight = step_to_backlight(new_step, backlight_min, backlight_max, steps)

    print("Current backlight: {0}\nChanging to: {1}".format(current_backlight, new_backlight))
    set_backlight(new_backlight)

The configuration is based on three variables:

  • backlight_min - the minimum brightness (in percent); too low value may make it stuck because the increment will be too small for xbacklight
  • backlight_max - the maximum brightness (in percent)
  • steps - the number of steps from minimum to maximum brightness

Having these variables hardcoded does not seem like a very good idea at first, but then how often can I change my mind about e.g. the number of steps?

The script takes one argument: -inc to increase brightness by one step or -dec to decrease it. And the algorithm is simple:

  1. Retrieve the current backlight value by calling xbacklight -get.
  2. Use that value to calculate the current step value, rounded to the closest one (brightness might have been changed by some other application in the meantime).
  3. Increase or decrease the step and calculate new backlight.
  4. Set the new backlight by calling xbacklight -set {value}.

And that’s all. Here’s a gist if you want.

Using it in Openbox

If you’re an Openbox user, just place this script somewhere in your PATH (~/bin/change-backlight.py in my case) and add these lines to ~/.config/openbox/rc.xml:

<keybind key="XF86MonBrightnessUp">
  <action name="Execute">
    <command>~/bin/change-backlight.py -inc</command>
  </action>
</keybind>
<keybind key="XF86MonBrightnessDown">
  <action name="Execute">
    <command>~/bin/change-backlight.py -dec</command>
  </action>
</keybind>

Oh, and don’t forget to reconfigure Openbox: openbox --reconfigure should do the trick.