Merge feat/color-blindness: add Color Blindness Simulation tool (#49)

This commit is contained in:
SnapOtter
2026-05-08 15:40:54 +08:00
20 changed files with 696 additions and 7 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
## Key Features
- **48 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more
- **49 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more
- **Local AI** - Remove backgrounds, upscale images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR). All on your hardware - no internet required
- **Pipelines** - Chain tools into reusable workflows with unlimited steps. Batch process unlimited images at once
- **REST API** - Every tool available via API with API key auth. Interactive docs at `/api/docs`
+44
View File
@@ -2053,6 +2053,50 @@ paths:
schema:
$ref: "#/components/schemas/UnauthorizedError"
/api/v1/tools/color-blindness:
post:
tags: [Tools]
summary: Color blindness simulation
description: Simulate how an image appears to people with various types of color vision deficiency.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: Image file to process
settings:
type: string
description: |
JSON string with options:
- `simulationType` (enum, default "deuteranomaly") -- Type of color vision deficiency to simulate. One of: protanopia, deuteranopia, tritanopia, protanomaly, deuteranomaly, tritanomaly, achromatopsia, blueConeMonochromacy
responses:
"200":
description: Processed image
content:
application/json:
schema:
$ref: "#/components/schemas/ToolResponse"
"400":
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedError"
/api/v1/tools/gif-tools:
post:
tags: [Tools]
@@ -0,0 +1,39 @@
import { colorBlindness } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
simulationType: z
.enum([
"protanopia",
"deuteranopia",
"tritanopia",
"protanomaly",
"deuteranomaly",
"tritanomaly",
"achromatopsia",
"blueConeMonochromacy",
])
.default("deuteranomaly"),
});
export function registerColorBlindness(app: FastifyInstance) {
createToolRoute(app, {
toolId: "color-blindness",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer);
const result = await colorBlindness(image, { type: settings.simulationType });
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const buffer = await result
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
return { buffer, filename, contentType: outputFormat.contentType };
},
});
}
+2
View File
@@ -8,6 +8,7 @@ import { registerBorder } from "./border.js";
import { registerBulkRename } from "./bulk-rename.js";
import { registerCollage } from "./collage.js";
import { registerColorAdjustments } from "./color-adjustments.js";
import { registerColorBlindness } from "./color-blindness.js";
import { registerColorPalette } from "./color-palette.js";
import { registerColorize } from "./colorize.js";
import { registerCompare } from "./compare.js";
@@ -131,6 +132,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
// Adjustments extra
{ id: "replace-color", register: registerReplaceColor },
{ id: "color-blindness", register: registerColorBlindness },
// AI Tools
{ id: "remove-background", register: registerRemoveBackground },
+2 -2
View File
@@ -4,7 +4,7 @@ import llmstxt from "vitepress-plugin-llms";
export default defineConfig({
title: "SnapOtter",
description:
"Documentation for SnapOtter - A Self Hosted Image Manipulator. 48 tools, local AI, pipelines, REST API.",
"Documentation for SnapOtter - A Self Hosted Image Manipulator. 49 tools, local AI, pipelines, REST API.",
base: "/",
appearance: { initialValue: "light" },
srcDir: ".",
@@ -48,7 +48,7 @@ export default defineConfig({
`,
customTemplateVariables: {
description:
"SnapOtter is a self-hosted, open-source image processing platform with 48 tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
"SnapOtter is a self-hosted, open-source image processing platform with 49 tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
details:
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
},
+1
View File
@@ -131,6 +131,7 @@ curl -X POST http://localhost:1349/api/v1/tools/<toolId>/batch \
| `adjust-colors` | Adjust Colors | `brightness`, `contrast`, `exposure`, `saturation`, `temperature`, `tint`, `hue`, `sharpness`, `red`, `green`, `blue`, `effect` (none/grayscale/sepia/invert) |
| `sharpening` | Sharpening | `method` (adaptive/unsharp-mask/high-pass), `sigma`, `m1`, `m2`, `x1`, `y2`, `y3`, `amount`, `radius`, `threshold`, `strength`, `kernelSize` (3/5), `denoise` (off/light/medium/strong) |
| `replace-color` | Replace Color | `sourceColor`, `targetColor` (replacement), `makeTransparent`, `tolerance` |
| `color-blindness` | Color Blindness Simulation | `simulationType` (protanopia/deuteranopia/tritanopia/protanomaly/deuteranomaly/tritanomaly/achromatopsia/blueConeMonochromacy, default "deuteranomaly") |
### AI Tools
+1 -1
View File
@@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`)
A Fastify v5 server exposing 48 tool routes (33 standard image operations + 15 AI-powered) that handles:
A Fastify v5 server exposing 49 tool routes (33 standard image operations + 15 AI-powered) that handles:
- File uploads, temporary workspace management, and persistent file storage
- User file library with version chains (`user_files` table) -- each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
- Tool execution (routes each tool request to the image engine or AI bridge)
+1 -1
View File
@@ -64,7 +64,7 @@ pnpm dev
## What You Can Do
### Image Processing (48 Tools)
### Image Processing (49 Tools)
| Category | Tools |
|----------|-------|
+2 -2
View File
@@ -4,7 +4,7 @@ layout: home
hero:
name: "SnapOtter"
text: "A Self Hosted Image Manipulator"
tagline: 48 tools. Local AI. No cloud. Your images never leave your home.
tagline: 49 tools. Local AI. No cloud. Your images never leave your home.
actions:
- theme: brand
text: Get started
@@ -14,7 +14,7 @@ hero:
link: /api/rest
features:
- title: 48 Image Tools
- title: 49 Image Tools
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
- title: Local AI
details: 15 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency. All on your hardware, no internet required.
@@ -0,0 +1,164 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
const SIMULATION_TYPES = [
{
group: "Red-Green",
types: [
{
value: "protanopia",
label: "Protanopia",
description: "No red cones. Reds appear dark. ~1% of males.",
},
{
value: "protanomaly",
label: "Protanomaly",
description: "Reduced red sensitivity. ~1% of males.",
},
{
value: "deuteranopia",
label: "Deuteranopia",
description: "No green cones. Reds and greens look similar. ~1% of males.",
},
{
value: "deuteranomaly",
label: "Deuteranomaly",
description: "Reduced green sensitivity. Most common type. ~5% of males.",
},
],
},
{
group: "Blue-Yellow",
types: [
{
value: "tritanopia",
label: "Tritanopia",
description: "No blue cones. Blues and greens look similar. Very rare.",
},
{
value: "tritanomaly",
label: "Tritanomaly",
description: "Reduced blue sensitivity. Very rare.",
},
],
},
{
group: "Monochromatic",
types: [
{
value: "achromatopsia",
label: "Achromatopsia",
description: "Complete color blindness. Sees only luminance. Very rare.",
},
{
value: "blueConeMonochromacy",
label: "Blue Cone Monochromacy",
description: "Only blue cones functional. Very limited color. Very rare.",
},
],
},
];
const TYPE_MAP = new Map(SIMULATION_TYPES.flatMap((g) => g.types.map((t) => [t.value, t])));
export function ColorBlindnessSettings() {
const { files } = useFileStore();
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("color-blindness");
const [simulationType, setSimulationType] = useState("deuteranomaly");
const selectedInfo = TYPE_MAP.get(simulationType);
const handleProcess = () => {
const settings = { simulationType };
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<div>
<label htmlFor="cb-simulation-type" className="text-xs text-muted-foreground">
Simulation Type
</label>
<select
id="cb-simulation-type"
value={simulationType}
onChange={(e) => setSimulationType(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
{SIMULATION_TYPES.map((group) => (
<optgroup key={group.group} label={group.group}>
{group.types.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</optgroup>
))}
</select>
{selectedInfo && (
<p className="mt-1 text-xs text-muted-foreground">{selectedInfo.description}</p>
)}
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Simulating color blindness"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="color-blindness-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Simulate (${files.length} files)` : "Simulate"}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="color-blindness-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
+6
View File
@@ -308,6 +308,11 @@ const TransparencyFixerSettings = lazy(() =>
default: m.TransparencyFixerSettings,
})),
);
const ColorBlindnessSettings = lazy(() =>
import("@/components/tools/color-blindness-settings").then((m) => ({
default: m.ColorBlindnessSettings,
})),
);
// ── Color tool wrapper ─────────────────────────────────────────────
// Color tools share a single component but differ by toolId.
@@ -426,6 +431,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
// Adjustments extra
["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }],
["color-blindness", { displayMode: "before-after", Settings: ColorBlindnessSettings }],
// AI Tools
["remove-background", { displayMode: "before-after", Settings: RemoveBgSettings }],
+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,50 @@
import type { ColorBlindnessOptions, 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: ColorBlindnessOptions): 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;
}
+8
View File
@@ -128,6 +128,14 @@ export const TOOLS: Tool[] = [
icon: "Pipette",
route: "/replace-color",
},
{
id: "color-blindness",
name: "Color Blindness Simulation",
description: "Simulate how images appear with color vision deficiency",
category: "adjustments",
icon: "Eye",
route: "/color-blindness",
},
// AI Tools
{
id: "remove-background",
+4
View File
@@ -72,6 +72,10 @@ export const en = {
name: "Replace & Invert Color",
description: "Replace specific colors or invert",
},
"color-blindness": {
name: "Color Blindness Simulation",
description: "Simulate how images appear with different types of color vision deficiency",
},
"remove-background": {
name: "Remove Background",
description: "AI-powered background removal",
+50
View File
@@ -0,0 +1,50 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test, uploadTestImage, waitForProcessing } from "./helpers";
test.describe("Color Blindness Simulation tool", () => {
test("upload -> simulate -> download cycle", async ({ loggedInPage: page }) => {
await page.goto("/color-blindness");
await expect(page.getByText("Color Blindness Simulation").first()).toBeVisible();
await uploadTestImage(page);
await expect(page.getByText("Upload from computer")).not.toBeVisible();
const dropdown = page.locator("#cb-simulation-type");
await expect(dropdown).toBeVisible();
await expect(dropdown).toHaveValue("deuteranomaly");
await dropdown.selectOption("protanopia");
await expect(dropdown).toHaveValue("protanopia");
await page.getByTestId("color-blindness-submit").click();
await waitForProcessing(page);
const downloadBtn = page.getByTestId("color-blindness-download");
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
const downloadPromise = page.waitForEvent("download");
await downloadBtn.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBeTruthy();
const downloadPath = path.join(
process.cwd(),
"test-results",
"download-color-blindness-result",
);
await download.saveAs(downloadPath);
const stat = fs.statSync(downloadPath);
expect(stat.size).toBeGreaterThan(0);
});
test("shows type description when selection changes", async ({ loggedInPage: page }) => {
await page.goto("/color-blindness");
await uploadTestImage(page);
const dropdown = page.locator("#cb-simulation-type");
await dropdown.selectOption("achromatopsia");
await expect(page.getByText("Complete color blindness", { exact: false })).toBeVisible();
});
});
+216
View File
@@ -0,0 +1,216 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const ALL_TYPES = [
"protanopia",
"deuteranopia",
"tritanopia",
"protanomaly",
"deuteranomaly",
"tritanomaly",
"achromatopsia",
"blueConeMonochromacy",
] as const;
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
function makePayload(
settings: Record<string, unknown>,
buffer: Buffer = PNG,
filename = "test.png",
contentType = "image/png",
) {
return createMultipartPayload([
{ name: "file", filename, contentType, content: buffer },
{ name: "settings", content: JSON.stringify(settings) },
]);
}
async function postTool(
settings: Record<string, unknown>,
buffer?: Buffer,
filename?: string,
ct?: string,
) {
const { body: payload, contentType } = makePayload(settings, buffer, filename, ct);
return app.inject({
method: "POST",
url: "/api/v1/tools/color-blindness",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
}
describe("Default settings", () => {
it("processes with default settings (deuteranomaly)", async () => {
const res = await postTool({});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
});
});
describe("All 8 simulation types", () => {
for (const type of ALL_TYPES) {
it(`processes with simulationType=${type}`, async () => {
const res = await postTool({ simulationType: type });
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
});
}
it("different types produce different outputs", async () => {
const buffers: Buffer[] = [];
for (const type of ["protanopia", "tritanopia", "achromatopsia"] as const) {
const res = await postTool({ simulationType: type });
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
buffers.push(dlRes.rawPayload);
}
const pixelSets = await Promise.all(
buffers.map(async (buf) => {
const { data } = await sharp(buf).removeAlpha().raw().toBuffer({ resolveWithObject: true });
return `${data[0]},${data[1]},${data[2]}`;
}),
);
const unique = new Set(pixelSets);
expect(unique.size).toBeGreaterThan(1);
});
});
describe("Dimension preservation", () => {
it("output has same dimensions as input", async () => {
const res = await postTool({ simulationType: "protanopia" });
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
});
describe("Multiple input formats", () => {
it("processes JPEG input", async () => {
const res = await postTool({ simulationType: "deuteranopia" }, JPG, "test.jpg", "image/jpeg");
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
});
it("processes WebP input", async () => {
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
const res = await postTool({ simulationType: "tritanopia" }, WEBP, "test.webp", "image/webp");
expect(res.statusCode).toBe(200);
});
it("processes HEIC input", { timeout: 120_000 }, async () => {
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const res = await postTool({ simulationType: "protanomaly" }, HEIC, "photo.heic", "image/heic");
expect(res.statusCode).toBe(200);
});
it("processes SVG input", async () => {
const SVG = readFileSync(join(FIXTURES, "test-100x100.svg"));
const res = await postTool(
{ simulationType: "achromatopsia" },
SVG,
"icon.svg",
"image/svg+xml",
);
expect(res.statusCode).toBe(200);
});
it("processes animated GIF input", async () => {
const GIF = readFileSync(join(FIXTURES, "animated.gif"));
const res = await postTool({ simulationType: "deuteranomaly" }, GIF, "anim.gif", "image/gif");
expect(res.statusCode).toBe(200);
});
});
describe("Error handling", () => {
it("returns 400 when no file is provided", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ simulationType: "protanopia" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-blindness",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(400);
});
it("returns 400 for invalid simulationType value", async () => {
const res = await postTool({ simulationType: "invalid-type" });
expect(res.statusCode).toBe(400);
});
});
describe("Edge cases", () => {
it("processes 1x1 pixel image", async () => {
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
const res = await postTool({ simulationType: "deuteranomaly" }, TINY, "tiny.png", "image/png");
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0);
});
it("processes stress-large.jpg", async () => {
const LARGE = readFileSync(join(FIXTURES, "content", "stress-large.jpg"));
const res = await postTool({ simulationType: "protanopia" }, LARGE, "large.jpg", "image/jpeg");
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0);
});
});
describe("Authentication", () => {
it("rejects unauthenticated request", async () => {
const { body: payload, contentType } = makePayload({ simulationType: "protanopia" });
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-blindness",
payload,
headers: { "content-type": contentType },
});
expect(res.statusCode).toBe(401);
});
});
@@ -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);
});
});