Skip to content

Commit 1a96bf3

Browse files
feat: implement perspective warp (#484)
- `transform()` now accepts 3x3 as well as 2x3 transformation matrices.
1 parent 798d40d commit 1a96bf3

20 files changed

+547
-23
lines changed

demo/components/Navbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import clsx from 'clsx';
1+
import { clsx } from 'clsx';
22
import { Link, useLocation } from 'react-router-dom';
33

44
const navigation = [

demo/components/testFunctions/testCorrectColor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { polishAltered } from '../../../src/correctColor/__tests__/testUtils/imageColors.js';
2-
import { referenceColorCard } from '../../../src/correctColor/__tests__/testUtils/referenceColorCard.js';
2+
import { referenceColorCard } from '../../../src/correctColor/utils/referenceColorCard.ts';
33
import { correctColor } from '../../../src/correctColor/correctColor.js';
44
import {
55
getMeasuredColors,

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"robust-point-in-polygon": "^1.0.3",
4848
"ssim.js": "^3.5.0",
4949
"tiff": "^7.0.0",
50-
"ts-pattern": "^5.7.1"
50+
"ts-pattern": "^5.7.1",
51+
"uint8-base64": "^1.0.0"
5152
},
5253
"devDependencies": {
5354
"@microsoft/api-extractor": "^7.52.8",
@@ -80,7 +81,6 @@
8081
"rimraf": "^6.0.1",
8182
"tailwindcss": "^4.1.10",
8283
"typescript": "~5.8.3",
83-
"uint8-base64": "^1.0.0",
8484
"vite": "^6.3.5",
8585
"vitest": "^3.2.3"
8686
},

src/correctColor/__tests__/correctColor.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getMeasuredColors, getReferenceColors } from '../utils/formatData.js';
33
import { getImageColors } from '../utils/getImageColors.js';
44

55
import { polish } from './testUtils/imageColors.js';
6-
import { referenceColorCard } from './testUtils/referenceColorCard.js';
6+
import { referenceColorCard } from '../utils/referenceColorCard.ts';
77

88
test('RGB image should not change', () => {
99
const image = testUtils.createRgbImage([[0, 0, 0, 10, 10, 10, 20, 20, 20]]);

src/correctColor/utils/formatData.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { RgbColor } from 'colord';
22
import { colord, extend } from 'colord';
33
import labPlugin from 'colord/plugins/lab';
44

5-
import type { ColorCard } from '../__tests__/testUtils/referenceColorCard.js';
5+
import type { ColorCard } from './referenceColorCard.ts';
66
import { getRegressionVariables } from '../correctColor.js';
77

88
// We can't use ts-expect-error because it's not an error when compiling for CJS.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { Image } from '../../Image.js';
2+
import { getPerspectiveWarp, order4Points } from '../getPerspectiveWarp.js';
3+
4+
describe('4 points sorting', () => {
5+
test('basic sorting test', () => {
6+
const points = [
7+
{ column: 0, row: 100 },
8+
{ column: 0, row: 0 },
9+
{ column: 100, row: 1 },
10+
{ column: 100, row: 100 },
11+
];
12+
13+
const result = order4Points(points);
14+
expect(result).toEqual([
15+
{ column: 0, row: 0 },
16+
{ column: 100, row: 1 },
17+
{ column: 100, row: 100 },
18+
{ column: 0, row: 100 },
19+
]);
20+
});
21+
test('inclined square', () => {
22+
const points = [
23+
{ column: 45, row: 0 },
24+
{ column: 0, row: 45 },
25+
{ column: 45, row: 90 },
26+
{ column: 90, row: 45 },
27+
];
28+
29+
const result = order4Points(points);
30+
expect(result).toEqual([
31+
{ column: 0, row: 45 },
32+
{ column: 90, row: 45 },
33+
{ column: 45, row: 0 },
34+
{ column: 45, row: 90 },
35+
]);
36+
});
37+
test('basic sorting test', () => {
38+
const points = [
39+
{ column: 155, row: 195 },
40+
{ column: 154, row: 611 },
41+
{ column: 858.5, row: 700 },
42+
{ column: 911.5, row: 786 },
43+
];
44+
45+
const result = order4Points(points);
46+
expect(result).toEqual([
47+
{ column: 155, row: 195 },
48+
49+
{ column: 858.5, row: 700 },
50+
{ column: 911.5, row: 786 },
51+
{ column: 154, row: 611 },
52+
]);
53+
});
54+
});
55+
56+
describe('warping tests', () => {
57+
it('resize without rotation', () => {
58+
const image = new Image(3, 3, {
59+
data: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]),
60+
colorModel: 'GREY',
61+
});
62+
const points = [
63+
{ column: 0, row: 0 },
64+
{ column: 2, row: 0 },
65+
{ column: 1, row: 2 },
66+
{ column: 0, row: 2 },
67+
];
68+
const matrix = getPerspectiveWarp(points);
69+
const result = image.transform(matrix.matrix, { inverse: true });
70+
expect(result.width).not.toBeLessThan(2);
71+
expect(result.height).not.toBeLessThan(2);
72+
expect(result.width).not.toBeGreaterThan(3);
73+
expect(result.height).not.toBeGreaterThan(3);
74+
});
75+
it('resize without rotation 2', () => {
76+
const image = new Image(4, 4, {
77+
data: new Uint8Array([
78+
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
79+
]),
80+
colorModel: 'GREY',
81+
});
82+
83+
const points = [
84+
{ column: 0, row: 0 },
85+
{ column: 3, row: 0 },
86+
{ column: 2, row: 1 },
87+
{ column: 0, row: 1 },
88+
];
89+
const matrix = getPerspectiveWarp(points);
90+
const result = image.transform(matrix.matrix, { inverse: true });
91+
expect(result.width).not.toBeLessThan(3);
92+
expect(result.height).not.toBeLessThan(1);
93+
expect(result.width).not.toBeGreaterThan(4);
94+
expect(result.height).not.toBeGreaterThan(4);
95+
});
96+
});
97+
98+
describe('openCV comparison', () => {
99+
test('nearest interpolation plants', () => {
100+
const image = testUtils.load('opencv/plants.png');
101+
const openCvResult = testUtils.load(
102+
'opencv/test_perspective_warp_plants_nearest.png',
103+
);
104+
105+
const points = [
106+
{ column: 858.5, row: 9 },
107+
{ column: 911.5, row: 786 },
108+
{ column: 154.5, row: 611 },
109+
{ column: 166.5, row: 195 },
110+
];
111+
const matrix = getPerspectiveWarp(points, {
112+
width: 1080,
113+
height: 810,
114+
});
115+
const result = image.transform(matrix.matrix, {
116+
inverse: true,
117+
interpolationType: 'nearest',
118+
});
119+
const croppedPieceOpenCv = openCvResult.crop({
120+
origin: { column: 45, row: 0 },
121+
width: 100,
122+
height: 100,
123+
});
124+
125+
const croppedPiece = result.crop({
126+
origin: { column: 45, row: 0 },
127+
width: 100,
128+
height: 100,
129+
});
130+
131+
expect(result.width).toEqual(openCvResult.width);
132+
expect(result.height).toEqual(openCvResult.height);
133+
expect(croppedPiece).toEqual(croppedPieceOpenCv);
134+
});
135+
136+
test('nearest interpolation card', () => {
137+
const image = testUtils.load('opencv/card.png');
138+
const openCvResult = testUtils.load(
139+
'opencv/test_perspective_warp_card_nearest.png',
140+
);
141+
const points = [
142+
{ column: 55, row: 140 },
143+
{ column: 680, row: 38 },
144+
{ column: 840, row: 340 },
145+
{ column: 145, row: 460 },
146+
];
147+
const matrix = getPerspectiveWarp(points, {
148+
width: 700,
149+
height: 400,
150+
});
151+
const result = image.transform(matrix.matrix, {
152+
inverse: true,
153+
interpolationType: 'nearest',
154+
width: 700,
155+
height: 400,
156+
});
157+
const croppedPieceOpenCv = openCvResult.crop({
158+
origin: { column: 45, row: 0 },
159+
width: 5,
160+
height: 5,
161+
});
162+
163+
const croppedPiece = result.crop({
164+
origin: { column: 45, row: 0 },
165+
width: 5,
166+
height: 5,
167+
});
168+
169+
expect(result.width).toEqual(openCvResult.width);
170+
expect(result.height).toEqual(openCvResult.height);
171+
expect(croppedPiece).toEqual(croppedPieceOpenCv);
172+
});
173+
test('nearest interpolation poker card', () => {
174+
const image = testUtils.load('opencv/poker_cards.png');
175+
const openCvResult = testUtils.load(
176+
'opencv/test_perspective_warp_poker_cards_nearest.png',
177+
);
178+
179+
const points = [
180+
{ column: 1100, row: 660 },
181+
{ column: 680, row: 660 },
182+
{ column: 660, row: 290 },
183+
{ column: 970, row: 290 },
184+
];
185+
const matrix = getPerspectiveWarp(points);
186+
const result = image.transform(matrix.matrix, {
187+
inverse: true,
188+
interpolationType: 'nearest',
189+
height: matrix.height,
190+
width: matrix.width,
191+
});
192+
193+
const cropped = result.crop({
194+
origin: { column: 10, row: 10 },
195+
width: 100,
196+
height: 100,
197+
});
198+
const croppedCV = openCvResult.crop({
199+
origin: { column: 10, row: 10 },
200+
width: 100,
201+
height: 100,
202+
});
203+
204+
expect(result.width).toEqual(openCvResult.width);
205+
expect(result.height).toEqual(openCvResult.height);
206+
expect(cropped).toEqual(croppedCV);
207+
});
208+
});
209+
210+
describe('error testing', () => {
211+
test("should throw if there aren't 4 points", () => {
212+
expect(() => {
213+
getPerspectiveWarp([{ column: 1, row: 1 }]);
214+
}).toThrow(
215+
'The array pts must have four elements, which are the four corners. Currently, pts have 1 elements',
216+
);
217+
});
218+
test('should throw if either only width or only height are defined', () => {
219+
expect(() => {
220+
getPerspectiveWarp(
221+
[
222+
{ column: 1, row: 1 },
223+
{ column: 2, row: 1 },
224+
{ column: 2, row: 2 },
225+
{ column: 1, row: 2 },
226+
],
227+
{ width: 10 },
228+
);
229+
}).toThrow(
230+
'Invalid dimensions: `height` is missing. Either provide both width and height, or omit both to auto-calculate dimensions.',
231+
);
232+
});
233+
});

src/geometry/__tests__/transform.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,5 +121,5 @@ test('should throw if matrix has wrong size', () => {
121121
];
122122
expect(() => {
123123
img.transform(translation);
124-
}).toThrow('transformation matrix must be 2x3. Received 2x4');
124+
}).toThrow('transformation matrix must be 2x3 or 3x3. Received 2x4');
125125
});

0 commit comments

Comments
 (0)