mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 22:21:29 +02:00
Rank near-colour matches by how they look, and share one filament type table (#2804)
Three follow-ups to #2804, all bearing on one decision: which spool a print uses when the exact colour is not loaded. Colour ranking is now perceptual. The ranking added in #2804 measured RGB distance, which rates a colour by how far apart the numbers are rather than how far apart they look, and it overweights blue badly enough to invert the answer: against a required #1E4821 green, a purple #38202F is the nearer of two eligible spools by RGB and four times the further once measured properly. Both sides now use CIEDE2000 -- perceptual_color_distance in backend/app/utils/color_utils.py and colorDistance in amsHelpers.ts, kept structurally identical so they can be read side by side. Verified against the Sharma/Wu/Dalal published reference set, all 31 pairs to 1e-4, and the two implementations agree to within 1e-9 across 800 sampled pairs. Eligibility is untouched, still the per-channel RGB box, so this only reorders spools that already qualified. Type matching now agrees between the interface and the scheduler. Bambu firmware treats PA-CF, PA12-CF and PAHT-CF as one material and the scheduler has always matched them accordingly, but the interface compared raw type strings and called that same pairing a mismatch. The badge contradicted what the printer was about to do, and the manual override picker, which groups by canonical type, offered the very spool the badge then rejected. The fifteen comparison sites in useFilamentMapping.ts, useMultiPrinterFilamentMapping.ts and PrinterSelector.tsx now call filamentTypesCompatible. The pipeline pre-flight reads the matcher's table instead of its own copy. That copy had drifted into disagreeing in both directions: it aliased PLA Basic to PLA where the matcher never has, so a run could clear the check and then fail to map its slots, and it lacked the nylon grouping, so it flagged runs the matcher handles without complaint. A check whose job is to predict dispatch is wrong whenever it disagrees with dispatch, whichever way it leans, so it and the scheduler now both read backend/app/utils/filament_types.py. That canonicaliser deliberately does not strip surrounding whitespace. It looks like a free improvement, but it would collapse a junk tray_type to "" just as a 3MF declaring no filament type yields "", and a typeless requirement would start matching a junk-typed tray instead of reporting the slot unmapped. Padded type strings are worth handling on their own terms, with that case addressed. One behaviour change outside the ranking: the pre-flight is stricter for a printer reporting a product name such as "PLA Basic" where the generic material belongs, which it now flags rather than passes. Rare in practice, since the printer reports material and product name in separate fields, and it is the answer the matcher would give. Nothing about which spool a print actually uses changed outside the colour ranking itself. Adds 203 backend and 6 frontend tests. The #2804 tie-break test now uses identical colours: two colours at equal RGB distance are not perceptually tied, which is rather the point.
This commit is contained in:
@@ -1397,3 +1397,51 @@ describe('colour verdict is independent of how the tray was found (#2687)', () =
|
||||
expect(item.colorMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filament type equivalence groups reach the matcher', () => {
|
||||
// The scheduler has always canonicalised types before comparing, so PA12-CF
|
||||
// satisfies a PA-CF requirement at dispatch. The interface compared the raw
|
||||
// strings, so it called the same pairing a type mismatch — the badge
|
||||
// contradicted what the printer would actually do, and the manual override
|
||||
// picker (which groups by canonical type) offered the very spool the badge
|
||||
// then rejected. Both sides now use `filamentTypesCompatible`.
|
||||
const wantPaCf = {
|
||||
filaments: [{ slot_id: 1, type: 'PA-CF', color: '#1A1A1A', used_grams: 20 }],
|
||||
};
|
||||
const pa12Loaded = createPrinterStatus([
|
||||
{ id: 0, tray: [{ id: 0, tray_type: 'PA12-CF', tray_color: '1A1A1AFF' }] },
|
||||
]);
|
||||
|
||||
it('matches PA12-CF against a PA-CF requirement', () => {
|
||||
const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {});
|
||||
expect(item.loaded?.globalTrayId).toBe(0);
|
||||
expect(item.status).toBe('match');
|
||||
});
|
||||
|
||||
it('agrees with the mapping the request is built from', () => {
|
||||
expect(
|
||||
buildAmsMapping(buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {})),
|
||||
).toEqual([0]);
|
||||
});
|
||||
|
||||
it('still refuses a genuinely different material', () => {
|
||||
const petg = createPrinterStatus([
|
||||
{ id: 0, tray: [{ id: 0, tray_type: 'PETG', tray_color: '1A1A1AFF' }] },
|
||||
]);
|
||||
const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(petg), {});
|
||||
expect(item.status).toBe('mismatch');
|
||||
});
|
||||
|
||||
it('does not treat a product variant as an alias for the base material', () => {
|
||||
// "PLA Silk" dries like PLA but does not print like it, so the matcher
|
||||
// must not substitute one for the other. Matches the backend's rule.
|
||||
const silk = createPrinterStatus([
|
||||
{ id: 0, tray: [{ id: 0, tray_type: 'PLA Silk', tray_color: 'FFFFFFFF' }] },
|
||||
]);
|
||||
const wantPla = {
|
||||
filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 20 }],
|
||||
};
|
||||
const [item] = buildFilamentComparison(wantPla, buildLoadedFilaments(silk), {});
|
||||
expect(item.status).toBe('mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
* dialog cannot promise a spool the backend would not have picked.
|
||||
*
|
||||
* The worked example is the maintainer's: required `#3A7BD5`, a purple
|
||||
* `#6253AD` in tray 1 (40/40/40 off — admitted, distance ~69) and a
|
||||
* near-identical `#3B7AD2` in tray 3 (distance ~3).
|
||||
* `#6253AD` in tray 1 (40/40/40 off — admitted) and a near-identical `#3B7AD2`
|
||||
* in tray 3.
|
||||
*
|
||||
* Ranking is by CIEDE2000 delta-E, matching the backend's
|
||||
* `perceptual_color_distance`, so those two are ~18.5 and ~0.5 apart rather
|
||||
* than the ~69 and ~3 an RGB metric reported. Eligibility is still the
|
||||
* per-channel RGB box, unchanged — only the ordering within it is perceptual.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -31,14 +36,31 @@ describe('colorDistance', () => {
|
||||
expect(colorDistance('#76D9F4', '76D9F400')).toBe(0);
|
||||
});
|
||||
|
||||
it('is euclidean rather than per-channel', () => {
|
||||
expect(colorDistance('#000000', '#282828')).toBeCloseTo(69.28, 1);
|
||||
it('is perceptual rather than per-channel', () => {
|
||||
// CIEDE2000 delta-E, where ~1 is a just-noticeable difference. The backend
|
||||
// must give the same answer for the same pair.
|
||||
expect(colorDistance('#000000', '#282828')).toBeCloseTo(9.91, 2);
|
||||
expect(colorDistance(NEAR, REQUIRED)).toBeCloseTo(0.4797, 3);
|
||||
expect(colorDistance(FAR_BUT_ADMITTED, REQUIRED)).toBeCloseTo(18.4853, 3);
|
||||
});
|
||||
|
||||
it('ranks a perceptually nearer colour above a numerically nearer one', () => {
|
||||
// Against a green requirement, a purple is closer by RGB distance
|
||||
// (49.7 vs 56.9) and much further perceptually. Both are inside the
|
||||
// tolerance, so the ranking alone decides which one prints.
|
||||
const green = '#1E4821';
|
||||
expect(colorDistance('#43683E', green)!).toBeLessThan(colorDistance('#38202F', green)!);
|
||||
});
|
||||
|
||||
it('is symmetric, so which spool is listed first cannot change the answer', () => {
|
||||
expect(colorDistance(NEAR, REQUIRED)).toBeCloseTo(colorDistance(REQUIRED, NEAR)!, 12);
|
||||
});
|
||||
|
||||
it('returns null for unusable input rather than a number', () => {
|
||||
expect(colorDistance(undefined, REQUIRED)).toBeNull();
|
||||
expect(colorDistance('', REQUIRED)).toBeNull();
|
||||
expect(colorDistance('#abc', REQUIRED)).toBeNull();
|
||||
expect(colorDistance('#zzzzzz', REQUIRED)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,9 +75,11 @@ describe('findNearestSimilar', () => {
|
||||
});
|
||||
|
||||
it('keeps the incoming order on a tie, so prefer-lowest still decides', () => {
|
||||
// Both 5 away — whichever the caller put first wins.
|
||||
expect(pick([tray(2, '3A7BD0FF'), tray(7, '3A7BDAFF')])).toBe(2);
|
||||
expect(pick([tray(7, '3A7BDAFF'), tray(2, '3A7BD0FF')])).toBe(7);
|
||||
// Same colour in both trays, so the tie is exact. Two *different* colours
|
||||
// at equal RGB distance are not perceptually tied — that is the whole point
|
||||
// of the metric — so they cannot be used to test this any more.
|
||||
expect(pick([tray(2, NEAR), tray(7, NEAR)])).toBe(2);
|
||||
expect(pick([tray(7, NEAR), tray(2, NEAR)])).toBe(7);
|
||||
});
|
||||
|
||||
it('admits exactly what the tolerance admitted before', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isGcodeCompatible } from '../../utils/printer';
|
||||
import {
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
filamentTypesCompatible,
|
||||
autoMatchFilament,
|
||||
filterFilamentsByNozzle,
|
||||
effectivePreferLowest,
|
||||
@@ -122,7 +123,7 @@ function InlineMappingEditor({
|
||||
// Determine status
|
||||
let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
|
||||
if (loaded) {
|
||||
const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const typeMatch = filamentTypesCompatible(loaded.type, req.type);
|
||||
const colorMatch =
|
||||
normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
|
||||
colorsAreSimilar(loaded.color, req.color);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeColor,
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
filamentTypesCompatible,
|
||||
findNearestSimilar,
|
||||
formatSlotLabel,
|
||||
getGlobalTrayId,
|
||||
@@ -257,7 +258,7 @@ export function buildFilamentComparison(
|
||||
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
|
||||
|
||||
if (manualLoaded) {
|
||||
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const typeMatch = filamentTypesCompatible(manualLoaded.type, req.type);
|
||||
const colorMatch = coloursMatch(manualLoaded.color, req.color);
|
||||
|
||||
let status: FilamentStatus;
|
||||
@@ -330,19 +331,19 @@ export function buildFilamentComparison(
|
||||
}
|
||||
exactMatch = idxMatches.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
filamentTypesCompatible(f.type, req.type) &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
if (!exactMatch) {
|
||||
similarMatch = findNearestSimilar(
|
||||
idxMatches.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
|
||||
idxMatches.filter((f) => filamentTypesCompatible(f.type, req.type)),
|
||||
req.color,
|
||||
(f) => f.color,
|
||||
);
|
||||
}
|
||||
if (!exactMatch && !similarMatch) {
|
||||
typeOnlyMatch = idxMatches.find(
|
||||
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
(f) => filamentTypesCompatible(f.type, req.type)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -352,19 +353,19 @@ export function buildFilamentComparison(
|
||||
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
|
||||
exactMatch = available.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
filamentTypesCompatible(f.type, req.type) &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
if (!exactMatch) {
|
||||
similarMatch = findNearestSimilar(
|
||||
available.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
|
||||
available.filter((f) => filamentTypesCompatible(f.type, req.type)),
|
||||
req.color,
|
||||
(f) => f.color,
|
||||
);
|
||||
}
|
||||
if (!exactMatch && !similarMatch) {
|
||||
typeOnlyMatch = available.find(
|
||||
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
(f) => filamentTypesCompatible(f.type, req.type)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
filamentTypesCompatible,
|
||||
findNearestSimilar,
|
||||
preferLowestSortKey,
|
||||
compareSortKeys,
|
||||
@@ -116,7 +117,7 @@ function computeMatchDetails(
|
||||
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
|
||||
|
||||
if (manualLoaded) {
|
||||
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const typeMatch = filamentTypesCompatible(manualLoaded.type, req.type);
|
||||
const colorMatch =
|
||||
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
|
||||
colorsAreSimilar(manualLoaded.color, req.color);
|
||||
@@ -152,13 +153,13 @@ function computeMatchDetails(
|
||||
|
||||
const exactMatch = candidates.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
filamentTypesCompatible(f.type, req.type) &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
const similarMatch = exactMatch
|
||||
? undefined
|
||||
: findNearestSimilar(
|
||||
candidates.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
|
||||
candidates.filter((f) => filamentTypesCompatible(f.type, req.type)),
|
||||
req.color,
|
||||
(f) => f.color,
|
||||
);
|
||||
@@ -166,7 +167,7 @@ function computeMatchDetails(
|
||||
exactMatch || similarMatch
|
||||
? undefined
|
||||
: candidates.find(
|
||||
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
(f) => filamentTypesCompatible(f.type, req.type)
|
||||
);
|
||||
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
|
||||
|
||||
@@ -241,13 +242,13 @@ function computeMappingWithOverrides(
|
||||
|
||||
const exactMatch = candidates.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
filamentTypesCompatible(f.type, req.type) &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
const similarMatch = exactMatch
|
||||
? undefined
|
||||
: findNearestSimilar(
|
||||
candidates.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
|
||||
candidates.filter((f) => filamentTypesCompatible(f.type, req.type)),
|
||||
req.color,
|
||||
(f) => f.color,
|
||||
);
|
||||
@@ -255,7 +256,7 @@ function computeMappingWithOverrides(
|
||||
exactMatch || similarMatch
|
||||
? undefined
|
||||
: candidates.find(
|
||||
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
(f) => filamentTypesCompatible(f.type, req.type)
|
||||
);
|
||||
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
|
||||
|
||||
|
||||
@@ -116,31 +116,135 @@ export function colorsAreSimilar(
|
||||
);
|
||||
}
|
||||
|
||||
const D65_WHITE: readonly [number, number, number] = [0.95047, 1.0, 1.08883];
|
||||
const LAB_DELTA = 6 / 29;
|
||||
|
||||
/**
|
||||
* Euclidean RGB distance between two hex colours, or null if either is unusable.
|
||||
*
|
||||
* Used to rank the candidates `colorsAreSimilar` admits. Eligibility stays the
|
||||
* per-channel box that shipped; this only decides which of several eligible
|
||||
* spools is closest, so no spool becomes usable or unusable because of it.
|
||||
* Convert a hex colour to CIE L*a*b* under D65, or null if it is unusable.
|
||||
*
|
||||
* Alpha is dropped by `normalizeColorForCompare`, deliberately: the alpha a
|
||||
* slicer writes for a transparent filament is not a colour the user chose, and
|
||||
* counting it would stop a transparent filament matching itself.
|
||||
*/
|
||||
function hexToLab(color: string | undefined): [number, number, number] | null {
|
||||
const hex = normalizeColorForCompare(color);
|
||||
if (!hex || hex.length < 6) return null;
|
||||
|
||||
const channels = [0, 2, 4].map((i) => parseInt(hex.substring(i, i + 2), 16) / 255);
|
||||
if (channels.some(Number.isNaN)) return null;
|
||||
|
||||
// sRGB gamma -> linear light.
|
||||
const [r, g, b] = channels.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
|
||||
const xyz: [number, number, number] = [
|
||||
0.4124564 * r + 0.3575761 * g + 0.1804375 * b,
|
||||
0.2126729 * r + 0.7151522 * g + 0.072175 * b,
|
||||
0.0193339 * r + 0.119192 * g + 0.9503041 * b,
|
||||
];
|
||||
|
||||
const f = (t: number) =>
|
||||
t > LAB_DELTA ** 3 ? Math.cbrt(t) : t / (3 * LAB_DELTA * LAB_DELTA) + 4 / 29;
|
||||
const [fx, fy, fz] = xyz.map((v, i) => f(v / D65_WHITE[i]));
|
||||
|
||||
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
||||
}
|
||||
|
||||
/**
|
||||
* CIEDE2000 colour difference between two L*a*b* triples.
|
||||
*
|
||||
* Straight transcription of the CIE formulation with kL = kC = kH = 1, kept
|
||||
* structurally identical to `perceptual_color_distance` in
|
||||
* `backend/app/utils/color_utils.py` so the two can be read side by side. They
|
||||
* must agree: the dialog must not promise a spool the scheduler would not pick.
|
||||
*/
|
||||
function ciede2000(lab1: [number, number, number], lab2: [number, number, number]): number {
|
||||
const [l1, a1, b1] = lab1;
|
||||
const [l2, a2, b2] = lab2;
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
|
||||
const c1 = Math.hypot(a1, b1);
|
||||
const c2 = Math.hypot(a2, b2);
|
||||
const cBar7 = ((c1 + c2) / 2) ** 7;
|
||||
const g = 0.5 * (1 - Math.sqrt(cBar7 / (cBar7 + 25 ** 7)));
|
||||
|
||||
const a1p = (1 + g) * a1;
|
||||
const a2p = (1 + g) * a2;
|
||||
const c1p = Math.hypot(a1p, b1);
|
||||
const c2p = Math.hypot(a2p, b2);
|
||||
|
||||
const hue = (ap: number, bp: number) => {
|
||||
if (ap === 0 && bp === 0) return 0;
|
||||
const deg = (Math.atan2(bp, ap) * 180) / Math.PI;
|
||||
return deg < 0 ? deg + 360 : deg;
|
||||
};
|
||||
const h1p = hue(a1p, b1);
|
||||
const h2p = hue(a2p, b2);
|
||||
|
||||
const dlp = l2 - l1;
|
||||
const dcp = c2p - c1p;
|
||||
|
||||
const chromaProduct = c1p * c2p;
|
||||
let dhp = 0;
|
||||
if (chromaProduct !== 0) {
|
||||
dhp = h2p - h1p;
|
||||
if (dhp > 180) dhp -= 360;
|
||||
else if (dhp < -180) dhp += 360;
|
||||
}
|
||||
const dhpBig = 2 * Math.sqrt(chromaProduct) * Math.sin(rad(dhp) / 2);
|
||||
|
||||
const lBar = (l1 + l2) / 2;
|
||||
const cBar = (c1p + c2p) / 2;
|
||||
|
||||
let hBar: number;
|
||||
if (chromaProduct === 0) hBar = h1p + h2p;
|
||||
else if (Math.abs(h1p - h2p) <= 180) hBar = (h1p + h2p) / 2;
|
||||
else if (h1p + h2p < 360) hBar = (h1p + h2p + 360) / 2;
|
||||
else hBar = (h1p + h2p - 360) / 2;
|
||||
|
||||
const t =
|
||||
1 -
|
||||
0.17 * Math.cos(rad(hBar - 30)) +
|
||||
0.24 * Math.cos(rad(2 * hBar)) +
|
||||
0.32 * Math.cos(rad(3 * hBar + 6)) -
|
||||
0.2 * Math.cos(rad(4 * hBar - 63));
|
||||
|
||||
const cBarP7 = cBar ** 7;
|
||||
const rc = 2 * Math.sqrt(cBarP7 / (cBarP7 + 25 ** 7));
|
||||
const sl = 1 + (0.015 * (lBar - 50) ** 2) / Math.sqrt(20 + (lBar - 50) ** 2);
|
||||
const sc = 1 + 0.045 * cBar;
|
||||
const sh = 1 + 0.015 * cBar * t;
|
||||
const rt = -Math.sin(rad(2 * (30 * Math.exp(-(((hBar - 275) / 25) ** 2))))) * rc;
|
||||
|
||||
const dL = dlp / sl;
|
||||
const dC = dcp / sc;
|
||||
const dH = dhpBig / sh;
|
||||
return Math.sqrt(dL * dL + dC * dC + dH * dH + rt * dC * dH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perceptual distance between two hex colours, or null if either is unusable.
|
||||
*
|
||||
* Used to rank the candidates `colorsAreSimilar` admits. Eligibility stays the
|
||||
* per-channel box that shipped; this only decides which of several eligible
|
||||
* spools is closest, so no spool becomes usable or unusable because of it.
|
||||
*
|
||||
* It ranks by how far apart the colours *look*, not how far apart their numbers
|
||||
* are. RGB distance overweights blue badly enough to invert the answer: against
|
||||
* a required `#1E4821` green, a purple `#38202F` is the nearer of two eligible
|
||||
* spools by RGB and four times the further once measured perceptually.
|
||||
*
|
||||
* The scale is CIEDE2000 delta-E, where ~1 is a just-noticeable difference —
|
||||
* far smaller numbers than the RGB distances this replaced, and not comparable
|
||||
* against an RGB threshold.
|
||||
*/
|
||||
export function colorDistance(
|
||||
color1: string | undefined,
|
||||
color2: string | undefined,
|
||||
): number | null {
|
||||
const hex1 = normalizeColorForCompare(color1);
|
||||
const hex2 = normalizeColorForCompare(color2);
|
||||
if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return null;
|
||||
|
||||
const dr = parseInt(hex1.substring(0, 2), 16) - parseInt(hex2.substring(0, 2), 16);
|
||||
const dg = parseInt(hex1.substring(2, 4), 16) - parseInt(hex2.substring(2, 4), 16);
|
||||
const db = parseInt(hex1.substring(4, 6), 16) - parseInt(hex2.substring(4, 6), 16);
|
||||
if (Number.isNaN(dr) || Number.isNaN(dg) || Number.isNaN(db)) return null;
|
||||
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
const lab1 = hexToLab(color1);
|
||||
const lab2 = hexToLab(color2);
|
||||
if (!lab1 || !lab2) return null;
|
||||
return ciede2000(lab1, lab2);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user