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

Commit e332345

Browse files
246 new roi property ellipse (#309)
* wip: add ellipse as a new feature * chore: get ellipse to scale to match ROI's surface * refactor: change surface name to lower case * test: fix testing case result * refactor: change names of the variables and return types * chore: remove unnecessary packages * refactor: change the way of notation of power * test: fix the testing case to match previous commits * chore: redo the whole implementation of an ellipse property due to a bug * test: change test case results due to refactoring * chore: remove commented import * fix: add a missing bracket to border interface * fix: remove ts-expect errors and fix object property names * chore: remove unused dependency * test: add testing cases for better code coverage * chore: convert angle in ellipse from rad to degrees * test: convert angles to degrees in testing cases * chore: remove unused ml-array-variance * chore: move function getEllipse to a separate folder * refactor: refactor conditions for eigenvalues * docs: add some comments about function and its parameters * chore: scale the ellipse internally instead of calculating the ellipse, comparing it and scaling it * test: fix minorAxis expecting result * chore: remove surface as parameter and nbSD since no longer used * test: add testing case for 1 1 1 mask
1 parent 99513c6 commit e332345

File tree

3 files changed

+305
-1
lines changed

3 files changed

+305
-1
lines changed

src/roi/Roi.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Point } from '../utils/geometry/points';
1212
import { RoiMap } from './RoiMapManager';
1313
import { getBorderPoints } from './getBorderPoints';
1414
import { getMask, GetMaskOptions } from './getMask';
15+
import { Ellipse, getEllipse } from './properties/getEllipse';
1516

1617
interface Border {
1718
connectedID: number; // refers to the roiID of the contiguous ROI
@@ -36,6 +37,7 @@ interface Computed {
3637
fillRatio: number;
3738
internalIDs: number[];
3839
feret: Feret;
40+
ellipse: Ellipse;
3941
centroid: Point;
4042
}
4143
export class Roi {
@@ -392,6 +394,13 @@ export class Roi {
392394
});
393395
}
394396

397+
get ellipse(): Ellipse {
398+
return this.#getComputed('ellipse', () => {
399+
const ellipse = getEllipse(this);
400+
return ellipse;
401+
});
402+
}
403+
395404
/**
396405
* Number of holes in the ROI and their total surface.
397406
* Used to calculate fillRatio.
@@ -608,7 +617,7 @@ export class Roi {
608617
* @param x
609618
*/
610619
computeIndex(y: number, x: number): number {
611-
const roiMap = this.getMap();
620+
const roiMap = this.map;
612621
return (y + this.origin.row) * roiMap.width + x + this.origin.column;
613622
}
614623
}

