Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,258 @@ export function isInstance (arg, constructor) {

return false;
}

/**
* Generate a matrix of size NxN with the given diagonal values of length N.
*
* @param {number[]} values
* @returns {number[][]}
*/
export function diag (values) {
const n = values.length;
const matrix = [];
for (let i = 0; i < n; i++) {
matrix[i] = [];
for (let j = 0; j < n; j++) {
matrix[i][j] = i === j ? values[i] : 0;
}
}
return matrix;
}

/**
* Calculate the LU decomposition of an NxN matrix.
*
* P is returned as PA = UL or A = P'UL which follows Matlab and Octave opposed to Scipy which returns P as
* A = PUL or P'A = UL. For matrix inverse, we need P such that PA = UL and it is faster not having to invert
* P, even if we can invert it fairly fast as it is just a shuffled identity matrix.
*
* P is returned as a permutation matrix unless pIndices is true, in which case P would be returned as
* a vector containing the indexes such that A[P,:] = L*U.
*
* Reference:
* - https://www.statlect.com/matrix-algebra/Gaussian-elimination
* - https://www.sciencedirect.com/topics/mathematics/partial-pivoting
*
* @overload
* @param {number[][]} matrix
* @param {{ pIndices?: false | undefined }} [options]
* @returns {[number[][], number[][], number[][]]}
*/
/**
* @overload
* @param {number[][]} matrix
* @param {{ pIndices?: true }} [options]
* @returns {[number[], number[][], number[][]]}
*/
/**
* @param {number[][]} matrix
* @param {{ pIndices?: boolean | undefined }} [options]
* @returns {[number[] | number[][], number[][], number[][]]}
*/
export function lu (matrix, { pIndices = false } = {}) {
let p1, p2, l, u;

const n = matrix.length;

// Initialize the triangle matrices along with the permutation matrix.
if (pIndices) {
p1 = Array.from({ length: n }, (_, index) => index);
l = diag(new Array(n).fill(1));
}
else {
p2 = diag(new Array(n).fill(1));
l = structuredClone(p2);
}
u = structuredClone(matrix);

// Create upper and lower triangle in 'u' and 'l'. 'p' tracks the permutation (relative position of rows)
for (let i = 0; i < n - 1; i++) {
// Partial pivoting: identify the row with the maximal value in the column
let j = i;
let maximum = Math.abs(u[i][i]);
for (let k = i + 1; k < n; k++) {
const a = Math.abs(u[k][i]);
if (a > maximum) {
j = k;
maximum = a;
}
}

// Partial pivoting: Swap rows
if (j != i) {
// Exchange current upper triangle row with row with maximal value at pivot
// Update permutation matrix as well
[u[i], u[j]] = [u[j], u[i]];
if (pIndices) {
[p1[i], p1[j]] = [p1[j], p1[i]];
}
else {
[p2[i], p2[j]] = [p2[j], p2[i]];
}

// Only swap columns up to the pivot for the lower triangle,
// if on first row, there is nothing to swap
if (i) {
for (let k = 0; k < i; k++) {
[l[i][k], l[j][k]] = [l[j][k], l[i][k]];
}
}
}

// Zero at pivot point, nothing to do
else if (!maximum) {
continue;
}

// We have a pivot point, let's zero out everything above and below
// the 'l' and 'u' diagonal respectively
for (let j = i + 1; j < n; j++) {
const scalar = u[j][i] / u[i][i];
for (let k = i; k < n; k++) {
u[j][k] += -u[i][k] * scalar;
l[j][k] += l[i][k] * scalar;
}
}
}

if (pIndices) {
return [p1, l, u];
}
return [p2, l, u];
}

/**
* Forward substitution for solution of ax = b where a and b are matricies.
*
* @param {number[][]} a
* @param {number[][]} b
* @param {number} n
* @returns {number[][]}
*/
function forwardSubMatrix (a, b, n) {
for (let i = 0; i < n; i++) {
const v = b[i];
for (let j = 0; j < i; j++) {
for (let k = 0; k < n; k++) {
v[k] -= a[i][j] * b[j][k];
}
}
for (let j = 0; j < n; j++) {
v[j] /= a[i][i];
}
}
return b;
}

/**
* Back substitution for solution of ax = b where a and b are matricies.
*
* @param {number[][]} a
* @param {number[][]} b
* @param {number} n
* @returns {number[][]}
*/
function backSubMatrix (a, b, n) {
for (let i = n - 1; i > -1; i--) {
const v = b[i];
for (let j = i + 1; j < n; j++) {
for (var k = 0; k < n; k++) {
v[k] -= a[i][j] * b[j][k];
}
}
for (let j = 0; j < n; j++) {
b[i][j] /= a[i][i];
}
}
return b;
}

