Initial Commit
This commit is contained in:
29
packages/media/image/colour-convert-adapt/README.md
Normal file
29
packages/media/image/colour-convert-adapt/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# @suaveplan/colour-convert-adapt - Bradford chromatic adaptation between XYZ illuminants for ICC-style colour management
|
||||
|
||||
Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's appearance so that it looks the same under different light sources (illuminants). The Bradford transform is the industry-standard method, used in ICC colour management and many colour science toolkits.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapt](./src/adapt/adapt.md) — `adapt.ts` provides Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's a…
|
||||
- [adapt](./src/adapt/adapt.md) — `adapt.ts` provides Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's a…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/colour-convert-adapt
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { bradfordAdapt } from "@suaveplan/colour-convert-adapt";
|
||||
|
||||
const d50 = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "D50");
|
||||
console.log(d50); // { x: ~52.9, y: ~50, z: ~37.7 }
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/adapt/adapt.md`](./src/adapt/adapt.md) — Bradford chromatic adaptation implementation and API reference
|
||||
44
packages/media/image/colour-convert-adapt/package.json
Normal file
44
packages/media/image/colour-convert-adapt/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@suaveplan/colour-convert-adapt",
|
||||
"version": "0.1.0",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./adapt/adapt": {
|
||||
"import": {
|
||||
"types": "./dist/adapt/adapt.d.ts",
|
||||
"default": "./dist/adapt/adapt.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:*",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-dts": "^4.5.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
48
packages/media/image/colour-convert-adapt/src/adapt/adapt.md
Normal file
48
packages/media/image/colour-convert-adapt/src/adapt/adapt.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# adapt
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`adapt.ts` provides Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's appearance so that it looks the same under different light sources (illuminants). The Bradford transform is the industry-standard method for this, used in ICC colour management and many colour science toolkits. When a photograph is taken under tungsten lighting but viewed under daylight, chromatic adaptation predicts how the colours should be shifted to preserve their perceived appearance.
|
||||
|
||||
## Features
|
||||
|
||||
- 11 built-in standard illuminants (A, B, C, D50/55/65/75, E, F2/F7/F11)
|
||||
- Custom XYZ illuminant support for specialised workflows
|
||||
- Default destination is D65, the most common reference white
|
||||
- Full XYZ round-trip fidelity when source and destination match
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { bradfordAdapt } from "@suaveplan/colour-convert-adapt";
|
||||
|
||||
const d50 = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "D50");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { bradfordAdapt } from "@suaveplan/colour-convert-adapt";
|
||||
|
||||
function matchIlluminant(colours: XYZ[], from: string, to: string) {
|
||||
return colours.map((c) => bradfordAdapt(c, from, to));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `bradfordAdapt(xyz: XYZ, source: string | XYZ, dest?: string | XYZ): XYZ`
|
||||
|
||||
Adapts an XYZ colour from one illuminant to another using the Bradford chromatic adaptation transform. The `source` and `dest` parameters may be a named illuminant string or a custom XYZ white point. Defaults to D65 when `dest` is omitted.
|
||||
|
||||
### `ILLUMINANTS: Record<string, XYZ>`
|
||||
|
||||
Map of standard CIE illuminant white points used by the Bradford transform.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Bradford adaptation uses a cone-response space (LMS) via the Bradford matrix and its inverse. The XYZ colour is first converted to LMS, scaled by the ratio of destination to source white point in LMS space, then converted back to XYZ. This preserves colour appearance under changing lighting conditions. The matrix coefficients are from the ICC specification. Small floating-point differences may occur in identity transformations due to the finite precision of the matrix coefficients, but these are typically on the order of 0.0001% and negligible for practical applications.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { bradfordAdapt, ILLUMINANTS } from "./adapt.js";
|
||||
|
||||
describe("colour-convert-adapt", () => {
|
||||
it("D65 to D65 is identity", { timeout: 5000 }, () => {
|
||||
const xyz = { x: 50, y: 50, z: 50 };
|
||||
const result = bradfordAdapt(xyz, "D65", "D65");
|
||||
expect(result.x).toBeCloseTo(xyz.x, 3);
|
||||
expect(result.y).toBeCloseTo(xyz.y, 3);
|
||||
expect(result.z).toBeCloseTo(xyz.z, 3);
|
||||
});
|
||||
|
||||
it("adapts D65 to D50", { timeout: 5000 }, () => {
|
||||
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "D50");
|
||||
expect(result.x).not.toBeCloseTo(50, 1);
|
||||
});
|
||||
|
||||
it("adapts D50 to D65", { timeout: 5000 }, () => {
|
||||
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D50", "D65");
|
||||
expect(result.x).not.toBeCloseTo(50, 1);
|
||||
});
|
||||
|
||||
it("adapts all named illuminants", { timeout: 5000 }, () => {
|
||||
const names = Object.keys(ILLUMINANTS);
|
||||
for (const src of names) {
|
||||
for (const dst of names) {
|
||||
if (src === dst) continue;
|
||||
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, src, dst);
|
||||
expect(Number.isFinite(result.x)).toBe(true);
|
||||
expect(Number.isFinite(result.y)).toBe(true);
|
||||
expect(Number.isFinite(result.z)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts custom XYZ illuminants", { timeout: 5000 }, () => {
|
||||
const custom = { x: 95, y: 100, z: 108 };
|
||||
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, custom, custom);
|
||||
expect(result.x).toBeCloseTo(50, 3);
|
||||
expect(result.y).toBeCloseTo(50, 3);
|
||||
expect(result.z).toBeCloseTo(50, 3);
|
||||
});
|
||||
|
||||
it("defaults destination to D65", { timeout: 5000 }, () => {
|
||||
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D50");
|
||||
const explicit = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D50", "D65");
|
||||
expect(result.x).toBeCloseTo(explicit.x, 5);
|
||||
expect(result.y).toBeCloseTo(explicit.y, 5);
|
||||
expect(result.z).toBeCloseTo(explicit.z, 5);
|
||||
});
|
||||
|
||||
it("throws on invalid illuminant string", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
bradfordAdapt({ x: 50, y: 50, z: 50 }, "XYZ", "D65")
|
||||
).toThrow("Invalid illuminant");
|
||||
});
|
||||
|
||||
it("throws on invalid destination string", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "INVALID")
|
||||
).toThrow("Invalid illuminant");
|
||||
});
|
||||
});
|
||||
76
packages/media/image/colour-convert-adapt/src/adapt/adapt.ts
Normal file
76
packages/media/image/colour-convert-adapt/src/adapt/adapt.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface XYZ {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
}
|
||||
|
||||
const BRADFORD_MA: [number[], number[], number[]] = [
|
||||
[0.8951, 0.2664, -0.1614],
|
||||
[-0.7502, 1.7135, 0.0367],
|
||||
[0.0389, -0.0685, 1.0296],
|
||||
];
|
||||
|
||||
const BRADFORD_MA_INV: [number[], number[], number[]] = [
|
||||
[0.986993, -0.147054, 0.159963],
|
||||
[0.432305, 0.51836, 0.049291],
|
||||
[-0.008529, 0.040043, 0.968487],
|
||||
];
|
||||
|
||||
export const ILLUMINANTS: Record<string, XYZ> = {
|
||||
A: { x: 109.85, y: 100.0, z: 35.585 },
|
||||
B: { x: 99.0927, y: 100.0, z: 85.313 },
|
||||
C: { x: 98.074, y: 100.0, z: 118.232 },
|
||||
D50: { x: 96.4212, y: 100.0, z: 82.5188 },
|
||||
D55: { x: 95.6797, y: 100.0, z: 92.1481 },
|
||||
D65: { x: 95.047, y: 100.0, z: 108.883 },
|
||||
D75: { x: 94.972, y: 100.0, z: 122.638 },
|
||||
E: { x: 100.0, y: 100.0, z: 100.0 },
|
||||
F2: { x: 99.187, y: 100.0, z: 67.395 },
|
||||
F7: { x: 95.044, y: 100.0, z: 108.755 },
|
||||
F11: { x: 100.966, y: 100.0, z: 64.37 },
|
||||
};
|
||||
|
||||
function matVec3(
|
||||
m: [number[], number[], number[]],
|
||||
v: { x: number; y: number; z: number }
|
||||
): { x: number; y: number; z: number } {
|
||||
const r0 = m[0]!;
|
||||
const r1 = m[1]!;
|
||||
const r2 = m[2]!;
|
||||
return {
|
||||
x: r0[0]! * v.x + r0[1]! * v.y + r0[2]! * v.z,
|
||||
y: r1[0]! * v.x + r1[1]! * v.y + r1[2]! * v.z,
|
||||
z: r2[0]! * v.x + r2[1]! * v.y + r2[2]! * v.z,
|
||||
};
|
||||
}
|
||||
|
||||
export function bradfordAdapt(
|
||||
xyz: XYZ,
|
||||
sourceIlluminant: string | XYZ,
|
||||
destIlluminant: string | XYZ = "D65"
|
||||
): XYZ {
|
||||
const src =
|
||||
typeof sourceIlluminant === "string"
|
||||
? ILLUMINANTS[sourceIlluminant]
|
||||
: sourceIlluminant;
|
||||
const dst =
|
||||
typeof destIlluminant === "string"
|
||||
? ILLUMINANTS[destIlluminant]
|
||||
: destIlluminant;
|
||||
|
||||
if (!src || !dst) {
|
||||
throw new Error("Invalid illuminant");
|
||||
}
|
||||
|
||||
const srcLms = matVec3(BRADFORD_MA, src);
|
||||
const dstLms = matVec3(BRADFORD_MA, dst);
|
||||
const xyzLms = matVec3(BRADFORD_MA, xyz);
|
||||
|
||||
const scaled = {
|
||||
x: xyzLms.x * (dstLms.x / srcLms.x),
|
||||
y: xyzLms.y * (dstLms.y / srcLms.y),
|
||||
z: xyzLms.z * (dstLms.z / srcLms.z),
|
||||
};
|
||||
|
||||
return matVec3(BRADFORD_MA_INV, scaled);
|
||||
}
|
||||
2
packages/media/image/colour-convert-adapt/src/index.ts
Normal file
2
packages/media/image/colour-convert-adapt/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { XYZ } from "./adapt/adapt.js";
|
||||
export { bradfordAdapt, ILLUMINANTS } from "./adapt/adapt.js";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import pkg from "../package.json";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("only depends on allowed @suaveplan packages", () => {
|
||||
const deps = Object.keys(pkg.dependencies ?? {});
|
||||
const allowed = ["@suaveplan/colours"];
|
||||
for (const dep of deps) {
|
||||
if (dep.startsWith("@suaveplan/")) {
|
||||
expect(allowed).toContain(dep);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
// no polyfills needed
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import pkg from "../package.json";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("has sideEffects false", () => {
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("has at least one subpath export", () => {
|
||||
const exports = Object.keys(pkg.exports ?? {});
|
||||
expect(exports.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
15
packages/media/image/colour-convert-adapt/tsconfig.lib.json
Normal file
15
packages/media/image/colour-convert-adapt/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
19
packages/media/image/colour-convert-adapt/vite.config.ts
Normal file
19
packages/media/image/colour-convert-adapt/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: {
|
||||
index: "./src/index.ts",
|
||||
"adapt/adapt": "./src/adapt/adapt.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: [/^@suaveplan\//, "tslib"],
|
||||
},
|
||||
},
|
||||
plugins: [dts({ rollupTypes: false, include: ["src/**/*.ts"], tsconfigPath: "./tsconfig.lib.json" })],
|
||||
});
|
||||
21
packages/media/image/colour-convert-adapt/vitest.config.ts
Normal file
21
packages/media/image/colour-convert-adapt/vitest.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/index.ts"],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user