Skip to content

Commit e9f00e5

Browse files
committed
add colorMatrix
1 parent 3642be8 commit e9f00e5

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- **`image.quantize({ colors, palette, dither, ditherStrength })`**: reduces the image to a palette, in place: `image.quantize({ colors: 16 })` for the 16 colors that best represent it, or a fixed one: `image.quantize({ palette: [0x0f380fff, 0x306230ff, 0x8bac0fff, 0x9bbc0fff] })` for a Game Boy look. `dither: 'floyd-steinberg'` (or `'atkinson'`, or `'ordered'` for a regular pattern) makes gradients look smooth with few colors. Transparent pixels keep their color and don't count towards the palette.
88
- **`Image.renderSVG(svg, { images })`**: images for the `<image>` elements by their `href`, without data URIs: `Image.renderSVG('<svg ...><image href="logo.png" .../></svg>', { images: { 'logo.png': bytes } })`. The values are encoded images or `Image`s; they are decoded only when the SVG uses them. Nothing is read from files or URLs.
9+
- **`image.colorMatrix(matrix)`**: mixes the colors of every pixel with a color matrix, like SVG's `feColorMatrix`: greyscale, sepia, tints, swapped channels, color blindness simulation or any mix of them in one pass. Twenty values (four rows of `[red, green, blue, alpha, offset]`, 0 to 1) or nine for just the colors: `image.colorMatrix([0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0])` swaps red and blue.
910

1011
## 3.19.0
1112

‎src/image.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2105,6 +2105,43 @@ export class Image {
21052105
return this;
21062106
}
21072107