src/roi/__tests__/ellipse.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { fromMask } from '..';
2+
3+
test('ellipse on a small figure 3x3', () => {
4+
const mask = testUtils.createMask([
5+
[0, 1, 0],
6+
[0, 1, 0],
7+
[0, 1, 0],
8+
]);
9+
const roiMapManager = fromMask(mask);
10+
const rois = roiMapManager.getRois();
11+
const result = rois[0].ellipse;
12+
13+
expect(result).toBeDeepCloseTo({
14+
center: { column: 1, row: 1 },
15+
majorAxis: {
16+
points: [
17+
{ column: NaN, row: Infinity },
18+
{ column: NaN, row: -Infinity },
19+
],
20+
length: Infinity,
21+
angle: NaN,
22+
},
23+
minorAxis: {
24+
points: [
25+
{ column: NaN, row: NaN },
26+
{ column: NaN, row: NaN },
27+
],
28+
length: NaN,
29+
angle: NaN,
30+
},
31+
surface: NaN,
32+
});
33+
});
34+
test('ellipse on a small figure 3x3', () => {
35+
const mask = testUtils.createMask([
36+
[1, 1, 0],
37+
[0, 1, 0],
38+
[0, 0, 0],
39+
]);
40+
const roiMapManager = fromMask(mask);
41+
42+
const rois = roiMapManager.getRois();
43+
const result = rois[0].ellipse;
44+
45+
expect(result).toBeDeepCloseTo({
46+
center: { column: 0.6666666666666666, row: 0.3333333333333333 },
47+
majorAxis: {
48+
points: [
49+
{ column: 1.4978340587735344, row: 1.1645007254402011 },
50+
{ column: -0.1645007254402011, row: -0.4978340587735344 },
51+
],
52+
length: 2.3508963970396173,
53+
angle: -135,
54+
},
55+
minorAxis: {
56+
points: [
57+
{ column: 1.146541384241206, row: -0.14654138424120605 },
58+
{ column: 0.18679194909212726, row: 0.8132080509078727 },
59+
],
60+
length: 1.6247924149339357,
61+
angle: 135,
62+
},
63+
surface: 3,
64+
});
65+
});
66+
67+
test('ellipse on 3x3 cross', () => {
68+
const mask = testUtils.createMask([
69+
[0, 1, 0],
70+
[1, 1, 1],
71+
[0, 1, 0],
72+
]);
73+
const roiMapManager = fromMask(mask);
74+
75+
const rois = roiMapManager.getRois();
76+
const result = rois[0].ellipse;
77+
expect(result).toBeDeepCloseTo({
78+
center: { column: 1, row: 1 },
79+
majorAxis: {
80+
points: [
81+
{ column: 1, row: 2.7841241161527712 },
82+
{ column: 1, row: -0.7841241161527714 },
83+
],
84+
length: 3.5682482323055424,
85+
angle: -90,
86+
},
87+
minorAxis: {
88+
points: [
89+
{ column: 2.7841241161527712, row: 1 },
90+
{ column: -0.7841241161527714, row: 1 },
91+
],
92+
length: 1.7841241161527712,
93+
angle: 180,
94+
},
95+
surface: 5,
96+
});
97+
});
98+
test('ellipse on slightly changed 3x3 cross', () => {
99+
const mask = testUtils.createMask([
100+
[1, 1, 0],
101+
[1, 1, 1],
102+
[0, 1, 0],
103+
]);
104+
const roiMapManager = fromMask(mask);
105+
106+
const rois = roiMapManager.getRois();
107+
const result = rois[0].ellipse;
108+
expect(result).toBeDeepCloseTo({
109+
center: { column: 0.8333333333333334, row: 0.8333333333333334 },
110+
majorAxis: {
111+
points: [
112+
{ column: 2.175183801009782, row: 2.175183801009782 },
113+
{ column: -0.5085171343431153, row: -0.5085171343431155 },
114+
],
115+
length: 3.7953262601294284,
116+
angle: -135,
117+
},
118+
minorAxis: {
119+
points: [
120+
{ column: -0.15768891509232064, row: 1.8243555817589874 },
121+
{ column: 1.8243555817589874, row: -0.15768891509232064 },
122+
],
123+
length: 2.01285390103734,
124+
angle: -45,
125+
},
126+
surface: 6.000000000000002,
127+
});
128+
});
129+
test('ellipse on 4x4 ROI', () => {
130+
const mask = testUtils.createMask([
131+
[0, 0, 1, 1],
132+
[0, 0, 1, 0],
133+
[0, 1, 1, 1],
134+
[1, 1, 1, 0],
135+
]);
136+
const roiMapManager = fromMask(mask);
137+
138+
const rois = roiMapManager.getRois();
139+
const result = rois[0].ellipse;
140+
expect(result).toBeDeepCloseTo({
141+
center: { column: 1.7777777777777777, row: 1.7777777777777777 },
142+
majorAxis: {
143+
points: [
144+
{ column: 0.488918397751106, row: 3.6243064249674615 },
145+
{ column: 3.0666371578044496, row: -0.06875086941190611 },
146+
],
147+
length: 4.503699166851579,
148+
angle: -55.08532670592521,
149+
},
150+
minorAxis: {
151+
points: [
152+
{ column: 0.8646151602330147, row: 1.1403989822180598 },
153+
{ column: 2.690940395322541, row: 2.4151565733374953 },
154+
],
155+
length: 2.544387508597132,
156+
angle: 34.91467329407479,
157+
},
158+
surface: 9.000000000000002,
159+
});
160+
});

