From 0b6a9709127b360529b35b90506f77059cc3dcfc Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 15:11:36 +0800 Subject: [PATCH] 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. --- packages/image-engine/src/engine.ts | 3 + packages/image-engine/src/index.ts | 1 + .../src/operations/color-blindness.ts | 53 +++++++++++ packages/image-engine/src/types.ts | 14 +++ .../unit/image-engine/color-blindness.test.ts | 87 +++++++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 packages/image-engine/src/operations/color-blindness.ts create mode 100644 tests/unit/image-engine/color-blindness.test.ts diff --git a/packages/image-engine/src/engine.ts b/packages/image-engine/src/engine.ts index a4388b11..99a850dc 100644 --- a/packages/image-engine/src/engine.ts +++ b/packages/image-engine/src/engine.ts @@ -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), diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index 155e1dba..921bc293 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -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"; diff --git a/packages/image-engine/src/operations/color-blindness.ts b/packages/image-engine/src/operations/color-blindness.ts new file mode 100644 index 00000000..8d4f2c06 --- /dev/null +++ b/packages/image-engine/src/operations/color-blindness.ts @@ -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 = { + 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 { + return image.recomb(COLOR_BLINDNESS_MATRICES[options.type]); +} diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 0515e359..064e5019 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -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; +} diff --git a/tests/unit/image-engine/color-blindness.test.ts b/tests/unit/image-engine/color-blindness.test.ts new file mode 100644 index 00000000..686235a5 --- /dev/null +++ b/tests/unit/image-engine/color-blindness.test.ts @@ -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(); + 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); + }); +});