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 sharp from "sharp";
|
||||||
import { brightness } from "./operations/brightness.js";
|
import { brightness } from "./operations/brightness.js";
|
||||||
|
import { colorBlindness } from "./operations/color-blindness.js";
|
||||||
import { colorChannels } from "./operations/color-channels.js";
|
import { colorChannels } from "./operations/color-channels.js";
|
||||||
import { compress } from "./operations/compress.js";
|
import { compress } from "./operations/compress.js";
|
||||||
import { contrast } from "./operations/contrast.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 { stripMetadata } from "./operations/strip-metadata.js";
|
||||||
import type {
|
import type {
|
||||||
BrightnessOptions,
|
BrightnessOptions,
|
||||||
|
ColorBlindnessOptions,
|
||||||
ColorChannelOptions,
|
ColorChannelOptions,
|
||||||
CompressOptions,
|
CompressOptions,
|
||||||
ContrastOptions,
|
ContrastOptions,
|
||||||
@@ -55,6 +57,7 @@ const OPERATION_MAP: Record<
|
|||||||
brightness: (img, opts) => brightness(img, opts as unknown as BrightnessOptions),
|
brightness: (img, opts) => brightness(img, opts as unknown as BrightnessOptions),
|
||||||
contrast: (img, opts) => contrast(img, opts as unknown as ContrastOptions),
|
contrast: (img, opts) => contrast(img, opts as unknown as ContrastOptions),
|
||||||
saturation: (img, opts) => saturation(img, opts as unknown as SaturationOptions),
|
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),
|
"color-channels": (img, opts) => colorChannels(img, opts as unknown as ColorChannelOptions),
|
||||||
grayscale: (img) => grayscale(img),
|
grayscale: (img) => grayscale(img),
|
||||||
sepia: (img) => sepia(img),
|
sepia: (img) => sepia(img),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from "./engine.js";
|
|||||||
export * from "./formats/detect.js";
|
export * from "./formats/detect.js";
|
||||||
export { analyzeImage, applyCorrections, scaleCorrections } from "./operations/auto-enhance.js";
|
export { analyzeImage, applyCorrections, scaleCorrections } from "./operations/auto-enhance.js";
|
||||||
export { brightness } from "./operations/brightness.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 { colorChannels } from "./operations/color-channels.js";
|
||||||
export { compress } from "./operations/compress.js";
|
export { compress } from "./operations/compress.js";
|
||||||
export { contrast } from "./operations/contrast.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;
|
progressive?: boolean;
|
||||||
stripMetadata?: 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