Skip to content

Commit 5b0a970

Browse files
docs: add watershed tutorial (#83)
1 parent c89df71 commit 5b0a970

17 files changed

+2398
-0
lines changed
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
In this tutorial we will talk about watershed algorithm, why it is used and the basics of how it is implemented. We will also cover some pitfalls that you might encounter during its use.
2+
3+
## Synopsis
4+
5+
Here is a quick summary of this tutorial.
6+
Watershed algorithm is an advanced image segmentation technique to identify objects when they are too close to each other. The idea is to treat an image as a landscape. We find extreme(lowest or highest) points in intensity and from there "fill" the regions until the intensity threshold is reached. However, to use watershed there are additional steps that need to be taken before actually applying the function itself.
7+
8+
![Input image](./images/watershed/input.jpg)
9+
10+
First you must have a grayscale image. If this is not the case, use `grey()` method to grayscale it. Then blur the image. The choice of a blurring technique depends on what kind of image is to blur, but regular blur will do. Be careful while setting the kernel size. If it gets too big, objects' edges and minor details start to deteriorate.
11+
12+
After that, a threshold needs to be defined. It can be defined as an arbitrary value, but we recommend to compute a threshold mask from the image of interest.
13+
Result can vary from one threshold algorithm to another so take a look at a few of them to see which one fits your needs.
14+
15+
Then extrema need to be found and filtered based on their intensity and position. You should get one point per region.
16+
17+
:::caution
18+
Don't forget! If you look for brightest regions then you need to specify `kind` option as `maximum`, if darkest - `minimum`.
19+
:::
20+
21+
With that, you are ready to use watershed. Your code should resemble something like this:
22+
23+
```ts
24+
if (image.colorModel != 'GREY') {
25+
image = image.grey();
26+
}
27+
let blurredImage = image.blur({ width: 3, height: 3 });
28+
const mask = blurredImage.threshold({ algorithm: 'isodata' });
29+
const points = getExtrema(blurredImage, {
30+
kind: 'minimum',
31+
algorithm: 'cross',
32+
mask,
33+
});
34+
const filteredPoints = removeClosePoints(points, blurredImage, {
35+
distance: 17,
36+
kind: 'minimum',
37+
});
38+
const roiMapManager = waterShed(blurredImage, { points: filteredPoints, mask });
39+
```
40+
41+
This will provide a map of all regions of interest(black ROIs are colored here):
42+
43+
![Final result](./images/watershed/WithMaskExtrema.png)
44+
45+
Below you will find a detailed review of all the steps.
46+
47+
## Why is watershed necessary?
48+
49+
[Threshold](../Features/Operations/Threshold.md 'internal link on threshold') is a great segmentation tool for finding objects, but it works only if objects are clearly separated from each other.
50+
51+
Sometimes objects can be too close to each other and the binary image takes it as a giant region of interest, which is not the desired result.
52+
53+
Let's take a look at this image of cell division. The cells here are quite close to each other and if we call a default threshold function and color output regions it will be something like this:
54+
55+
| Input image | What threshold mask looks like |
56+
| -------------------------------------------- | ------------------------------------------- |
57+
| ![Input image](./images/watershed/input.jpg) | ![Mask output](./images/watershed/OTSU.png) |
58+
59+
Two massive regions while, in reality, there are multiple cells there.
60+
This is where the watershed algorithm comes in.
61+
62+
![Watershed Segmentation](./images/watershed/watershedSegmentation.png)
63+
64+
The idea behind watershed is that it treats an image as topographic landscape of intensity where regions of interest's(ROIs') extreme points serve as starting points for basins that should be "filled". Once these basins start getting filled, since they already belong to different regions from the start, even if they touch each other, regions do not get mixed, as if they were limited by a border. Hence is the name: watershed filter.
65+
66+
For instance, here is the same image with applied watershed:
67+
68+
| Input image | What watershed found(in color) |
69+
| -------------------------------------------- | ---------------------------------------------------------- |
70+
| ![Input image](./images/watershed/input.jpg) | ![Watershed image](./images/watershed/WithMaskExtrema.png) |
71+
72+
Nice and clean output, right?
73+
74+
However, if you try to use watershed like this:
75+
76+
```ts
77+
const roiMapManager = waterShed(image);
78+
```
79+
80+
Most likely, you will extract a mess like this:
81+
82+
![Raw Otsu](./images/watershed/RawOTSU.png)
83+
84+
This is because watershed by default finds all the extrema(starting points) without filtering them. You need to filter those starting points beforehand as well as to find a threshold value to localize these regions.
85+
86+
Let's have a look at the necessary elements for a correct regions output.
87+
88+
:::info
89+
90+
Before starting, check the [color model](../Glossary.md#color-model 'internal link on glossary') of an image. If the image is colored, you need to apply grayscale filter, otherwise the watershed algorithm will not work.
91+
92+
```ts
93+
let image = image.grey();
94+
```
95+
96+
You can take a look at different types of grayscale algorithm on [grayscale page](../Features/Filters/Grayscale.md 'internal link on grayscale') in our "Features" section, but a default grayscale should be enough, since the important aspect is for an image to have only one channel.
97+
:::
98+
99+
## Blurring
100+
101+
First thing that you possibly need to do is to remove [image noise](https://en.wikipedia.org/wiki/Image_noise 'wikipedia link on image noise'). It is especially recommended if the image is of poor quality.
102+
103+
ImageJS has several kinds of blurring:
104+
105+
- [blur filter](../Features/Filters/Blur.md)
106+
107+
- [gaussian blur filter](../Features/Filters/Gaussian%20Blur.md)
108+
109+
- [median filter](../Features/Filters/Median.md)
110+
111+
Each filter serves its own purpose, which we will briefly explain.
112+
113+
#### Blur
114+
115+
It is a basic tool that uses a simple average of the neighboring points and replaces point's value with the average. While mean blur is straightforward and computationally efficient, it may not be the best choice in situations where preserving fine details and edges is crucial.
116+
To use it you need to specify width and height of the kernel:
117+
118+
```ts
119+
let blurredImage = image.blur({ width: 3, height: 3 });
120+
```
121+
122+
#### Gaussian blur
123+
124+
As the name suggests it is similar to blur. However, Gaussian blur uses **weighted** average. This means, that the intensity value is also taken into account during computation. It works better than regular blur but it can be slower. It is effective against high-frequency noise.
125+
To use it you need to specify the size of the kernel. This is one of the ways of doing it.
126+
127+
```ts
128+
let blurredImage = image.gaussianBlur({ sigma: 3 });
129+
```
130+
131+
To discover more options you can visit our "Features" page about [gaussian blur](../Features/Filters/Gaussian%20Blur.md 'internal link on gaussian blur').
132+
133+
#### Median
134+
135+
Median filter sorts all the neighbor values in ascending order and sets the pixel to the median value of this array. Unlike blur, median filter is better at preserving image edges and dealing with salt-and-pepper noise, but it performs worse at the image edges.
136+
To use median filter you need to specify the kernel size and the algorithm to treat borders. How the border algorithm works is not really a goal of this tutorial. Just know that this option is mandatory.
137+
138+
```ts
139+
let blurredImage = image.medianFilter({
140+
cellSize: 3,
141+
borderType: 'reflect101',
142+
});
143+
```
144+
145+
To discover more options you can visit our "Features" page about [median filter](../Features/Filters/Median.md 'internal link on median').
146+
147+
:::caution
148+
For each technique, kernel size must be an odd number in order for algorithm to find the center correctly!
149+
:::
150+
151+
Honestly speaking, in this particular case, any filter will do fine. But keep in mind that depending on the image, result from these three filters can vary. And do not overdo it. Giving a kernel too big and the filter will deteriorate the image details.
152+
Here you can see blurring with different kernel size(red numbers). By kernel of size 9 it becomes difficult to see the regions' boundaries.
153+
154+
![Different blurs](./images/watershed/Blurs.png)
155+
156+
Any further blurring will deteriorate the image completely and make it impossible to find regions correctly.
157+
158+
## Finding threshold mask for watershed
159+
160+
Although threshold mask cannot precisely locate the desired regions, it is a crucial algorithm for successful watershed application because you will be using this mask on multiple occasions to significantly improve your output result. It allows watershed to get a general location of ROIs by separating background from the foreground where objects are situated. This in turn simplifies the search for extrema and borders of the regions.
161+
162+
Here you can see how thresholding works with different algorithms.
163+
164+
![Mask algorithms](./images/watershed/maskCombo.png)
165+
166+
As you can see, different algorithms provide different results. Here we want to analyze the cells in the middle, so a good choice would be `isodata` or `intermodes`.
167+
168+
```ts
169+
const mask = blurredImage.threshold({ algorithm: 'isodata' });
170+
```
171+
172+
## Finding extrema
173+
174+
[Finding extrema](https://en.wikipedia.org/wiki/Maximum_and_minimum 'wikipedia link on extrema') is one of the most crucial aspects for watershed because these are the starting points for each region of interest. However, unfiltered, there can be tens or even hundreds of those on the image, as you can see.
175+
176+
![Extrema unfiltered](./images/watershed/Cross5LI.jpg)
177+
178+
So how to spot the correct ones?
179+
180+
There are two functions that are used for finding extrema: `getExtrema` and `removeClosePoints`.
181+
182+
#### `getExtrema`
183+
184+
This function searches for all local extrema(minima in case of this image). It checks each point for the values around. If all the neighbors are smaller, the point in-check becomes the minima(for maxima it checks if all values are bigger).
185+
In the end it returns all extreme points of the image:
186+
187+
```ts
188+
const points = getExtrema(
189+
blurredImage,
190+
{ kind: 'minimum', algorithm: 'square' },
191+
mask,
192+
);
193+
```
194+
195+
:::tip
196+
This is where a mask can be useful. Adding a mask improves the precision of the algorithm, so it is highly recommended to add it as a parameter (Here ROIs are colored for visual aid).
197+
198+
| Watershed ROIs from extrema without mask | Watershed ROIs from extrema with mask |
199+
| :------------------------------------------------------------: | :-----------------------------------------------------------: |
200+
| ![No mask in getExtrema](./images/watershed/NoMaskExtrema.png) | ![Mask in getExtrema](./images/watershed/WithMaskExtrema.png) |
201+
202+
You can notice small particles that the `getExtrema` picks on. It is not very crucial for visual representation of regions but it is an unnecessary interference if there is a need for more complex analysis. All those spots will be considered as regions of interest as well and you will need to make extra steps to remove them.
203+
204+
:::
205+
206+
In `getExtrema` function there are three algorithm shapes that represent the searching area (checked points are colored in light red):
207+
208+
| Algorithm | What it is |
209+
| :----------------------------------------------------------------------: | :----------------------------------------------------------: |
210+
| ![Cross](./images/watershed/cross.svg 'internal link on cross image') | Checks extremum in 4 directions: up,down,left and right. |
211+
| ![Square](./images/watershed/square.svg 'internal link on square image') | Checks extremum within all neighboring points. |
212+
| ![Star](./images/watershed/star.svg 'internal link on star image') | Checks extremum beyond the neighbors within main directions. |
213+
214+
The chosen algorithm changes the size of the area that it checks.
215+
216+
But even with this, `getExtrema` can only give us a smaller number of local extrema. Moreover, if overdone, it can neglect certain extrema that would be considered correct.
217+
218+
![Different algorithms and extrema](./images/watershed/extremaAlgos.png)
219+
220+
#### `removeClosePoints`
221+
222+
This is where another function can be used: `removeClosePoints`. With `distance` option this function can weed out local minima so that only those points that are within bigger or equal distance are left.
223+
224+
As you can see, depending on the distance between points, number of extrema gets reduced to the correct amount. Just like with `getExtrema` algorithms, do not overdo it. With a distance too big between extrema, you will inevitably lose some regions of interest.
225+
226+
![Filtering extrema](./images/watershed/FilterExtrema.png)
227+
228+
With some fine-tuning of those two functions you should get the correct number of minima.
229+
For instance, in case of this image, extrema can be obtained with these parameters:
230+
231+
```ts
232+
//Don't forget to explicitly specify the kind of points you are looking for.
233+
const points = getExtrema(
234+
blurredImage,
235+
{ kind: 'minimum', algorithm: 'cross' },
236+
mask,
237+
);
238+
const filteredPoints = removeClosePoints(points, blurredImage, {
239+
distance: 17,
240+
kind: 'minimum',
241+
});
242+
```
243+
244+
And this is how extrema will now be situated:
245+
246+
![Extrema positions](./images/watershed/ISODATATransformTest.png)
247+
248+
## Applying watershed
249+
250+
At this point we covered every important parameter for watershed to work, so all is left is to apply extrema and mask that we calculated before:
251+
252+
```ts
253+
const roiMapManager = waterShed(blurredImage, { points: filteredPoints, mask });
254+
```
255+
256+
After extracting black ROIs you will receive these regions(colored):
257+
258+
![Final result](./images/watershed/WithMaskExtrema.png)
259+
260+
:::info
261+
It is worth mentioning, however, that mask is not the only way of finding ROIs through watershed. Another way of applying watershed is to pass the threshold value directly. While looking for threshold we looked for a mask, but we can also find the threshold value that the mask is based on.
262+
Thus, by using `computeThreshold()` function we can pass its result like this:
263+
264+
```ts
265+
//Mask was using `isodata` algorithm, so we use the same algorithm here.
266+
const thresholdValue = computeThreshold(blurredImage, 'isodata');
267+
//Watershed's threshold option is an index between 0 and 1.
268+
//So you need to divide the received value from computeThreshold by
269+
//maximum value of an image to receive the ratio.
270+
const roiMapManager = waterShed(blurredImage, {
271+
points: filteredPoints,
272+
threshold: thresholdValue / image.maxValue,
273+
});
274+
```
275+
276+
It will provide the same result as if a threshold mask was used.
277+
:::
160 KB
Loading
50.1 KB
Loading
78.1 KB
Loading
53.2 KB
Loading
52.3 KB
Loading
7.82 KB
Loading
49.8 KB
Loading
51.1 KB
Loading

0 commit comments

Comments
 (0)