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

Commit ecab15e

Browse files
feat: add watershed filter (#379)
* feat: add watershed filter * feat: add threshold computation to watershed filter * test: add a testing case for threshold * fix: update package.json * fix: fix eslint errors * fix: fix eslint errors in testing file * test: add test for coverage * fix: fix the problem with return values and improve extrema check * test: add testing cases * fix: fix eslint error * fix: remove unnecessary points check * fix: fix RoiMapManager computation * fix: fix eslint error * refactor: add kind:'bw' to get all the existing rois * feat: add a helper function to get Int16Arrays for easier presentation and debugging * docs: add documentation to getInt16Array function * test: refactor output arrays for better presentation and simpler debugging and change test names * refactor: remove 'kind' option and adjust the code * chore: move watershed to roi folder * chore: add watershed export to index.ts * chore: fix eslint errors
1 parent f7b46a3 commit ecab15e

File tree

7 files changed

+368
-3
lines changed

7 files changed

+368
-3
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"fast-png": "^6.2.0",
5252
"image-type": "^4.1.0",
5353
"jpeg-js": "^0.4.4",
54+
"js-priority-queue": "^0.1.5",
5455
"median-quickselect": "^1.0.1",
5556
"ml-affine-transform": "^1.0.3",
5657
"ml-convolution": "^2.0.0",
@@ -66,6 +67,7 @@
6667
"@microsoft/api-extractor": "^7.36.3",
6768
"@tailwindcss/forms": "^0.5.4",
6869
"@types/jest": "^29.5.3",
70+
"@types/js-priority-queue": "^0.0.6",
6971
"@types/jest-image-snapshot": "^6.2.0",
7072
"@types/node": "^20.4.8",
7173
"@types/picomatch": "^2.3.0",

src/roi/RoiMapManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export class RoiMapManager implements RoiManager {
8181
}
8282

8383
public getRoiById(roiID: number): Roi {
84-
const rois = this.getRois();
84+
const rois = this.getRois({ kind: 'bw' });
8585
const foundRoi = rois.find((roi) => roi.id === roiID);
8686
if (!foundRoi) {
8787
throw new Error(`invalid ID: ${roiID}`);

src/roi/__tests__/waterShed.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { computeThreshold } from '../..';
2+
import { createGreyImage, getInt16Array } from '../../../test/testUtils';
3+
import { waterShed } from '../waterShed';
4+
5+
describe('Test WaterShed Roi generation', () => {
6+
it('test 1,basic test without parameters/options', () => {
7+
const image = createGreyImage([
8+
[3, 3, 3, 3, 3],
9+
[3, 2, 2, 2, 3],
10+
[3, 2, 1, 2, 3],
11+
[3, 2, 2, 2, 3],
12+
[3, 3, 3, 3, 3],
13+
]);
14+
const roiMapManager = waterShed(image, {});
15+
const resultArray = testUtils.getInt16Array(`
16+
-1, -1, -1, -1, -1,
17+
-1, -1, -1, -1, -1,
18+
-1, -1, -1, -1, -1,
19+
-1, -1, -1, -1, -1,
20+
-1, -1, -1, -1, -1,`);
21+
expect(roiMapManager).toEqual({
22+
map: {
23+
data: resultArray,
24+
nbPositive: 0,
25+
nbNegative: 1,
26+
width: 5,
27+
height: 5,
28+
},
29+
whiteRois: [],
30+
blackRois: [],
31+
});
32+
});
33+
34+
it('test 2, waterShed for a grey image', () => {
35+
const image = createGreyImage([
36+
[3, 3, 3, 3, 3, 3, 3, 3, 4, 4],
37+
[3, 3, 2, 2, 2, 3, 3, 3, 4, 4],
38+
[4, 3, 2, 1, 2, 2, 3, 3, 4, 4],
39+
[4, 3, 2, 2, 2, 2, 3, 3, 3, 4],
40+
[4, 4, 4, 3, 2, 3, 2, 3, 3, 4],
41+
[4, 4, 4, 3, 3, 3, 3, 1, 3, 3],
42+
[4, 3, 3, 3, 3, 3, 2, 2, 2, 3],
43+
[4, 4, 3, 3, 3, 3, 2, 2, 2, 2],
44+
[4, 4, 4, 4, 3, 2, 2, 2, 2, 3],
45+
[4, 4, 4, 4, 3, 3, 3, 3, 2, 3],
46+
]);
47+
48+
const roiMapManager = waterShed(image, { threshold: 2 / 255 });
49+
50+
const resultArray = getInt16Array(`
51+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
52+
0, 0,-2,-2,-2, 0, 0, 0, 0, 0,
53+
0, 0,-2,-2,-2,-2, 0, 0, 0, 0,
54+
0, 0,-2,-2,-2,-2, 0, 0, 0, 0,
55+
0, 0, 0, 0,-2, 0, 0, 0, 0, 0,
56+
0, 0, 0, 0, 0, 0, 0,-1, 0, 0,
57+
0, 0, 0, 0, 0, 0,-1,-1,-1, 0,
58+
0, 0, 0, 0, 0, 0,-1,-1,-1,-1,
59+
0, 0, 0, 0, 0,-1,-1,-1,-1, 0,
60+
0, 0, 0, 0, 0, 0, 0, 0,-1, 0,`);
61+
expect(roiMapManager).toEqual({
62+
map: {
63+
data: resultArray,
64+
nbPositive: 0,
65+
nbNegative: 2,
66+
width: 10,
67+
height: 10,
68+
},
69+
whiteRois: [],
70+
blackRois: [],
71+
});
72+
const rois = roiMapManager.getRois({ kind: 'bw' });
73+
expect(rois[0].origin).toEqual({ column: 5, row: 5 });
74+
expect(rois[1].origin).toEqual({ column: 2, row: 1 });
75+
});
76+
77+
it('test 3, with threshold option', () => {
78+
const image = createGreyImage([
79+
[1, 1, 1, 1, 1],
80+
[1, 2, 2, 2, 1],
81+
[1, 2, 3, 2, 1],
82+
[1, 2, 2, 2, 1],
83+
[1, 1, 1, 1, 1],
84+
]);
85+
const invertedImage = image.invert();
86+
const roiMapManager = waterShed(invertedImage, {
87+
threshold: 253 / image.maxValue,
88+
});
89+
const resultArray = getInt16Array(`
90+
0, 0, 0, 0, 0,
91+
0,-1,-1,-1, 0,
92+
0,-1,-1,-1, 0,
93+
0,-1,-1,-1, 0,
94+
0, 0, 0, 0, 0,
95+
`);
96+
expect(roiMapManager).toEqual({
97+
map: {
98+
data: resultArray,
99+
nbPositive: 0,
100+
nbNegative: 1,
101+
width: 5,
102+
height: 5,
103+
},
104+
whiteRois: [],
105+
blackRois: [],
106+
});
107+
const roi1 = roiMapManager.getRoiById(-1);
108+
109+
expect(roi1.surface).toEqual(9);
110+
expect(roi1.width).toEqual(3);
111+
});
112+
it('test 4, waterShed through threshold value', () => {
113+
const image = createGreyImage([
114+
[3, 3, 3, 3, 3, 3, 3, 3, 4, 4],
115+
[3, 3, 2, 2, 2, 3, 3, 3, 4, 4],
116+
[4, 3, 2, 1, 2, 2, 3, 3, 4, 4],
117+
[4, 3, 2, 2, 2, 2, 3, 3, 3, 4],
118+
[4, 4, 4, 3, 2, 3, 3, 3, 3, 4],
119+
[4, 4, 4, 3, 3, 3, 3, 3, 3, 3],
120+
[4, 3, 3, 3, 3, 3, 2, 2, 2, 3],
121+
[4, 4, 3, 3, 3, 3, 2, 1, 2, 2],
122+
[4, 4, 4, 4, 3, 2, 2, 2, 2, 3],
123+
[4, 4, 4, 4, 3, 3, 3, 3, 2, 3],
124+
]);
125+
const threshold = computeThreshold(image, 'otsu');
126+
127+
const roiMapManager = waterShed(image, {
128+
threshold: threshold / image.maxValue,
129+
});
130+
const resultArray = getInt16Array(`
131+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
132+
0, 0,-2,-2,-2, 0, 0, 0, 0, 0,
133+
0, 0,-2,-2,-2,-2, 0, 0, 0, 0,
134+
0, 0,-2,-2,-2,-2, 0, 0, 0, 0,
135+
0, 0, 0, 0,-2, 0, 0, 0, 0, 0,
136+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
137+
0, 0, 0, 0, 0, 0,-1,-1,-1, 0,
138+
0, 0, 0, 0, 0, 0,-1,-1,-1,-1,
139+
0, 0, 0, 0, 0,-1,-1,-1,-1, 0,
140+
0, 0, 0, 0, 0, 0, 0, 0,-1, 0,
141+
`);
142+
expect(roiMapManager).toEqual({
143+
map: {
144+
data: resultArray,
145+
nbPositive: 0,
146+
nbNegative: 2,
147+
width: 10,
148+
height: 10,
149+
},
150+
whiteRois: [],
151+
blackRois: [],
152+
});
153+
});
154+
it('test 5, waterShed through threshold mask and with inverted image', () => {
155+
const image = createGreyImage([
156+
[3, 3, 3, 3, 3, 3, 3, 3, 4, 4],
157+
[3, 3, 2, 2, 2, 3, 3, 8, 4, 4],
158+
[4, 3, 2, 1, 2, 2, 3, 3, 4, 4],
159+
[4, 3, 2, 2, 6, 2, 3, 3, 3, 4],
160+
[4, 4, 4, 3, 2, 3, 3, 3, 3, 4],
161+
[4, 4, 4, 3, 3, 3, 3, 3, 3, 3],
162+
[4, 3, 3, 3, 3, 6, 2, 2, 2, 3],
163+
[4, 4, 3, 3, 3, 3, 2, 1, 2, 2],
164+
[4, 4, 4, 4, 3, 2, 2, 2, 2, 3],
165+
[4, 4, 4, 4, 3, 3, 3, 3, 9, 3],
166+
]);
167+
168+
const mask = image.threshold({ algorithm: 'otsu' });
169+
const roiMapManager = waterShed(image, { mask });
170+
const resultArray = getInt16Array(`
171+
-2, -2, -2, -2, -2, -2, -2, -2, -2, -1,
172+
-2, -2, -2, -2, -2, -2, -2, 0, -1, -1,
173+
-2, -2, -2, -2, -2, -2, -2, -2, -1, -1,
174+
-2, -2, -2, -2, 0, -2, -2, -1, -1, -1,
175+
-2, -2, -2, -2, -1, -2, -1, -1, -1, -1,
176+
-2, -1, -1, -1, -1, -1, -1, -1, -1, -1,
177+
-1, -1, -1, -1, -1, 0, -1, -1, -1, -1,
178+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
179+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
180+
-1, -1, -1, -1, -1, -1, -1, -1, 0, -1,
181+
`);
182+
expect(roiMapManager).toEqual({
183+
map: {
184+
data: resultArray,
185+
nbPositive: 0,
186+
nbNegative: 2,
187+
width: 10,
188+
height: 10,
189+
},
190+
whiteRois: [],
191+
blackRois: [],
192+
});
193+
const rois = roiMapManager.getRois({ kind: 'bw' });
194+
expect(rois[0].origin).toEqual({ column: 0, row: 0 });
195+
expect(rois[0].surface).toEqual(60);
196+
});
197+
});

src/roi/computeRois.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export function computeRois(roiMapManager: RoiMapManager): void {
3737
for (let row = 0; row < map.height; row++) {
3838
for (let column = 0; column < map.width; column++) {
3939
const currentIndex = roiMapManager.getMapValue(column, row);
40-
40+
if (currentIndex === 0) {
41+
continue;
42+
}
4143
let currentRoi;
4244
if (currentIndex < 0) {
4345
currentRoi = blacks[-currentIndex - 1];

src/roi/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './getMask';
66
export * from './getRois';
77
export * from './Roi';
88
export * from './RoiMapManager';
9+
export * from './waterShed';

src/roi/waterShed.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import PriorityQueue from 'js-priority-queue';
2+
3+
import { RoiMapManager } from '..';
4+
import { Image } from '../Image';
5+
import { Mask } from '../Mask';
6+
import getExtrema from '../compute/getExtrema';
7+
import { Point } from '../geometry';
8+
import { filterPoints } from '../utils/geometry/filterPoints';
9+
import checkProcessable from '../utils/validators/checkProcessable';
10+
/**
11+
* Point interface that is used in the queue data structure.
12+
*/
13+
interface PointWithIntensity {
14+
/**
15+
* @param row - Row of a point.
16+
*/
17+
row: number;
18+
/**
19+
* @param column - Column of a point.
20+
*/
21+
column: number;
22+
/**
23+
* @param intensity - Value of a point.
24+
*/
25+
intensity: number;
26+
}
27+
28+
interface WaterShedOptions {
29+
/**
30+
* @param points - Points which should be filled by watershed filter.
31+
* @default - minimum points from getExtrema() function.
32+
*/
33+
points?: Point[];
34+
/**
35+
* @param mask - A binary image, the same size as the image. The algorithm will fill only if the current pixel in the binary mask is not null.
36+
* @default undefined
37+
*/
38+
mask?: Mask;
39+
/**
40+
* @param threshold - Limit of filling. Maximum value that pixel can have.
41+
* @default 1
42+
*/
43+
threshold?: number;
44+
}
45+
/**
46+
* This method allows to create a ROIMap using the water shed algorithm. By default this algorithm
47+
* will fill the holes and therefore the lowest value of the image (black zones).
48+
* If no points are given, the function will look for all the minimal points.
49+
* If no mask is given the algorithm will completely fill the image.
50+
* Please take care about the value that has be in the mask ! In order to be coherent with the expected mask,
51+
* meaning that if it is a dark zone, the mask will be dark the normal behavior to fill a zone
52+
* is that the mask pixel is clear (value of 0) !
53+
* If you are looking for 'maxima' the image must be inverted before applying the algorithm
54+
* @param image - Image that the filter will be applied to.
55+
* @param options - WaterShedOptions
56+
* @returns RoiMapManager
57+
*/
58+
export function waterShed(
59+
image: Image,
60+
options: WaterShedOptions,
61+
): RoiMapManager {
62+
let { points } = options;
63+
const { mask, threshold = 1 } = options;
64+
const currentImage = image;
65+
checkProcessable(image, {
66+
bitDepth: [8, 16],
67+
components: 1,
68+
});
69+
70+
const fillMaxValue = threshold * image.maxValue;
71+
72+
// WaterShed is done from points in the image. We can either specify those points in options,
73+
// or it is gonna take the minimum locals of the image by default.
74+
if (!points) {
75+
points = getExtrema(image, {
76+
kind: 'minimum',
77+
mask,
78+
});
79+
points = filterPoints(points, image, { kind: 'minimum' });
80+
}
81+
82+
const maskExpectedValue = 0;
83+
84+
const data = new Int16Array(currentImage.size);
85+
const width = currentImage.width;
86+
const height = currentImage.height;
87+
const toProcess = new PriorityQueue({
88+
comparator: (a: PointWithIntensity, b: PointWithIntensity) =>
89+
a.intensity - b.intensity,
90+
strategy: PriorityQueue.BinaryHeapStrategy,
91+
});
92+
for (let i = 0; i < points.length; i++) {
93+
const index = points[i].column + points[i].row * width;
94+
data[index] = -i - 1;
95+
const intensity = currentImage.getValueByIndex(index, 0);
96+
if (intensity <= fillMaxValue) {
97+
toProcess.queue({
98+
column: points[i].column,
99+
row: points[i].row,
100+
intensity,
101+
});
102+
}
103+
}
104+
const dxs = [+1, 0, -1, 0, +1, +1, -1, -1];
105+
const dys = [0, +1, 0, -1, +1, -1, +1, -1];
106+
// Then we iterate through each points
107+
108+
while (toProcess.length > 0) {
109+
const currentPoint = toProcess.dequeue();
110+
const currentValueIndex = currentPoint.column + currentPoint.row * width;
111+
for (let dir = 0; dir < 4; dir++) {
112+
const newX = currentPoint.column + dxs[dir];
113+
const newY = currentPoint.row + dys[dir];
114+
if (newX >= 0 && newY >= 0 && newX < width && newY < height) {
115+
const currentNeighbourIndex = newX + newY * width;
116+
if (
117+
!mask ||
118+
mask.getBitByIndex(currentNeighbourIndex) === maskExpectedValue
119+
) {
120+
const intensity = currentImage.getValueByIndex(
121+
currentNeighbourIndex,
122+
0,
123+
);
124+
if (intensity <= fillMaxValue && data[currentNeighbourIndex] === 0) {
125+
data[currentNeighbourIndex] = data[currentValueIndex];
126+
toProcess.queue({
127+
column: currentPoint.column + dxs[dir],
128+
row: currentPoint.row + dys[dir],
129+
intensity,
130+
});
131+
}
132+
}
133+
}
134+
}
135+
}
136+
const nbNegative = points.length;
137+
const nbPositive = 0;
138+
139+
return new RoiMapManager({
140+
data,
141+
nbPositive,
142+
nbNegative,
143+
width: image.width,
144+
height: image.height,
145+
});
146+
}

0 commit comments

Comments
 (0)