Sepia algorithm

Recently, I wanted to produce sepia images digitally that would ressemble the prints we toned in the lab in the days of film.

I didn't find good options at first, nor good algorithms. So with a bit of experimentation, I came up with the algorithm described below.

For this blog, I'll use the following image, a pre-desaturated version as input to the sepia algos:

Searching the web, I found the following algorithm which, apparently, comes from Microsoft :

   newRed =   0.393*R + 0.769*G + 0.189*B
   newGreen = 0.349*R + 0.686*G + 0.168*B
   newBlue =  0.272*R + 0.534*G + 0.131*B
I found this transform many times on the web. It effectively does both a desaturation and a sepia toning.

I verified that GIMP 2.10 uses this same algorithm in Colors -> Desaturate -> Sepia... (with sRGB checked). Here's the result along with a standard (luminance-based) desaturation of the sepia. This gives following:

   

To my liking, this is too yellow. But the bigger problem is that if you desaturate again, you get a result that is significantly brighter than the original desaturation, meaning that this sepia toning affects luminance.

I think that a sepia toning should preserve luminance, or perhaps one of the other measures in Colors -> Desaturate -> Desaturate.... A good test is to repeatedly do the sepia transform, the image should stay essentially the same.

Then I tried a little experiment in GIMP, Colors -> Curves:

This suggested that three parallel straight curves with tapering off at the ends seem to do the trick. So I wrote an algorithm with two parameters: the distance between the red and green lines, and between the green and blue lines. Varying these two parms (within reasonable limits) allows one to vary the sepia tint. The assumption is that the red curve is highest (on the "curves" graph) and blue curve lowest, so as to get a sepia tint. The green, being the main component of luminance, is in the center, between red and blue.

   rg = 19  # distance between red and green lines -- my original default values (I've evolved somewhat since)
   gb = 27  # distance between green and blue lines
   # overall, a distance of about 50 between red and blue seems about right
We now calculate by how much the red line is above the desaturated value (luminance) by requiring that luminance be preserved. This gives the following equations:
   # ar is adjustment for red that we want to compute; l is an arbritrary luminance
   # the following equation expresses the wish that luminance be invariant
   # ie. luminance of sepia is same as luminance of original
   (l + ar) * .30  +  (l + ar - rg) * .59  +  (l + ar - rg - gb) * .11  ==  l
   # remove 'l'
   ar * .30  +  (ar - rg) * .59  +  (ar - rg - gb) * .11  ==  0
   # regroup things
   ar - rg * 0.70 - gb * 0.11 == 0
   # isolate ar
   ar = rg * 0.70 + gb * 0.11
   # adjustments for green and blue are now simply
   ag = ar - rg
   ab = ar - rg - gb
and the conversion becomes simply:
   l = (30*r + 59*g + 11*b)   # compute luminance of pixel
   r = l + ar                 # new sepia values of RGB
   g = l + ag
   b = l + ab
The tapering off at the ends is left as an exercise for the reader :-). This gives the resulting image, and the following re-desaturation (very close to original):

   

I think this is better than GIMP's algorithm. After this work, I discovered that Preview (MacOS) does quite a good job.

   


Analysing example from the web

Recently, I came back to this small project, with some new tools to analyse images.

Here's an image from the web which I feel has a nice sepia toning : image (lonely cattle).

Here's a profile of the RGB values on column at x = 2222 on the blurred original image (the vertical green arrow on above). On the left of the profile (top of green arrow), we see the sky, the right half would look like noise without some blurring (here 11 pixels). And we see the same parallel lines for the three colors as I have above.

The distance (in the sky on the left) between the red and the green lines is about 21, and between green and blue lines, about 31. Note how the RGB curves come closer together in the blacks, showing a tapering off as discussed above. If I redo the sepia processing (using the algorithm below) using those numbers on the original image, I get essentially the same image back. This sepia has a greenish tint.


Python sepia plug-in for GIMP 2.10

Here's an implementation of the above ideas in a python plug-in for GIMP 2.10. I had some difficulties getting GIMP's python plug-ins to work, but that's another thread. The following code would be stored for example (on MacOS) in ~/Library/Application Support/Gimp/2.10/plug-ins/sepia-lew.py. Note also the first line, which is part of how I got it working. Don't forget to make the file executable.

#!/Users/lew/Applications/GIMP-2.10.app/Contents/MacOS/python

from gimpfu import *

# converts spline curve to 256 points array; for now, still with sharp angles, but that's not really visible (it's in the differential
def splineToPoints(spline):
    points = [ k/255.0 for k in range(256) ]
    x0 = 0.0
    y0 = 0.0
    ix = 0
    x0 = spline.pop(0) # remove initial (0.0,0.0)
    y0 = spline.pop(0)
    while spline:
       x = spline.pop(0)
       y = spline.pop(0)
       while ix < 256:
          xi = ix / 255.
          if xi > x:
             break
          points[ix] = ((x-xi) * y0 + (xi-x0) * y) / (x-x0)
          ix += 1
       x0 = x
       y0 = y
    return points

