mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user