2108+
/**
2109+
* Mixes the colors of every pixel with a color matrix, like SVG's `feColorMatrix`: the new red, green, blue and alpha
2110+
* are each a sum of the old red, green, blue and alpha times a factor, plus an offset. Greyscale, sepia, tints,
2111+
* swapped channels, color blindness simulation or a mix of those in one pass.
2112+
*
2113+
* The values are 20 numbers, four rows of `[red, green, blue, alpha, offset]` for the new red, green, blue and alpha,
2114+
* with the colors and the offsets as 0 to 1 (like CSS and SVG): `[1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0,
2115+
* 0, 0, 0, 1, 0]` changes nothing, `[0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0]` swaps red and
2116+
* blue. Nine numbers are the first three rows without the alpha and the offsets (the alpha stays as it is). The
2117+
* colors are the sRGB values, not premultiplied, and the results are clamped. With a gain map (an HDR image), the
2118+
* HDR highlights follow the change as they do for other color changes
2119+
* @param matrix The 9 or 20 values
2120+
*/
2121+
colorMatrix(matrix: ArrayLike<number>): this {
2122+
if (matrix === null || typeof matrix !== 'object' || (matrix.length !== 9 && matrix.length !== 20))
2123+
throw new RangeError('A color matrix has 9 or 20 values');
2124+
const m = new Float64Array(20);
2125+
// (numbers only: a string is a mistake in data like this)
2126+
const value = (v: unknown): number => finite(typeof v === 'number' ? v : NaN, 'color matrix value');
2127+
if (matrix.length === 20) for (let i = 0; i < 20; i++) m[i] = value(matrix[i]);
2128+
else for (let row = 0; row < 3; row++) for (let column = 0; column < 3; column++) m[row * 5 + column] = value(matrix[row * 3 + column]);
2129+
// (nine values leave the alpha as it is)
2130+
if (matrix.length === 9) m[18] = 1;
2131+
2132+
if (this.__gain_map__) return this.__recolor__(image => image.colorMatrix(m));
2133+
const bitmap = this.bitmap;
2134+
for (let i = 0; i < bitmap.length; i += 4) {
2135+
const r = bitmap[i], g = bitmap[i + 1], b = bitmap[i + 2], a = bitmap[i + 3];
2136+
// (the offsets are 0 to 1, the values 0 to 255; the clamped array rounds and clamps)
2137+
bitmap[i] = m[0] * r + m[1] * g + m[2] * b + m[3] * a + m[4] * 255;
2138+
bitmap[i + 1] = m[5] * r + m[6] * g + m[7] * b + m[8] * a + m[9] * 255;
2139+
bitmap[i + 2] = m[10] * r + m[11] * g + m[12] * b + m[13] * a + m[14] * 255;
2140+
bitmap[i + 3] = m[15] * r + m[16] * g + m[17] * b + m[18] * a + m[19] * 255;
2141+
}
2142+
return this;
2143+
}
2144+
21082145
/**
21092146
* Adds a shadow behind the image, shaped by its alpha: a drop shadow with an offset, or a glow without one
21102147
* (`image.dropShadow({ blur: 8, color: 0xff2fb9ff })`, with `spread` to make it stronger). The image grows to fit

‎tests/color-matrix.js‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const {Image, Frame, GainMap} = require('..');
2+
3+
const panic = message => {
4+
console.error(message);
5+
process.exit(1);
6+
};
7+
const check = (condition, message) => {
8+
if (!condition) panic(message);
9+
};
10+
const throwsA = (fn, type, label) => {
11+
let error = null;
12+
try { fn(); } catch (e) { error = e; }
13+
if (!(error instanceof type)) panic(`${label}: should throw a ${type.name}, got ${error}`);
14+
};
15+
const px = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4));
16+
const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap));
17+
// an opaque image where every pixel is different
18+
const photo = (width, height) => new Image(width, height).fill((x, y) => Image.rgbaToColor((x * 37 + y * 11) % 256, (x * 5 + y * 91) % 256, (x * y + x) % 256, 255));
19+
const IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];
20+
21+
(async () => {
22+
// colorMatrix
23+
{
24+
const source = new Image(3, 2).fill((x, y) => [0x0a141ec8, 0x64c8ffff, 0xff000000, 0x00ff0080, 0x336699ff, 0xffffffff][y * 3 + x]);
25+
check(same(source.clone().colorMatrix(IDENTITY), source), 'colorMatrix: the identity changes nothing');
26+
check(same(source.clone().colorMatrix([1, 0, 0, 0, 1, 0, 0, 0, 1]), source), 'colorMatrix: the 3x3 identity changes nothing');
27+
check(same(source.clone().colorMatrix(new Float32Array(IDENTITY)), source), 'colorMatrix: typed arrays are values too');
28+
// red and blue swapped, the alpha as it is
29+
const swapped = source.clone().colorMatrix([0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0]);
30+
check(px(swapped, 0, 0).join() === '30,20,10,200', `colorMatrix: swapped channels ${px(swapped, 0, 0)}`);
31+
check(same(source.clone().colorMatrix([0, 0, 1, 0, 1, 0, 1, 0, 0]), source.clone().colorMatrix([0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0])), 'colorMatrix: nine values are twenty with the alpha left alone');
32+
// offsets are 0 to 1, the alpha row can set the alpha
33+
check(px(source.clone().colorMatrix([1, 0, 0, 0, 0.2, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]), 0, 0).join() === '61,20,30,200', 'colorMatrix: offsets are 0 to 1');
34+
check(px(source.clone().colorMatrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1]), 2, 0).join() === '255,0,0,255', 'colorMatrix: an offset in the alpha row makes it opaque');
35+
check(px(source.clone().colorMatrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0.5, 0]), 1, 0)[3] === 128, 'colorMatrix: the alpha is scaled');
36+
// greyscale by the luminance: 0.2126 * 100 + 0.7152 * 150 + 0.0722 * 200 = 143
37+
const luma = [0.2126, 0.7152, 0.0722, 0, 0];
38+
const grey = new Image(1, 1).fill(0x6496c8ff).colorMatrix([...luma, ...luma, ...luma, 0, 0, 0, 1, 0]);
39+
check(px(grey, 0, 0).join() === '143,143,143,255', `colorMatrix: greyscale ${px(grey, 0, 0)}`);
40+
// results are clamped
41+
check(px(new Image(1, 1).fill(0x6496c8ff).colorMatrix([3, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]), 0, 0).join() === '255,0,200,255', 'colorMatrix: clamped');
42+
// the sepia() of the library is such a matrix
43+
const sepia = [0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1, 0];
44+
check(same(photo(20, 15).colorMatrix(sepia), photo(20, 15).sepia()), 'colorMatrix: the sepia matrix is sepia()');
45+
46+
for (const [label, bad] of Object.entries({
47+
'eight values': new Array(8).fill(1), 'twenty-one values': new Array(21).fill(0), 'none': [], 'a number': 5, 'null': null, 'NaN': [...IDENTITY.slice(0, 19), NaN],
48+
'a string': [...IDENTITY.slice(0, 19), '1'], 'Infinity': [Infinity, ...IDENTITY.slice(1)]
49+
})) {
50+
const image = photo(4, 4), before = image.bitmap.slice();
51+
throwsA(() => image.colorMatrix(bad), RangeError, `colorMatrix with ${label}`);
52+
check(Buffer.from(image.bitmap).equals(Buffer.from(before)), `colorMatrix with ${label}: the image should be unchanged`);
53+
}
54+
55+
const frame = Frame.from(photo(4, 4), {duration: 40, xOffset: 2, yOffset: 3}).colorMatrix(sepia);
56+
check(frame instanceof Frame && frame.duration === 40 && frame.xOffset === 2 && frame.yOffset === 3 && same(frame, photo(4, 4).sepia()), 'colorMatrix on a frame');
57+
58+
const hdr = photo(8, 8);
59+
hdr.gainMap = new GainMap(new Image(4, 4).fill(0xc0c0c0ff), {gainMapMin: 0, gainMapMax: 2});
60+
hdr.colorMatrix(sepia);
61+
check(hdr.gainMap && hdr.gainMap.width === 4 && hdr.gainMap.height === 4, 'colorMatrix with a gain map keeps it');
62+
check((await Image.decode(await hdr.encodeJPEG())).gainMap, 'colorMatrix: the gain map is still encoded');
63+
}
64+
})().catch(error => panic(error.stack || String(error)));

0 commit comments

Comments
 (0)