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

Commit 6e2b57c

Browse files
authored
feat: add cropRectangle (#439)
Closes: #436
1 parent 4d254d3 commit 6e2b57c

File tree

11 files changed

+314
-12
lines changed

11 files changed

+314
-12
lines changed

src/Image.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ import {
3737
GradientFilterOptions,
3838
hypotenuse,
3939
HypotenuseOptions,
40+
increaseContrast,
41+
IncreaseContrastOptions,
4042
invert,
4143
InvertOptions,
4244
level,
4345
LevelOptions,
44-
increaseContrast,
45-
IncreaseContrastOptions,
4646
medianFilter,
4747
MedianFilterOptions,
4848
pixelate,
@@ -86,14 +86,16 @@ import {
8686
copyTo,
8787
CopyToOptions,
8888
crop,
89+
cropAlpha,
8990
CropAlphaOptions,
9091
CropOptions,
92+
cropRectangle,
93+
CropRectangleOptions,
9194
grey,
9295
paintMaskOnImage,
9396
PaintMaskOnImageOptions,
9497
split,
9598
} from './operations';
96-
import { cropAlpha } from './operations/cropAlpha';
9799
import { colorModels, ImageColorModel } from './utils/constants/colorModels';
98100
import { getMinMax } from './utils/getMinMax';
99101
import { validateChannel, validateValue } from './utils/validators/validators';
@@ -842,6 +844,17 @@ export class Image {
842844
return crop(this, options);
843845
}
844846

847+
/**
848+
* Crop an oriented rectangle from the image.
849+
* If the rectangle's length or width are not an integers, its dimension is expanded in both directions such as the length and width are integers.
850+
* @param points - The points of the rectangle. Points must be circling around the rectangle (clockwise or anti-clockwise)
851+
* @param options - Crop options, see {@link CropRectangleOptions}
852+
* @returns The cropped image. The orientation of the image is the one closest to the rectangle passed as input.
853+
*/
854+
public cropRectangle(points: Point[], options?: CropRectangleOptions) {
855+
return cropRectangle(this, points, options);
856+
}
857+
845858
/**
846859
* Crops the image based on the alpha channel
847860
* This removes lines and columns where the alpha channel is lower than a threshold value.

src/geometry/transform.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { inverse, Matrix } from 'ml-matrix';
22

33
import { Image } from '../Image';
44
import { getClamp } from '../utils/clamp';
5-
import { getBorderInterpolation, BorderType } from '../utils/interpolateBorder';
5+
import { BorderType, getBorderInterpolation } from '../utils/interpolateBorder';
66
import {
77
getInterpolationFunction,
88
InterpolationType,

src/maskAnalysis/utils/getAngle.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { difference, normalize, Point } from '../../utils/geometry/points';
22

33
/**
44
* The angle in radians of a vector relatively to the x axis.
5-
* The angle is positive in the cloxkwise direction.
5+
* The angle is positive in the clockwise direction.
66
* This is an optimized version because it assumes that one of
77
* the points is on the line y = 0.
88
* @param p1 - First point.
99
* @param p2 - Second point.
10-
* @returns Rotation angle in radians to make the line horizontal.
10+
* @returns Rotation angle in radians to make the line horizontal. -π <= angle <= π.
1111
*/
1212
export function getAngle(p1: Point, p2: Point): number {
1313
const diff = difference(p2, p1);
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Image } from '../../Image';
2+
import { rotatePoint } from '../../point/operations';
3+
import { Point } from '../../utils/geometry/points';
4+
import { assert } from '../../utils/validators/assert';
5+
6+
test('straight rectangle top left', () => {
7+
const image = testUtils.createGreyImage([
8+
[1, 2, 3],
9+
[4, 5, 6],
10+
[7, 8, 9],
11+
]);
12+
const points = testUtils.createPoints([0, 0], [2, 0], [2, 2], [0, 2]);
13+
14+
const result = image.cropRectangle(points, { interpolationType: 'nearest' });
15+
expect(result).toMatchImage(
16+
testUtils.createGreyImage([
17+
[1, 2],
18+
[4, 5],
19+
]),
20+
);
21+
});
22+
23+
test('straight rectangle bottom right', () => {
24+
const image = testUtils.createGreyImage([
25+
[1, 2, 3],
26+
[4, 5, 6],
27+
[7, 8, 9],
28+
]);
29+
const points = testUtils.createPoints([1, 3], [3, 3], [3, 1], [1, 1]);
30+
31+
const result = image.cropRectangle(points);
32+
expect(result).toMatchImage(
33+
testUtils.createGreyImage([
34+
[5, 6],
35+
[8, 9],
36+
]),
37+
);
38+
});
39+
40+
test('vertical rectangle with small angle', () => {
41+
const image = testUtils.createGreyImage([
42+
[1, 2, 3],
43+
[4, 5, 6],
44+
[7, 8, 9],
45+
]);
46+
const points = testUtils
47+
.createPoints([1.5, 0], [2.5, 0], [2.5, 3], [1.5, 3])
48+
.map((p) => rotatePoint(p, { row: 1.5, column: 1.5 }, 0.1));
49+
50+
const expected = testUtils.createGreyImage([[3], [6], [8]]);
51+
expectCropRectangleToMatch({
52+
image,
53+
expected,
54+
points,
55+
});
56+
});
57+
58+
test('horizontal rectangle with small angle', () => {
59+
const image = testUtils.createGreyImage([
60+
[1, 2, 3],
61+
[4, 5, 6],
62+
[7, 8, 9],
63+
]);
64+
const points = testUtils
65+
.createPoints([0, 1.5], [3, 1.5], [3, 2.5], [0, 2.5])
66+
.map((p) => rotatePoint(p, { row: 1.5, column: 1.5 }, 0.1));
67+
68+
const expected = testUtils.createGreyImage([[4, 5, 9]]);
69+
expectCropRectangleToMatch({
70+
image,
71+
expected,
72+
points,
73+
});
74+
});
75+
76+
test('diagonal rectangle oriented slightly > 45 degrees clockwise', () => {
77+
const image = testUtils.createGreyImage([
78+
[1, 2, 3],
79+
[4, 5, 6],
80+
[7, 8, 9],
81+
]);
82+
const points = testUtils
83+
.createPoints([1.5, 0], [2.5, 0], [2.5, 3], [1.5, 3])
84+
.map((p) => rotatePoint(p, { row: 1.5, column: 1.5 }, Math.PI / 4 + 0.01));
85+
86+
// Resulting image is horizontal because the rectangle is closer to the horizontal axis than the vertical one
87+
const expected = testUtils.createGreyImage([[0, 8, 6]]);
88+
expectCropRectangleToMatch({
89+
image,
90+
expected,
91+
points,
92+
});
93+
});
94+
95+
test('diagonal rectangle oriented slightly below 45 degrees', () => {
96+
const image = testUtils.createGreyImage([
97+
[1, 2, 3],
98+
[4, 5, 6],
99+
[7, 8, 9],
100+
]);
101+
102+
const points = testUtils
103+
.createPoints([1.5, 0], [2.5, 0], [2.5, 3], [1.5, 3])
104+
.map((p) => rotatePoint(p, { row: 1.5, column: 1.5 }, Math.PI / 4 - 0.01));
105+
106+
// Resulting image is vertical because the rectangle is closer to the vertical axis than the horizontal one
107+
const expected = testUtils.createGreyImage([[0], [6], [8]]);
108+
expectCropRectangleToMatch({
109+
image,
110+
expected,
111+
points,
112+
});
113+
});
114+
115+
function expectCropRectangleToMatch(options: {
116+
image: Image;
117+
expected: Image;
118+
points: Point[];
119+
}) {
120+
const { image, expected, points } = options;
121+
assert(options.points.length === 4, 'Expected to receive 4 points');
122+
const variants: Point[][] = [];
123+
const pointsReversed = points.slice().reverse();
124+
for (let i = 0; i < 4; i++) {
125+
const variant: Point[] = [];
126+
variants.push(variant);
127+
for (let j = 0; j < 4; j++) {
128+
variant.push(points[(i + j) % 4]);
129+
}
130+
}
131+
for (let i = 0; i < 4; i++) {
132+
const variant: Point[] = [];
133+
variants.push(variant);
134+
for (let j = 0; j < 4; j++) {
135+
variant.push(pointsReversed[(i + j) % 4]);
136+
}
137+
}
138+
139+
for (const points of variants) {
140+
const result = image.cropRectangle(points, {
141+
interpolationType: 'nearest',
142+
});
143+
expect(result).toMatchImage(expected);
144+
}
145+
}

src/operations/cropRectangle.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Image } from '../Image';
2+
import { transform, TransformOptions } from '../geometry';
3+
import { getAngle } from '../maskAnalysis/utils/getAngle';
4+
import { rotatePoint } from '../point/operations';
5+
import { Point } from '../utils/geometry/points';
6+
7+
export type CropRectangleOptions = Omit<
8+
TransformOptions,
9+
'width' | 'height' | 'inverse' | 'fullImage'
10+
>;
11+
12+
/**
13+
* Crop an oriented rectangle from an image.
14+
* If the rectangle's length or width are not an integers, its dimension is expanded in both directions such as the length and width are integers.
15+
* @param image - The input image
16+
* @param points - The points of the rectangle. Points must be circling around the rectangle (clockwise or anti-clockwise). The validity of the points passed is assumed and not checked.
17+
* @param options - Crop options, see {@link CropRectangleOptions}
18+
* @returns The cropped image. The orientation of the image is the one closest to the rectangle passed as input.
19+
*/
20+
export function cropRectangle(
21+
image: Image,
22+
points: Point[],
23+
options?: CropRectangleOptions,
24+
): Image {
25+
if (points.length !== 4) {
26+
throw new Error('The points array must contain 4 points');
27+
}
28+
29+
// get the smallest possible angle which puts the rectangle in an upright position
30+
const angle = getSmallestAngle(points);
31+
32+
const center: Point = {
33+
row: (points[0].row + points[2].row) / 2,
34+
column: (points[0].column + points[2].column) / 2,
35+
};
36+
37+
// Rotated points form an upright rectangle
38+
const rotatedPoints = points.map((p) => rotatePoint(p, center, angle));
39+
const [p1, p2, p3] = rotatedPoints;
40+
41+
const originalWidth = Math.max(
42+
Math.abs(p1.column - p2.column),
43+
Math.abs(p2.column - p3.column),
44+
);
45+
const originalHeight = Math.max(
46+
Math.abs(p1.row - p2.row),
47+
Math.abs(p2.row - p3.row),
48+
);
49+
50+
// Deal with numerical imprecision when the rectangle actually had a whole number width or height
51+
const width = Math.min(
52+
Math.ceil(originalWidth),
53+
Math.ceil(originalWidth - 1e-10),
54+
);
55+
const height = Math.min(
56+
Math.ceil(originalHeight),
57+
Math.ceil(originalHeight - 1e-10),
58+
);
59+
60+
// Top left position of the upright rectangle after normalization of width and height
61+
const expandedTopLeft = {
62+
row:
63+
Math.min(...rotatedPoints.map((p) => p.row)) -
64+
(height - originalHeight) / 2,
65+
column:
66+
Math.min(...rotatedPoints.map((p) => p.column)) -
67+
(width - originalWidth) / 2,
68+
};
69+
70+
const translation = rotatePoint(expandedTopLeft, center, -angle);
71+
72+
const angleCos = Math.cos(-angle);
73+
const angleSin = Math.sin(-angle);
74+
const matrix = [
75+
[angleCos, -angleSin, translation.column],
76+
[angleSin, angleCos, translation.row],
77+
];
78+
79+
return transform(image, matrix, {
80+
inverse: true,
81+
width,
82+
height,
83+
...options,
84+
});
85+
}
86+
87+
/**
88+
* Get the smallest angle to put the rectangle in an upright position
89+
* @param points - 2 points forming a line
90+
* @returns The angle in radians
91+
*/
92+
function getSmallestAngle(points: Point[]): number {
93+
// Angle respective to horizontal, -π/2 and π/2
94+
let angleHorizontal = -getAngle(points[1], points[0]);
95+
96+
if (angleHorizontal > Math.PI / 2) {
97+
angleHorizontal -= Math.PI;
98+
} else if (angleHorizontal < -Math.PI / 2) {
99+
angleHorizontal += Math.PI;
100+
}
101+
102+
// angle is between -π/4 and π/4
103+
let angle = angleHorizontal;
104+
if (Math.abs(angleHorizontal) > Math.PI / 4) {
105+
angle =
106+
angleHorizontal > 0
107+
? -Math.PI / 2 + angleHorizontal
108+
: Math.PI / 2 + angleHorizontal;
109+
}
110+
return angle;
111+
}

src/operations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export * from './threshold';
77
export * from './grey';
88
export * from './copyTo';
99
export * from './crop';
10+
export * from './cropAlpha';
11+
export * from './cropRectangle';
1012
export * from './operations.types';
1113
export * from './paintMaskOnImage';
1214
export * from './paintMaskOnMask';

src/point/operations.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Point } from '../utils/geometry/points';
2+
3+
/**
4+
* Rotate a point around a center by a given angle.
5+
* @param point - The point to rotate
6+
* @param rotationCenter - The center of rotation
7+
* @param angle - The angle of rotation in radians
8+
* @returns The rotated point
9+
*/
10+
export function rotatePoint(
11+
point: Point,
12+
rotationCenter: Point,
13+
angle: number,
14+
): Point {
15+
const angleCos = Math.cos(angle);
16+
const angleSin = Math.sin(angle);
17+
18+
const column =
19+
point.column * angleCos -
20+
point.row * angleSin +
21+
(1 - angleCos) * rotationCenter.column +
22+
rotationCenter.row * angleSin;
23+
const row =
24+
point.column * angleSin +
25+
point.row * angleCos +
26+
(1 - angleCos) * rotationCenter.row -
27+
rotationCenter.column * angleSin;
28+
return { column, row };
29+
}

src/stack/compute/meanImage.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Image } from '../../Image';
22
import { Stack } from '../../Stack';
3-
43
import { checkProcessable } from '../utils/checkProcessable';
54

65
/**

src/stack/compute/medianImage.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import quickMedian from 'median-quickselect';
33

44
import { Image } from '../../Image';
55
import { Stack } from '../../Stack';
6-
76
import { checkProcessable } from '../utils/checkProcessable';
87

98
/**

src/stack/compute/minImage.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Image } from '../../Image';
22
import { Stack } from '../../Stack';
3-
43
import { checkProcessable } from '../utils/checkProcessable';
54

65
/**

0 commit comments

Comments
 (0)