⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eOn an older Zig?

Colour Matrices

Grayscale, sepia, saturation, hue rotation and channel swaps look like separate filters. They are one filter with different constants: a 3x3 matrix multiplying the channel vector.

[ R' ]   [ a b c ] [ R ]
[ G' ] = [ d e f ] [ G ]
[ B' ]   [ g h i ] [ B ]

The reason to write them this way is not elegance. It is that matrices multiply, so a chain of four colour filters becomes one matrix computed before the image is touched, and then one pass.

const std = @import("std");
const canvas = @import("_canvas.zig");

var pixels: [canvas.width * canvas.height]u32 = undefined;
var scratch: [canvas.width * canvas.height]u32 = undefined;

/// Row-major, every entry scaled by 256. Fixed point rather than floats
/// because the input and output are bytes: carrying `f32` through would add a
/// conversion at each end to buy precision that the final `@intCast` throws
/// away.
const Matrix = [9]i32;

const scale = 256;

const identity: Matrix = .{
    scale, 0,     0,
    0,     scale, 0,
    0,     0,     scale,
};

/// Rec. 601 luma in all three rows: every output channel becomes the same
/// weighted average, which is grayscale. The weights are the ones
/// `canvas.luma` uses, and they sum to exactly 256.
const luma_matrix: Matrix = .{
    77, 150, 29,
    77, 150, 29,
    77, 150, 29,
};

/// The classic sepia matrix. Its rows sum to 346, 308 and 241 rather than 256,
/// which is not a mistake: sepia is meant to lift the image as well as tint it,
/// and the red row deliberately gains the most.
const sepia: Matrix = .{
    101, 197, 48,
    89,  176, 43,
    70,  137, 34,
};

/// Saturation is a straight line between grayscale and the original, extended
/// past both ends.
///
///     out = luma + s * (in - luma)
///
/// At `s = 0` that is the luma matrix; at `s = 256` the identity; above 256 it
/// extrapolates away from grey, which is what a saturation slider past 100%
/// does. Writing it as a matrix rather than as a special case is what lets it
/// compose with everything else here.
fn saturation(s: i32) Matrix {
    var m: Matrix = undefined;
    for (&m, identity, luma_matrix) |*out, i, l| {
        out.* = @divTrunc(s * i + (scale - s) * l, scale);
    }
    return m;
}

/// Matrix product, in the same fixed point. `compose(a, b)` applies `b` first.
fn compose(a: Matrix, b: Matrix) Matrix {
    var m: Matrix = undefined;
    for (0..3) |row| {
        for (0..3) |col| {
            var sum: i32 = 0;
            for (0..3) |k| sum += a[row * 3 + k] * b[k * 3 + col];
            m[row * 3 + col] = @divTrunc(sum, scale);
        }
    }
    return m;
}

fn transform(color: u32, m: Matrix) u32 {
    const c = canvas.channels(color);
    const r: i32 = c.r;
    const g: i32 = c.g;
    const b: i32 = c.b;
    return canvas.rgb(
        clamp8(@divTrunc(m[0] * r + m[1] * g + m[2] * b, scale)),
        clamp8(@divTrunc(m[3] * r + m[4] * g + m[5] * b, scale)),
        clamp8(@divTrunc(m[6] * r + m[7] * g + m[8] * b, scale)),
    );
}

fn clamp8(v: i32) u8 {
    return @intCast(std.math.clamp(v, 0, 255));
}

fn apply(buf: []u32, m: Matrix) void {
    for (buf) |*p| p.* = transform(p.*, m);
}

pub fn main(init: std.process.Init) !void {
    var buf: [16384]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    canvas.scene(&pixels);
    apply(&pixels, sepia);
    try out.writeAll("sepia:\n");
    try canvas.dump(out, &pixels);

    const samples = [_]struct { name: []const u8, color: u32 }{
        .{ .name = "red disc", .color = canvas.rgb(230, 40, 40) },
        .{ .name = "green disc", .color = canvas.rgb(40, 220, 90) },
        .{ .name = "yellow block", .color = canvas.rgb(255, 220, 60) },
    };
    const shown = [_]struct { name: []const u8, m: Matrix }{
        .{ .name = "identity", .m = identity },
        .{ .name = "sat 0", .m = saturation(0) },
        .{ .name = "sat 200%", .m = saturation(512) },
        .{ .name = "sepia", .m = sepia },
    };

    try out.writeAll("\n");
    for (samples) |s| {
        try out.print("{s:>13}:", .{s.name});
        for (shown) |t| {
            const c = canvas.channels(transform(s.color, t.m));
            try out.print("  {s} {d:>3},{d:>3},{d:>3}", .{ t.name, c.r, c.g, c.b });
        }
        try out.writeByte('\n');
    }

    // Saturation moves chroma without moving luma, because the weights that
    // build the grey it interpolates against are the same weights that measure
    // brightness. The dump of a saturated image is nearly identical to the dump
    // of the original, and that is the correct result rather than a bug in the
    // dump.
    try out.writeAll("\nluma under saturation (original, sat 0, sat 200%)\n");
    for (samples) |s| {
        try out.print("{s:>13}: {d:>3} {d:>3} {d:>3}\n", .{
            s.name,
            canvas.luma(s.color),
            canvas.luma(transform(s.color, saturation(0))),
            canvas.luma(transform(s.color, saturation(512))),
        });
    }

    // Composition, and its cost. Two passes round to a byte twice; the composed
    // matrix rounds once, in the matrix product instead. The results are close
    // but not identical, which is the difference from a lookup table: composing
    // point operations on 8-bit channels is exact, composing matrices is not.
    canvas.scene(&pixels);
    apply(&pixels, saturation(160));
    apply(&pixels, sepia);

    canvas.scene(&scratch);
    apply(&scratch, compose(sepia, saturation(160)));

    var worst: i32 = 0;
    var differing: usize = 0;
    for (pixels, scratch) |a, b| {
        const ca = canvas.channels(a);
        const cb = canvas.channels(b);
        const d = @max(
            @abs(@as(i32, ca.r) - @as(i32, cb.r)),
            @max(@abs(@as(i32, ca.g) - @as(i32, cb.g)), @abs(@as(i32, ca.b) - @as(i32, cb.b))),
        );
        if (d > 0) differing += 1;
        worst = @max(worst, @as(i32, @intCast(d)));
    }
    try out.print(
        "\ntwo passes vs one composed matrix: {d} of {d} pixels differ, by at most {d}\n",
        .{ differing, pixels.len, worst },
    );

    const composed = compose(sepia, saturation(160));
    try out.writeAll("\nsepia . saturation(62%) =\n");
    for (0..3) |row| {
        try out.print("  {d:>4} {d:>4} {d:>4}\n", .{
            composed[row * 3], composed[row * 3 + 1], composed[row * 3 + 2],
        });
    }

    try out.flush();
}

Grayscale, written as a matrix

Put the luma weights in all three rows:

const luma_matrix: Matrix = .{
    77, 150, 29,
    77, 150, 29,
    77, 150, 29,
};

Every output channel becomes the same weighted average, which is grayscale. The entries are scaled by 256 and the multiply divides by 256 at the end, so this stays in integers throughout. Floats would buy precision that the final cast to u8 throws away, and cost a conversion at each end.

Saturation is a line through grey

out = luma + s * (in - luma)

At s = 0 that is grayscale, at s = 1 the original, and above 1 it keeps going in the same direction, away from grey. As a matrix it is a blend of the luma matrix and the identity:

fn saturation(s: i32) Matrix {
    var m: Matrix = undefined;
    for (&m, identity, luma_matrix) |*out, i, l| {
        out.* = @divTrunc(s * i + (scale - s) * l, scale);
    }
    return m;
}

Writing it as a matrix rather than as a special case is what lets it compose with the others.

There is a property worth noticing in the output. Saturation moves chroma and leaves brightness alone:

luma under saturation (original, sat 0, sat 200%)
     red disc:  97  97  76
   green disc: 151 151 152
 yellow block: 212 212 209

Grayscale preserves luma exactly, because the grey it produces is defined as the luma. At 200% the numbers drift, and the red disc drops from 97 to 76. That is clipping, not a flaw in the maths: extrapolating red past 100% saturation gives 255, 0, 0, the channels hit the wall, and a clipped colour no longer has the luma the matrix asked for. Any saturation control pushed far enough loses brightness in exactly the colours it was supposed to intensify.

Sepia’s rows do not sum to 256

const sepia: Matrix = .{
    101, 197, 48,
    89,  176, 43,
    70,  137, 34,
};

Those rows sum to 346, 308 and 241. A row summing to 256 preserves brightness; these deliberately do not. Sepia is meant to lift the image as it tints it, and the red row gains the most, which is where the warmth comes from. Getting this matrix “wrong” in the direction of 256 gives a muddy grey-brown that looks nothing like the effect.

It is worth knowing the difference, because a matrix that unintentionally sums to more than 256 blows out highlights for no reason.

Composition, and what it costs

The product of two colour matrices is the matrix that applies both:

fn compose(a: Matrix, b: Matrix) Matrix { ... }  // applies b first

Sepia after 62% saturation, as one matrix:

  +100 +198  +43
   +89 +177  +38
   +70 +138  +30

One pass over the image instead of two, and the saving grows with the length of the chain. A photo app applying six adjustments does one multiply per pixel, not six.

The composition is not exact:

two passes vs one composed matrix: 1560 of 2048 pixels differ, by at most 1

Two passes round to a byte in between; the composed matrix rounds once, inside the matrix product. This is the difference from the lookup tables in per-pixel transforms, where composing was exact to the last bit. A table maps bytes to bytes and there is nowhere for an error to hide. A matrix carries fractions, and rounding them earlier or later gives different answers.

Off by one per channel is below what anyone can see, and it is the composed version that is more accurate, since it rounds once instead of twice. Worth knowing anyway, because it means “same filter, different order of operations” does not give bit-identical output, and a test that compares images byte for byte will fail on it.

What a matrix cannot do

Anything spatial. A colour matrix reads one pixel. Blur, sharpen and edge detection need neighbours, which is convolution, a different loop with a different cost.

Anything non-linear. Gamma, curves and thresholds are not linear functions of the channels, so no matrix expresses them. Those are lookup tables, and a real pipeline mixes both: tables for the curves, a matrix for the colour mixing.

Alpha. These are 3x3 over RGB. Systems that include alpha use a 4x5 (four rows, four coefficients plus a constant offset), which is what the CSS feColorMatrix filter and Android’s ColorMatrix both take. The extra column is what lets a matrix add a constant, and that is how tinting toward a fixed colour works.

Everything on this page also multiplies sRGB bytes directly, with the same caveat alpha blending sets out: those bytes are not linear light. Colour operations that care convert to linear first.