feat: add color blindness simulation image-engine operation

Add colorBlindness() operation with 8 simulation matrices (Vienot/Machado)
for protanopia, deuteranopia, tritanopia, protanomaly, deuteranomaly,
tritanomaly, achromatopsia, and blue cone monochromacy.
This commit is contained in:
SnapOtter
2026-05-08 15:11:36 +08:00
parent 1669e34909
commit 0b6a970912
5 changed files with 158 additions and 0 deletions
+3
View File
@@ -1,5 +1,6 @@
import sharp from "sharp";
import { brightness } from "./operations/brightness.js";
import { colorBlindness } from "./operations/color-blindness.js";
import { colorChannels } from "./operations/color-channels.js";
import { compress } from "./operations/compress.js";
import { contrast } from "./operations/contrast.js";
@@ -17,6 +18,7 @@ import { sharpen, sharpenAdvanced } from "./operations/sharpen.js";
import { stripMetadata } from "./operations/strip-metadata.js";
import type {
BrightnessOptions,
ColorBlindnessOptions,
ColorChannelOptions,
CompressOptions,
ContrastOptions,
@@ -55,6 +57,7 @@ const OPERATION_MAP: Record<
brightness: (img, opts) => brightness(img, opts as unknown as BrightnessOptions),
contrast: (img, opts) => contrast(img, opts as unknown as ContrastOptions),
saturation: (img, opts) => saturation(img, opts as unknown as SaturationOptions),
"color-blindness": (img, opts) => colorBlindness(img, opts as unknown as ColorBlindnessOptions),
"color-channels": (img, opts) => colorChannels(img, opts as unknown as ColorChannelOptions),
grayscale: (img) => grayscale(img),
sepia: (img) => sepia(img),
+1
View File
@@ -2,6 +2,7 @@ export * from "./engine.js";
export * from "./formats/detect.js";
export { analyzeImage, applyCorrections, scaleCorrections } from "./operations/auto-enhance.js";
export { brightness } from "./operations/brightness.js";
export { COLOR_BLINDNESS_MATRICES, colorBlindness } from "./operations/color-blindness.js";
export { colorChannels } from "./operations/color-channels.js";
export { compress } from "./operations/compress.js";
export { contrast } from "./operations/contrast.js";
@@ -0,0 +1,53 @@
import type { ColorBlindnessType, Sharp } from "../types.js";
type Matrix3x3 = [[number, number, number], [number, number, number], [number, number, number]];
export const COLOR_BLINDNESS_MATRICES: Record<ColorBlindnessType, Matrix3x3> = {
protanopia: [
[0.152286, 1.052583, -0.204868],
[0.114503, 0.786281, 0.099216],
[-0.003882, -0.048116, 1.051998],
],
deuteranopia: [
[0.367322, 0.860646, -0.227968],
[0.280085, 0.672501, 0.047413],
[-0.01182, 0.04294, 0.968881],
],
tritanopia: [
[1.255528, -0.076749, -0.178779],
[-0.078411, 0.930809, 0.147602],
[0.004733, 0.691367, 0.3039],
],
protanomaly: [
[0.458064, 0.679578, -0.137642],
[0.092785, 0.846313, 0.060902],
[-0.007494, -0.016807, 1.024301],
],
deuteranomaly: [
[0.547494, 0.607765, -0.155259],
[0.181692, 0.781742, 0.036566],
[-0.01041, 0.027275, 0.983136],
],
tritanomaly: [
[1.017277, 0.027029, -0.044306],
[-0.006113, 0.958479, 0.047634],
[0.006379, 0.248708, 0.744913],
],
achromatopsia: [
[0.2126, 0.7152, 0.0722],
[0.2126, 0.7152, 0.0722],
[0.2126, 0.7152, 0.0722],
],
blueConeMonochromacy: [
[0.01775, 0.10945, 0.87262],
[0.01775, 0.10945, 0.87262],
[0.01775, 0.10945, 0.87262],
],
};
export async function colorBlindness(
image: Sharp,
options: { type: ColorBlindnessType },
): Promise<Sharp> {
return image.recomb(COLOR_BLINDNESS_MATRICES[options.type]);
}
+14
View File
@@ -190,3 +190,17 @@ export interface OptimizeForWebOptions {
progressive?: boolean;
stripMetadata?: boolean;
}
export type ColorBlindnessType =
| "protanopia"
| "deuteranopia"
| "tritanopia"
| "protanomaly"
| "deuteranomaly"
| "tritanomaly"
| "achromatopsia"
| "blueConeMonochromacy";
export interface ColorBlindnessOptions {
type: ColorBlindnessType;
}
@@ -0,0 +1,87 @@
import type { ColorBlindnessType } from "@snapotter/image-engine";
import { COLOR_BLINDNESS_MATRICES, colorBlindness } from "@snapotter/image-engine";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
const ALL_TYPES: ColorBlindnessType[] = [
"protanopia",
"deuteranopia",
"tritanopia",
"protanomaly",
"deuteranomaly",
"tritanomaly",
"achromatopsia",
"blueConeMonochromacy",
];
function makeColorImage(r: number, g: number, b: number): sharp.Sharp {
return sharp({
create: { width: 10, height: 10, channels: 3, background: { r, g, b } },
}).png();
}
describe("Color blindness matrices", () => {
it("all 8 types have a valid 3x3 matrix", () => {
for (const type of ALL_TYPES) {
const matrix = COLOR_BLINDNESS_MATRICES[type];
expect(matrix).toBeDefined();
expect(matrix).toHaveLength(3);
for (const row of matrix) {
expect(row).toHaveLength(3);
for (const val of row) {
expect(Number.isFinite(val)).toBe(true);
}
}
}
});
it("matrix row sums are in a reasonable range (0 to 1.5)", () => {
for (const type of ALL_TYPES) {
const matrix = COLOR_BLINDNESS_MATRICES[type];
for (const row of matrix) {
const sum = row[0] + row[1] + row[2];
expect(sum).toBeGreaterThanOrEqual(0);
expect(sum).toBeLessThanOrEqual(1.5);
}
}
});
});
describe("colorBlindness operation", () => {
it("returns a Sharp instance for each type", async () => {
for (const type of ALL_TYPES) {
const result = await colorBlindness(makeColorImage(255, 0, 0), { type });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
}
});
it("different types produce different outputs on a red image", async () => {
const outputs = new Map<string, Buffer>();
for (const type of ALL_TYPES) {
const result = await colorBlindness(makeColorImage(255, 0, 0), { type });
const { data } = await result.removeAlpha().raw().toBuffer({ resolveWithObject: true });
outputs.set(type, Buffer.from(data));
}
const uniqueOutputs = new Set([...outputs.values()].map((b) => `${b[0]},${b[1]},${b[2]}`));
expect(uniqueOutputs.size).toBeGreaterThan(1);
});
it("achromatopsia produces grayscale output (R === G === B)", async () => {
const result = await colorBlindness(makeColorImage(255, 0, 0), {
type: "achromatopsia",
});
const { data } = await result.removeAlpha().raw().toBuffer({ resolveWithObject: true });
expect(data[0]).toBe(data[1]);
expect(data[1]).toBe(data[2]);
});
it("preserves image dimensions", async () => {
const result = await colorBlindness(makeColorImage(100, 150, 200), {
type: "deuteranomaly",
});
const meta = await result.metadata();
expect(meta.width).toBe(10);
expect(meta.height).toBe(10);
});
});