Skip to content
This repository was archived by the owner on Jul 26, 2025. It is now read-only.

Commit 2a523c9

Browse files
committed
test: add tests for perspectiveWarp
1 parent cc4d5b6 commit 2a523c9

File tree

3 files changed

+210
-110
lines changed

3 files changed

+210
-110
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Image } from '../../Image.js';
2+
import { readSync } from '../../load/read.js';
3+
import getPerspectiveWarp from '../getPerspectiveWarp.js';
4+
5+
describe('warping legacy tests', () => {
6+
it('resize without rotation', () => {
7+
const image = new Image(3, 3, {
8+
data: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]),
9+
colorModel: 'GREY',
10+
});
11+
const points = [
12+
{ column: 0, row: 0 },
13+
{ column: 2, row: 0 },
14+
{ column: 1, row: 2 },
15+
{ column: 0, row: 2 },
16+
];
17+
const result = getPerspectiveWarp(image, points);
18+
expect(result.width).not.toBeLessThan(2);
19+
expect(result.height).not.toBeLessThan(2);
20+
expect(result.width).not.toBeGreaterThan(3);
21+
expect(result.height).not.toBeGreaterThan(3);
22+
});
23+
it('resize without rotation 2', () => {
24+
const image = new Image(4, 4, {
25+
data: new Uint8Array([
26+
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
27+
]),
28+
colorModel: 'GREY',
29+
});
30+
31+
const points = [
32+
{ column: 0, row: 0 },
33+
{ column: 3, row: 0 },
34+
{ column: 2, row: 1 },
35+
{ column: 0, row: 1 },
36+
];
37+
const result = getPerspectiveWarp(image, points);
38+
expect(result.width).not.toBeLessThan(3);
39+
expect(result.height).not.toBeLessThan(1);
40+
expect(result.width).not.toBeGreaterThan(4);
41+
expect(result.height).not.toBeGreaterThan(2);
42+
});
43+
});
44+
describe('openCV comparison', () => {
45+
it('image of plants', () => {
46+
const image = readSync('./src/geometry/__tests__/plants.png');
47+
const openCvResult = testUtils.load(
48+
'opencv/test_perspective_warp_plants.png',
49+
);
50+
const points = [
51+
{ column: 166.5, row: 195 },
52+
{ column: 858.5, row: 9 },
53+
{ column: 911.5, row: 786 },
54+
{ column: 154.5, row: 611 },
55+
];
56+
const result = getPerspectiveWarp(image, points, {
57+
width: 1080,
58+
height: 810,
59+
});
60+
expect(result.width).toEqual(openCvResult.width);
61+
expect(result.height).toEqual(openCvResult.height);
62+
expect(result.getValue(0, 0, 0)).toEqual(openCvResult.getValue(0, 0, 0));
63+
expect(result.getValue(22, 22, 0)).toEqual(
64+
openCvResult.getValue(22, 22, 0),
65+
);
66+
});
67+
});

src/geometry/getPerspectiveWarp.ts

Lines changed: 143 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,126 @@
1+
import { Matrix, inverse, SingularValueDecomposition } from 'ml-matrix';
2+
3+
import { Image } from '../Image.js';
4+
import type { Point } from '../utils/geometry/points.js';
5+
6+
type Vector = [number, number, number];
7+
interface PerspectiveWarpOptionsWithDimensions {
8+
width?: number;
9+
height?: number;
10+
}
11+
interface PerspectiveWarpOptionsWithRatios {
12+
calculateRatio?: boolean;
13+
}
14+
115
// REFERENCES :
216
// https://stackoverflow.com/questions/38285229/calculating-aspect-ratio-of-perspective-transform-destination-image/38402378#38402378
317
// http://www.corrmap.com/features/homography_transformation.php
418
// https://ags.cs.uni-kl.de/fileadmin/inf_ags/3dcv-ws11-12/3DCV_WS11-12_lec04.pdf
519
// http://graphics.cs.cmu.edu/courses/15-463/2011_fall/Lectures/morphing.pdf
620