def do_sepia(img, layer, desat, red_green, green_blue) :

    gimp.progress_init("Converting " + layer.name + " to sepia...")

    red_green /= 255.    # comvert parms to range 0..1
    green_blue /= 255.

    # compute the desired adjustments to preserve luminance
    ar = red_green * 0.70 + green_blue * 0.11   # expected to be +ve
    ag = ar - red_green                         # could be either
    ab = ar - red_green - green_blue            # expected to be -ve

    if red_green < 0.0 or green_blue < 0.0 or abs(ar) > 0.4 or abs(ag) > 0.4 or abs(ab) > 0.4 :
       pdb.gimp_message('Unexpected parms, results may surprise you!')
       # return  # TBD : find the proper way to handle this

    # Set up an undo group, so the operation will be undone in one step.
    pdb.gimp_undo_push_group_start(img)

    # pdb.gimp_message('ar = ' + str(round(ar,3)) + ', ag = ' + str(round(ag,3)) + ', ab = ' + str(round(ab,3)))

    if desat:
       pdb.gimp_drawable_desaturate(layer, DESATURATE_LUMINANCE)   # in case not previously done

    # versions 1 and 2 of this program used pdb.gimp_drawable_curves_spline(), but pdb.gimp_drawable_curves_explicit() gives me fewer artefacts
    # 0.93 is a factor that was useful with pdb.gimp_drawable_curves_spline() to counter a slight buldging

    # red curve moves up
    pdb.gimp_drawable_curves_explicit(layer,    HISTOGRAM_RED,   256, splineToPoints([0.0, 0.0,   0.03-0.93*ab, 0.03-0.93*ab+0.93*ar,   0.5-ar/2, 0.5+ar/2,   0.97-0.93*ar, 0.97,                   1.0, 1.0]))

    # blue curve moves down
    pdb.gimp_drawable_curves_explicit(layer,    HISTOGRAM_BLUE,  256, splineToPoints([0.0, 0.0,   0.03-0.93*ab, 0.03,                   0.5-ab/2, 0.5+ab/2,   0.97-0.93*ar, 0.97-0.93*ar+0.93*ab,   1.0, 1.0]))

    if abs(ag) > 0.003 :   # don't bother if green moves less than 1
       pdb.gimp_drawable_curves_explicit(layer, HISTOGRAM_GREEN, 256, splineToPoints([0.0, 0.0,   0.03-0.93*ab, 0.03-0.93*ab+0.93*ag,   0.5-ag/2, 0.5+ag/2,   0.97-0.93*ar, 0.97-0.93*ar+0.93*ag,   1.0, 1.0]))

    # https://stackoverflow.com/questions/58772647/adjust-color-curves-in-python-similar-to-gimp

    pdb.gimp_displays_flush() #this will update the image.

    # Close the undo group.
    pdb.gimp_undo_push_group_end(img)

register(
    "python_fu_do_sepia",
    "Tone sepia (lew)",
    "Converts an image to sepia (https://leware.net/photo/blogSepia.html)",
    "Pierre Lewis",
    "Pierre Lewis",
    "2020",
    "Sepia(lew)...",
    "*",      # Alternately use RGB, RGB*, GRAY*, INDEXED etc.
    [
        (PF_IMAGE, "image", "Input Image", None),
        (PF_DRAWABLE, "drawable", "Input Layer", None),
        (PF_BOOL, "desat", "Desaturate", True),
        (PF_INT, "red_green", "Red-green", 23),
        (PF_INT, "green_blue", "Green-Blue", 19)
    ],
    [],
    do_sepia, menu="/Colors/Desaturate")

main()

The GIMP plug-in has an extra parameter, whether to do a desaturation first (defaults to yes). Not needed if the image is already desaturated. With an image that has some color, it can lead to interesting effects with option set to "no".

The plug-in appears under Color -> Desaturate as "Sepia(lew)...". But it's easy to move it elsewhere.

Here's the profile of the mapping of grey scale values (x axis) to RGB values (y axis) of the above algorithm. This was obtained by looking at any row of the a gradient image here passed thru above algo.

With the current default parameters (23, 19), the toning will have a red tint, my current preference.

For comparison, here's the profile for GIMP's implementation (Microsoft's) of sepia. Very different. This was with sRGB checked. Without this option, the resulting curves are very similar, only closer to the center diagonal.

And here is the profile of Preview's implementation of sepia. Somewhat similar to what I have.


P.S. Here's the original in color... (note that I used something else than luminance to do the initial desaturation in this case -- the reds were too dark otherwise):

P.P.S. I might have confused some terms, eg. "luminosity", "luminance". In the above discussion, "luminance" means "lum = (30*r + 59*g + 11*b)" and I think that matches GIMP's "Colors -> Desaturate -> Desaturate... -> Luminance".

Home. Email:  (fran├žais, English, Deutsch).