src/roi/properties/getEllipse.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { EigenvalueDecomposition } from 'ml-matrix';
2+
import { xVariance, xyCovariance } from 'ml-spectra-processing';
3+
4+
import { FeretDiameter } from '../../maskAnalysis';
5+
import { getAngle } from '../../maskAnalysis/utils/getAngle';
6+
import { assert } from '../../utils/assert';
7+
import { toDegrees } from '../../utils/geometry/angles';
8+
import { Roi } from '../Roi';
9+
10+
export interface Ellipse {
11+
center: {
12+
column: number;
13+
row: number;
14+
};
15+
majorAxis: FeretDiameter;
16+
minorAxis: FeretDiameter;
17+
surface: number;
18+
}
19+
/**
20+
*Calculates ellipse on around ROI
21+
*
22+
* @param roi - region of interest
23+
* @param surface - the surface of ROI that ellipse should match
24+
* @returns Ellipse
25+
*/
26+
export function getEllipse(roi: Roi): Ellipse {
27+
let xCenter = roi.centroid.column;
28+
let yCenter = roi.centroid.row;
29+
30+
let xCentered = roi.points.map((point: number[]) => point[0] - xCenter);
31+
let yCentered = roi.points.map((point: number[]) => point[1] - yCenter);
32+
33+
let centeredXVariance = xVariance(xCentered, { unbiased: false });
34+
let centeredYVariance = xVariance(yCentered, { unbiased: false });
35+
36+
let centeredCovariance = xyCovariance(
37+
{
38+
x: xCentered,
39+
y: yCentered,
40+
},
41+
{ unbiased: false },
42+
);
43+
44+
//spectral decomposition of the sample covariance matrix
45+
let sampleCovarianceMatrix = [
46+
[centeredXVariance, centeredCovariance],
47+
[centeredCovariance, centeredYVariance],
48+
];
49+
let e = new EigenvalueDecomposition(sampleCovarianceMatrix);
50+
let eigenvalues = e.realEigenvalues;
51+
let vectors = e.eigenvectorMatrix;
52+
53+
let radiusMajor: number;
54+
let radiusMinor: number;
55+
let vectorMajor: number[];
56+
let vectorMinor: number[];
57+
58+
assert(eigenvalues[0] <= eigenvalues[1]);
59+
radiusMajor = Math.sqrt(eigenvalues[1]);
60+
radiusMinor = Math.sqrt(eigenvalues[0]);
61+
vectorMajor = vectors.getColumn(1);
62+
vectorMinor = vectors.getColumn(0);
63+
64+
let majorAxisPoint1 = {
65+
column: xCenter + radiusMajor * vectorMajor[0],
66+
row: yCenter + radiusMajor * vectorMajor[1],
67+
};
68+
let majorAxisPoint2 = {
69+
column: xCenter - radiusMajor * vectorMajor[0],
70+
row: yCenter - radiusMajor * vectorMajor[1],
71+
};
72+
let minorAxisPoint1 = {
73+
column: xCenter + radiusMinor * vectorMinor[0],
74+
row: yCenter + radiusMinor * vectorMinor[1],
75+
};
76+
let minorAxisPoint2 = {
77+
column: xCenter - radiusMinor * vectorMinor[0],
78+
row: yCenter - radiusMinor * vectorMinor[1],
79+
};
80+
81+
let majorLength = Math.sqrt(
82+
(majorAxisPoint1.column - majorAxisPoint2.column) ** 2 +
83+
(majorAxisPoint1.row - majorAxisPoint2.row) ** 2,
84+
);
85+
let minorLength = Math.sqrt(
86+
(minorAxisPoint1.column - majorAxisPoint2.column) ** 2 +
87+
(minorAxisPoint1.row - minorAxisPoint2.row) ** 2,
88+
);
89+
90+
let ellipseSurface = (((minorLength / 2) * majorLength) / 2) * Math.PI;
91+
if (ellipseSurface !== roi.surface) {
92+
const scaleFactor = Math.sqrt(roi.surface / ellipseSurface);
93+
radiusMajor *= scaleFactor;
94+
radiusMinor *= scaleFactor;
95+
majorAxisPoint1 = {
96+
column: xCenter + radiusMajor * vectorMajor[0],
97+
row: yCenter + radiusMajor * vectorMajor[1],
98+
};
99+
majorAxisPoint2 = {
100+
column: xCenter - radiusMajor * vectorMajor[0],
101+
row: yCenter - radiusMajor * vectorMajor[1],
102+
};
103+
minorAxisPoint1 = {
104+
column: xCenter + radiusMinor * vectorMinor[0],
105+
row: yCenter + radiusMinor * vectorMinor[1],
106+
};
107+
minorAxisPoint2 = {
108+
column: xCenter - radiusMinor * vectorMinor[0],
109+
row: yCenter - radiusMinor * vectorMinor[1],
110+
};
111+
112+
majorLength *= scaleFactor;
113+
114+
minorLength *= scaleFactor;
115+
ellipseSurface *= scaleFactor ** 2;
116+
}
117+
118+
return {
119+
center: {
120+
column: xCenter,
121+
row: yCenter,
122+
},
123+
majorAxis: {
124+
points: [majorAxisPoint1, majorAxisPoint2],
125+
length: majorLength,
126+
angle: toDegrees(getAngle(majorAxisPoint1, majorAxisPoint2)),
127+
},
128+
minorAxis: {
129+
points: [minorAxisPoint1, minorAxisPoint2],
130+
length: minorLength,
131+
angle: toDegrees(getAngle(minorAxisPoint1, minorAxisPoint2)),
132+
},
133+
surface: ellipseSurface,
134+
};
135+
}

0 commit comments

Comments
 (0)