/**
* Forward substitution for solution of ax = b where a is a matrix and b is a vector.
*
* @param {number[][]} a
* @param {number[]} b
* @param {number} n
* @returns {number[]}
*/
function forwardSubVector (a, b, n) {
for (let i = 0; i < n; i++) {
let v = b[i];
for (let j = 0; j < i; j++) {
v -= a[i][j] * b[j];
}
b[i] = v / a[i][i];
}
return b;
}

/**
* Back substitution for solution of ax = b where a is a matrix and b is a vector.
*
* @param {number[][]} a
* @param {number[]} b
* @param {number} n
* @returns {number[]}
*/
function backSubVector (a, b, n) {
for (let i = n - 1; i > -1; i--) {
let v = b[i];
for (let j = i + 1; j < n; j++) {
v -= a[i][j] * b[j];
}
b[i] = v / a[i][i];
}
return b;
}

/**
* Invert a NxN matrix.
*
* @param {number[][]} matrix
* @returns {number[][]}
*/
export function inv (matrix) {
// Calculate the LU decomposition.
const [p, l, u] = lu(matrix);
const n = l.length;

// Floating point math can produce very small, non-zero determinants for singular matrices.
// This seems to happen in Numpy as well.
// Don't bother calculating sign as we only care about how close to zero we are.
if (l.map((row, i) => row[i] * u[i][i]).reduce((acc, val) => acc * val, 1) === 0.0) {
throw new Error("Matrix is singular");
}

// Solve for the identity matrix (will give us inverse)
// Permutation matrix is the identity matrix, even if shuffled.
return backSubMatrix(u, forwardSubMatrix(l, p, n), n);
}

/**
* Solve a NxN matrix representing a system of equations.
*
* @param {number[][]} matrix
* @param {number[]} vector
* @returns {number[]}
*/
export function solve (matrix, vector) {
// Calculate the LU decomposition.
const [p, l, u] = lu(matrix, { pIndices: true });
const n = l.length;

// If determinant is zero, we can't solve.
if (l.map((row, i) => row[i] * u[i][i]).reduce((acc, val) => acc * val, 1) === 0.0) {
throw new Error("Matrix is singular");
}

return backSubVector(
u,
forwardSubVector(
l,
p.map(i => vector[i]),
n,
),
n,
);
}
85 changes: 85 additions & 0 deletions test/algebra.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as math from "mathjs"; // Used as test oracle
import { solve, inv } from "../src/util.js";
import * as check from "../node_modules/htest.dev/src/check.js";

// Used to collect expected results from oracle
function refInv (A) {
return math.inv(A).valueOf();
}

function refSolve (A, B) {
return math.lusolve(A, B).valueOf();
}

function testInvExpected (testObj) {
return { ...testObj, expect: refInv(...testObj.args) };
}

function testSolveExpected (testObj) {
return { ...testObj, expect: refSolve(...testObj.args).map(x => x[0]) };
}

const M_lin_sRGB_to_XYZ = [
[0.4123907992659593, 0.357584339383878, 0.1804807884018343],
[0.21263900587151024, 0.715168678767756, 0.07219231536073371],
[0.01933081871559182, 0.11919477979462598, 0.9505321522496607],
];

export default {
name: "Linear Algebra Tests",
check: check.deep(check.proximity({ epsilon: 1e-14 })),
tests: [
{
name: "Invert matrices",
tests: [
{
name: "Matrix inverse 3x3",
run: inv,
args: [M_lin_sRGB_to_XYZ],
},
{
name: "Matrix inverse 2x2",
run: inv,
args: [
[
[1, 2],
[0, 4],
],
],
},
].map(testInvExpected),
},
{
name: "Solve system of linear equations",
tests: [
{
name: "Matrix solve RGB value given an XYZ (white)",
run: solve,
args: [
M_lin_sRGB_to_XYZ,
[0.9504559270516715, 0.9999999999999999, 1.0890577507598784],
],
},
{
name: "Matrix solve RGB value given an XYZ (red)",
run: solve,
args: [
M_lin_sRGB_to_XYZ,
[0.4123907992659593, 0.21263900587151024, 0.01933081871559182],
],
},
{
name: "Solve 2D",
run: solve,
args: [
[
[1, 2],
[0, 4],
],
[-2, 3],
],
},
].map(testSolveExpected),
},
],
};
1 change: 1 addition & 0 deletions test/index-fn.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ let tests = await Promise.all(
"parse",
"contrast",
"multiply_matrices",
"algebra"
].map(name => import(`./${name}.js`).then(module => module.default)),
);

Expand Down
1 change: 1 addition & 0 deletions test/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"angles": "Angles",
"adapt": "Chromatic adaptation",
"multiply_matrices": "Matrix multiplication",
"algebra": "Linear algebra",
"hooks": "Hooks"
}
Loading