Skip to content

Commit

Permalink
Created the Match Alpha node (#523)
Browse files Browse the repository at this point in the history
* created Match Alpha node

* added changeset for match-alpha-node

* simplified the code in the Match Alpha node

removed unnecessary roundTo() function

* now using correct isNaN() in match-alpha tests

* updated yarn.lock

* added isRange and color outputs to Match Alpha node

fixed incorrect precision test
updated tests for new outputs
added Gray color preset to utils

* ran a code format

* added TS declarations for inputs and outputs

* added back separate threshold input
  • Loading branch information
AlexBxl authored Nov 21, 2024
1 parent c3e7ea3 commit 9ff1317
Show file tree
Hide file tree
Showing 7 changed files with 355 additions and 101 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-ligers-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tokens-studio/graph-engine": minor
---

Created the Match Alpha node which finds the alpha value that can be used to composite two colors to match a reference third color.
2 changes: 2 additions & 0 deletions packages/graph-engine/src/nodes/color/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import deltaE from './deltaE.js';
import distance from './distance.js';
import flattenAlpha from './flattenAlpha.js';
import lighten from './lighten.js';
import matchAlpha from './matchAlpha.js';
import mix from './mix.js';
import name from './name.js';
import poline from './poline.js';
Expand All @@ -28,6 +29,7 @@ export const nodes = [
distance,
deltaE,
flattenAlpha,
matchAlpha,
name,
poline,
scale,
Expand Down
5 changes: 5 additions & 0 deletions packages/graph-engine/src/nodes/color/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export const White = {
channels: [1, 1, 1]
} as ColorType;

export const Gray = {
space: 'srgb',
channels: [0.5, 0.5, 0.5]
} as ColorType;

export const Red = {
space: 'srgb',
channels: [1, 0, 0]
Expand Down
176 changes: 176 additions & 0 deletions packages/graph-engine/src/nodes/color/matchAlpha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Black, Gray, White, toColor, toColorObject } from './lib/utils.js';
import {
BooleanSchema,
ColorSchema,
NumberSchema
} from '../../schemas/index.js';
import { Color as ColorType } from '../../types.js';
import { INodeDefinition, ToInput, ToOutput } from '../../index.js';
import { Node } from '../../programmatic/node.js';
import { setToPrecision } from '@/utils/precision.js';
import Color from 'colorjs.io';

export default class NodeDefinition extends Node {
static title = 'Match Alpha';
static type = 'studio.tokens.color.matchAlpha';
static description =
'Finds the alpha value that, when used to blend the foreground and background colors, will result in the reference color.';

declare inputs: ToInput<{
foreground: ColorType;
background: ColorType;
reference: ColorType;
threshold: number;
precision: number;
}>;
declare outputs: ToOutput<{
inRange: boolean;
color: ColorType;
alpha: number;
}>;

constructor(props: INodeDefinition) {
super(props);

this.addInput('foreground', {
type: {
...ColorSchema,
default: Black
}
});
this.addInput('background', {
type: {
...ColorSchema,
default: White
}
});
this.addInput('reference', {
type: {
...ColorSchema,
default: Gray
}
});
this.addInput('threshold', {
type: {
...NumberSchema,
default: 0.01
}
});
this.addInput('precision', {
type: {
...NumberSchema,
default: 0.01
}
});

this.addOutput('inRange', {
type: BooleanSchema
});
this.addOutput('color', {
type: ColorSchema
});
this.addOutput('alpha', {
type: NumberSchema
});
}

execute(): void | Promise<void> {
const { foreground, background, reference, threshold, precision } =
this.getAllInputs();

const bg = toColor(background);
const fg = toColor(foreground);
const ref = toColor(reference);

let alpha = Number.NaN;
let inRange = false;

// the matching is done per channel

// if the background and reference are "the same" (within precision), return zero
if (
Math.abs(bg.r - ref.r) < precision &&
Math.abs(bg.g - ref.g) < precision &&
Math.abs(bg.b - ref.b) < precision
) {
alpha = 0;
inRange = true;
} else {
// find the matching alpha for each channel
const ar = validateAlpha((ref.r - bg.r) / (fg.r - bg.r));
const ag = validateAlpha((ref.g - bg.g) / (fg.g - bg.g));
const ab = validateAlpha((ref.b - bg.b) / (fg.b - bg.b));

// return the average of the alphas for all matched channels
if (!isNaN(ar) && !isNaN(ag) && !isNaN(ab)) {
if (
Math.abs(ar - ar) < precision &&
Math.abs(ag - ag) < precision &&
Math.abs(ab - ab) < precision
) {
alpha = (ar + ag + ab) / 3;
inRange = true;
}
} else if (!isNaN(ar) && !isNaN(ag)) {
if (Math.abs(ar - ag) < precision) {
alpha = (ar + ag) / 2;
inRange = true;
}
} else if (!isNaN(ag) && !isNaN(ab)) {
if (Math.abs(ag - ab) < precision) {
alpha = (ag + ab) / 2;
inRange = true;
}
} else if (!isNaN(ar) && !isNaN(ab)) {
if (Math.abs(ar - ab) < precision) {
alpha = (ar + ab) / 2;
inRange = true;
}
} else {
alpha = ar || ag || ab;
inRange = !isNaN(alpha);
}
}

// round the result to match the precision
alpha = setToPrecision(
alpha,
Math.max(0, -Math.floor(Math.log10(precision)))
);

// calculate the composite color, and if it's too far from the reference, return NaN
// deltaE() returns values typically ranging from 0 to 100, so I divide it by 100
// to compare normalized colors
const comp = blendColors(fg, bg, alpha);

if (comp.deltaE2000(ref) / 100 > threshold) {
alpha = Number.NaN;
inRange = false;
}

// if the resulting alpha is valid, assign it to the foreground color,
// which becomes the output color
if (inRange) fg.alpha = alpha;

this.outputs.inRange.set(inRange);
this.outputs.color.set(toColorObject(fg));
this.outputs.alpha.set(alpha);
}
}

function validateAlpha(alpha: number) {
// set valid but out-of-range alphas to NaN
return !isNaN(alpha) && alpha >= 0 && alpha <= 1 ? alpha : Number.NaN;
}

function blendChannels(fg: number, bg: number, alpha: number) {
return fg * alpha + bg * (1 - alpha);
}

function blendColors(fg: Color, bg: Color, alpha: number) {
return new Color('srgb', [
blendChannels(fg.r, bg.r, alpha),
blendChannels(fg.g, bg.g, alpha),
blendChannels(fg.b, bg.b, alpha)
]);
}
103 changes: 103 additions & 0 deletions packages/graph-engine/tests/suites/nodes/color/matchAlpha.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Graph } from '../../../../src/graph/graph.js';
import { describe, expect, test } from 'vitest';
import { getAllOutputs } from '../../../../src/utils/node.js';
import Color from 'colorjs.io';
import Node from '../../../../src/nodes/color/matchAlpha.js';

type number3 = [number, number, number];

describe('color/matchAlpha', () => {
test('all colors are valid and a result that makes sense can be found', async () => {
const graph = new Graph();
const node = new Node({ graph });

const fg: number3 = [0.96, 0, 0];
const bg: number3 = [0, 0, 0];
const ref: number3 = [0.48, 0, 0];

node.inputs.foreground.setValue({ space: 'srgb', channels: fg });
node.inputs.background.setValue({ space: 'srgb', channels: bg });
node.inputs.reference.setValue({ space: 'srgb', channels: ref });

await node.run();

const output = getAllOutputs(node);

const expAlpha = 0.5;
const expColor = { space: 'srgb', channels: fg, alpha: expAlpha };

expect(output.inRange).to.equal(true);
expect(output.color as Color).to.deep.equal(expColor);
expect(output.alpha).to.equal(expAlpha);
});

test('the hues of fg and bg are too different and no result makes sense', async () => {
const graph = new Graph();
const node = new Node({ graph });

const fg: number3 = [0.96, 0, 0];
const bg: number3 = [0, 0.2, 0];
const ref: number3 = [0.48, 0, 0];

node.inputs.foreground.setValue({ space: 'srgb', channels: fg });
node.inputs.background.setValue({ space: 'srgb', channels: bg });
node.inputs.reference.setValue({ space: 'srgb', channels: ref });

await node.run();

const output = getAllOutputs(node);

const expColor = { space: 'srgb', channels: fg, alpha: 1 };

expect(output.inRange).to.equal(false);
expect(output.color as Color).to.deep.equal(expColor);
expect(Number.isNaN(output.alpha)).toEqual(true);
});

test('bg and ref are the same (within threshold)', async () => {
const graph = new Graph();
const node = new Node({ graph });

const fg: number3 = [0.96, 0.96, 0];
const bg: number3 = [0.33, 0.33, 0];
const ref: number3 = [0.325, 0.335, 0];

node.inputs.foreground.setValue({ space: 'srgb', channels: fg });
node.inputs.background.setValue({ space: 'srgb', channels: bg });
node.inputs.reference.setValue({ space: 'srgb', channels: ref });

await node.run();

const output = getAllOutputs(node);

const expAlpha = 0;
const expColor = { space: 'srgb', channels: fg, alpha: expAlpha };

expect(output.inRange).to.equal(true);
expect(output.color as Color).to.deep.equal(expColor);
expect(output.alpha).to.equal(expAlpha);
});

test('ref is further from bg than fg, so the result is outside the valid alpha range (0-1)', async () => {
const graph = new Graph();
const node = new Node({ graph });

const fg: number3 = [0, 0.5, 0.5];
const bg: number3 = [0, 0, 0];
const ref: number3 = [0, 1, 1];

node.inputs.foreground.setValue({ space: 'srgb', channels: fg });
node.inputs.background.setValue({ space: 'srgb', channels: bg });
node.inputs.reference.setValue({ space: 'srgb', channels: ref });

await node.run();

const output = getAllOutputs(node);

const expColor = { space: 'srgb', channels: fg, alpha: 1 };

expect(output.inRange).to.equal(false);
expect(output.color as Color).to.deep.equal(expColor);
expect(Number.isNaN(output.alpha)).toEqual(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// vitest.config.ts
import { defineConfig } from "file:///C:/Users/Alex%20(Hyma)/Documents/GitHub/graph-engine/packages/graph-engine/node_modules/vite/dist/node/index.js";
import tsconfigPaths from "file:///C:/Users/Alex%20(Hyma)/Documents/GitHub/graph-engine/node_modules/vite-tsconfig-paths/dist/index.mjs";
var vitest_config_default = defineConfig({
test: {
// ... Specify options here.
},
plugins: [tsconfigPaths()]
});
export {
vitest_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZXN0LmNvbmZpZy50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIkM6XFxcXFVzZXJzXFxcXEFsZXggKEh5bWEpXFxcXERvY3VtZW50c1xcXFxHaXRIdWJcXFxcZ3JhcGgtZW5naW5lXFxcXHBhY2thZ2VzXFxcXGdyYXBoLWVuZ2luZVwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcQWxleCAoSHltYSlcXFxcRG9jdW1lbnRzXFxcXEdpdEh1YlxcXFxncmFwaC1lbmdpbmVcXFxccGFja2FnZXNcXFxcZ3JhcGgtZW5naW5lXFxcXHZpdGVzdC5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL0M6L1VzZXJzL0FsZXglMjAoSHltYSkvRG9jdW1lbnRzL0dpdEh1Yi9ncmFwaC1lbmdpbmUvcGFja2FnZXMvZ3JhcGgtZW5naW5lL3ZpdGVzdC5jb25maWcudHNcIjsvLy8gPHJlZmVyZW5jZSB0eXBlcz1cInZpdGVzdFwiIC8+XG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJztcbmltcG9ydCB0c2NvbmZpZ1BhdGhzIGZyb20gJ3ZpdGUtdHNjb25maWctcGF0aHMnO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuXHR0ZXN0OiB7XG5cdFx0Ly8gLi4uIFNwZWNpZnkgb3B0aW9ucyBoZXJlLlxuXHR9LFxuXHRwbHVnaW5zOiBbdHNjb25maWdQYXRocygpXVxufSk7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQ0EsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxtQkFBbUI7QUFFMUIsSUFBTyx3QkFBUSxhQUFhO0FBQUEsRUFDM0IsTUFBTTtBQUFBO0FBQUEsRUFFTjtBQUFBLEVBQ0EsU0FBUyxDQUFDLGNBQWMsQ0FBQztBQUMxQixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
Loading

0 comments on commit 9ff1317

Please sign in to comment.