7-
import { Matrix, inverse, SingularValueDecomposition } from 'ml-matrix';
21+
/**
22+
* Applies perspective warp on an image from 4 points.
23+
* @param image - Image to apply the algorithm on.
24+
* @param pts - 4 reference corners of the new image.
25+
* @param options - PerspectiveWarpOptions
26+
* @returns - New image after warp.
27+
*/
28+
export default function getPerspectiveWarp(
29+
image: Image,
30+
pts: Point[],
31+
options: PerspectiveWarpOptionsWithDimensions &
32+
PerspectiveWarpOptionsWithRatios = {},
33+
) {
34+
const { width, height, calculateRatio } = options;
835

9-
import { Image } from '../Image.js';
10-
import type { Point } from '../utils/geometry/points.js';
36+
if (pts.length !== 4) {
37+
throw new Error(
38+
`The array pts must have four elements, which are the four corners. Currently, pts have ${pts.length} elements`,
39+
);
40+
}
1141

12-
type Vector = [number, number, number];
42+
const [tl, tr, br, bl] = order4Points(pts);
43+
44+
let widthRect;
45+
let heightRect;
46+
if (calculateRatio) {
47+
[widthRect, heightRect] = computeWidthAndHeigth(
48+
{
49+
tl,
50+
tr,
51+
br,
52+
bl,
53+
},
54+
image.width,
55+
image.height,
56+
);
57+
} else if (height && width) {
58+
widthRect = width;
59+
heightRect = height;
60+
} else {
61+
widthRect = Math.ceil(
62+
Math.max(distance2Points(tl, tr), distance2Points(bl, br)),
63+
);
64+
heightRect = Math.ceil(
65+
Math.max(distance2Points(tl, bl), distance2Points(tr, br)),
66+
);
67+
}
68+
69+
const newImage = Image.createFrom(image, {
70+
width: widthRect,
71+
height: heightRect,
72+
});
73+
const [x1, y1] = [0, 0];
74+
const [x2, y2] = [0, widthRect - 1];
75+
const [x3, y3] = [heightRect - 1, widthRect - 1];
76+
const [x4, y4] = [heightRect - 1, 0];
77+
78+
const S = new Matrix([
79+
[x1, y1, 1, 0, 0, 0, -x1 * tl.column, -y1 * tl.column],
80+
[x2, y2, 1, 0, 0, 0, -x2 * tr.column, -y2 * tr.column],
81+
[x3, y3, 1, 0, 0, 0, -x3 * br.column, -y3 * br.column],
82+
[x4, y4, 1, 0, 0, 0, -x4 * bl.column, -y4 * bl.column],
83+
[0, 0, 0, x1, y1, 1, -x1 * tl.row, -y1 * tl.row],
84+
[0, 0, 0, x2, y2, 1, -x2 * tr.row, -y2 * tr.row],
85+
[0, 0, 0, x3, y3, 1, -x3 * br.row, -y3 * br.row],
86+
[0, 0, 0, x4, y4, 1, -x4 * bl.row, -y4 * bl.row],
87+
]);
88+
89+
const D = Matrix.columnVector([
90+
tl.column,
91+
tr.column,
92+
br.column,
93+
bl.column,
94+
tl.row,
95+
tr.row,
96+
br.row,
97+
bl.row,
98+
]);
99+
100+
const svd = new SingularValueDecomposition(S);
101+
const T = svd.solve(D); // solve S*T = D
102+
const [a, b, c, d, e, f, g, h] = T.to1DArray();
13103

104+
for (let i = 0; i < heightRect; i++) {
105+
for (let j = 0; j < widthRect; j++) {
106+
for (let channel = 0; channel < image.channels; channel++) {
107+
newImage.setValue(
108+
j,
109+
i,
110+
channel,
111+
projectionPoint(i, j, a, b, c, d, e, f, g, h, image, channel),
112+
);
113+
}
114+
}
115+
}
116+
117+
return newImage;
118+
}
119+
/**
120+
* Sorts 4 points in order =>[top-left,top-right,bottom-right,bottom-left].
121+
* @param pts - Array of 4 points.
122+
* @returns Sorted array of 4 points.
123+
*/
14124
function order4Points(pts: Point[]) {
15125
let tl: Point;
16126
let tr: Point;
@@ -61,11 +171,21 @@ function order4Points(pts: Point[]) {
61171

62172
return [tl, tr, br, bl];
63173
}
64-
174+
/**
175+
* Calculates distance between points.
176+
* @param p1 - Point1
177+
* @param p2 - Point2
178+
* @returns distance between points.
179+
*/
65180
function distance2Points(p1: Point, p2: Point) {
66181
return Math.hypot(p1.column - p2.column, p1.row - p2.row);
67182
}
68-
183+
/**
184+
* Calculates cross products between two vectors.
185+
* @param u - Vector1.
186+
* @param v - Vector2.
187+
* @returns new calculated vector.
188+
*/
69189
function crossVect(u: Vector, v: Vector): Vector {
70190
const result = [
71191
u[1] * v[2] - u[2] * v[1],
@@ -74,11 +194,27 @@ function crossVect(u: Vector, v: Vector): Vector {
74194
];
75195
return result as Vector;
76196
}
77-
197+
/**
198+
* Calculates dot product between two vectors.
199+
* @param u - Vector1.
200+
* @param v - Vector2.
201+
* @returns result of the product.
202+
*/
78203
function dotVect(u: Vector, v: Vector): number {
79204
const result = u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
80205
return result;
81206
}
207+
/**
208+
* Calculates width and height of the new image for perspective warp.
209+
* @param points - 4 reference corners.
210+
* @param points.tl - top-left corner.
211+
* @param points.tr - top-right corner.
212+
* @param points.br - bottom-right corner.
213+
* @param points.bl - bottom-left corner.
214+
* @param widthImage - image width.
215+
* @param heightImage - image height.
216+
* @returns new width and height values.
217+
*/
82218
function computeWidthAndHeigth(
83219
points: { tl: Point; tr: Point; br: Point; bl: Point },
84220
widthImage: number,
@@ -182,106 +318,3 @@ function projectionPoint(
182318
];
183319
return image.getValue(Math.floor(newX), Math.floor(newY), channel);
184320
}
185-
186-
/**
187-
* Transform a quadrilateral into a rectangle
188-
* @memberof Image
189-
* @instance
190-
* @param image
191-
* @param [pts] - Array of the four corners.
192-
* @param [options]
193-
* @param [options.calculateRatio=true] - true if you want to calculate the aspect ratio "width x height" by taking the perspectiv into consideration.
194-
* @returns The new image, which is a rectangle
195-
* @example
196-
* var cropped = image.warpingFourPoints({
197-
* pts: [[0,0], [100, 0], [80, 50], [10, 50]]
198-
* });
199-
*/
200-
201-
export default function getPerspectiveWarp(
202-
image: Image,
203-
pts: Point[],
204-
options: { calculateRatio?: boolean } = {},
205-
) {
206-
const { calculateRatio = true } = options;
207-
208-
if (pts.length !== 4) {
209-
throw new Error(
210-
`The array pts must have four elements, which are the four corners. Currently, pts have ${pts.length} elements`,
211-
);
212-
}
213-
214-
const [tl, tr, br, bl] = order4Points(pts);
215-
216-
let widthRect;
217-
let heightRect;
218-
if (calculateRatio) {
219-
[widthRect, heightRect] = computeWidthAndHeigth(
220-
{
221-
tl,
222-
tr,
223-
br,
224-
bl,
225-
},
226-
image.width,
227-
image.height,
228-
);
229-
} else {
230-
widthRect = Math.ceil(
231-
Math.max(distance2Points(tl, tr), distance2Points(bl, br)),
232-
);
233-
heightRect = Math.ceil(
234-
Math.max(distance2Points(tl, bl), distance2Points(tr, br)),
235-
);
236-
}
237-
238-
const newImage = Image.createFrom(image, {
239-
width: widthRect,
240-
height: heightRect,
241-
});
242-
const [x1, y1] = [0, 0];
243-
const [x2, y2] = [0, widthRect - 1];
244-
const [x3, y3] = [heightRect - 1, widthRect - 1];
245-
const [x4, y4] = [heightRect - 1, 0];
246-
247-
const S = new Matrix([
248-
[x1, y1, 1, 0, 0, 0, -x1 * tl.column, -y1 * tl.column],
249-
[x2, y2, 1, 0, 0, 0, -x2 * tr.column, -y2 * tr.column],
250-
[x3, y3, 1, 0, 0, 0, -x3 * br.column, -y3 * br.column],
251-
[x4, y4, 1, 0, 0, 0, -x4 * bl.column, -y4 * bl.column],
252-
[0, 0, 0, x1, y1, 1, -x1 * tl.row, -y1 * tl.row],
253-
[0, 0, 0, x2, y2, 1, -x2 * tr.row, -y2 * tr.row],
254-
[0, 0, 0, x3, y3, 1, -x3 * br.row, -y3 * br.row],
255-
[0, 0, 0, x4, y4, 1, -x4 * bl.row, -y4 * bl.row],
256-
]);
257-
258-
const D = Matrix.columnVector([
259-
tl.column,
260-
tr.column,
261-
br.column,
262-
bl.column,
263-
tl.row,
264-
tr.row,
265-
br.row,
266-
bl.row,
267-
]);
268-
269-
const svd = new SingularValueDecomposition(S);
270-
const T = svd.solve(D); // solve S*T = D
271-
const [a, b, c, d, e, f, g, h] = T.to1DArray();
272-
273-
for (let i = 0; i < heightRect; i++) {
274-
for (let j = 0; j < widthRect; j++) {
275-
for (let channel = 0; channel < image.channels; channel++) {
276-
newImage.setValue(
277-
j,
278-
i,
279-
channel,
280-
projectionPoint(i, j, a, b, c, d, e, f, g, h, image, channel),
281-
);
282-
}
283-
}
284-
}
285-
286-
return newImage;
287-
}
1.92 MB
Loading

0 commit comments

Comments
 (0)